From 4698925fac8bb9944621f5c0f68d067a4efe3a52 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Sun, 24 May 2026 18:49:31 +0200 Subject: [PATCH 1/6] feat: add `NormalizeListPlaceholders` and enhance support for circular references Added `NormalizeListPlaceholders` processor for consistent transformation of empty list placeholders. Introduced `CyclicAwareNodeProvider` and `VerifyingNodeProvider` interfaces to ensure proper handling of circular dependencies. Implemented `CircularBlueIdCalculator` for calculating circular BlueId sets. Enhanced snapshot handling and validation with `FrozenNodeToBlueIdInput` utilities. --- README.md | 38 +- src/main/java/blue/language/Blue.java | 233 ++++++++- .../blue/language/BlueConformanceFailure.java | 42 ++ .../blue/language/BlueConformanceReport.java | 354 +++++++++++++ .../language/BlueConformanceSuiteRunner.java | 482 ++++++++++++++++++ .../blue/language/BlueFixtureCategory.java | 36 ++ .../conformance/FrozenConformancePlanner.java | 13 +- .../dictionary/DictionaryAwareExporter.java | 2 +- .../mapping/ComplexObjectConverter.java | 17 +- src/main/java/blue/language/merge/Merger.java | 79 ++- .../merge/processor/SchemaPropagator.java | 17 +- .../merge/processor/SchemaVerifier.java | 75 +-- src/main/java/blue/language/model/Node.java | 40 +- .../blue/language/model/NodeDeserializer.java | 348 +++++++++++-- src/main/java/blue/language/model/Schema.java | 130 +++-- .../language/preprocess/Preprocessor.java | 60 ++- .../processor/NormalizeListPlaceholders.java | 146 ++++++ .../language/processor/ContractLoader.java | 9 +- .../processor/DocumentProcessingRuntime.java | 14 +- .../language/processor/ProcessorEngine.java | 7 + .../language/processor/ScopeExecutor.java | 2 +- .../language/provider/BasicNodeProvider.java | 28 +- .../provider/CyclicAwareNodeProvider.java | 12 + .../language/provider/NodeContentHandler.java | 11 +- .../provider/VerifyingNodeProvider.java | 71 +++ .../snapshot/CanonicalOverlayPatchEngine.java | 22 +- .../blue/language/snapshot/FrozenNode.java | 227 ++++++--- .../snapshot/FrozenNodeToBlueIdInput.java | 327 ++++++++++++ .../blue/language/utils/BlueIdCalculator.java | 199 ++++---- .../java/blue/language/utils/BlueIds.java | 72 ++- .../utils/CircularBlueIdCalculator.java | 202 ++++++++ .../language/utils/FrozenTypeMatcher.java | 58 +-- .../blue/language/utils/MergeReverser.java | 61 ++- .../blue/language/utils/NodeExtender.java | 4 + .../blue/language/utils/NodePathAccessor.java | 8 +- .../blue/language/utils/NodePathEditor.java | 11 +- .../blue/language/utils/NodePathSelector.java | 8 + .../language/utils/NodeProviderWrapper.java | 43 +- .../language/utils/NodeToBlueIdInput.java | 339 ++++++++++++ .../language/utils/NodeToMapListOrValue.java | 19 +- .../blue/language/utils/NodeTransformer.java | 40 +- .../blue/language/utils/NodeTypeMatcher.java | 9 +- src/main/java/blue/language/utils/Nodes.java | 52 ++ .../java/blue/language/utils/Properties.java | 2 + .../utils/SchemaToMapListOrValue.java | 82 +++ .../java/blue/language/utils/TypeUtils.java | 10 + src/main/java/blue/language/utils/Types.java | 27 +- .../language/utils/UncheckedObjectMapper.java | 68 ++- .../limits/NodeToPathLimitsConverter.java | 8 +- .../resources/transformation/DefaultBlue.blue | 4 +- .../InferBasicTypesForUntypedValues.blue | 4 +- .../ReplaceInlineTypesWithBlueIds.blue | 4 +- .../transformation/Transformation.blue | 2 +- .../language/BlueConformanceReportTest.java | 308 +++++++++++ .../language/DictionaryProcessorTest.java | 4 +- .../blue/language/ListControlFormsTest.java | 205 ++++++-- .../java/blue/language/ListProcessorTest.java | 22 +- src/test/java/blue/language/ListTest.java | 3 +- .../java/blue/language/MergeReverserTest.java | 57 ++- .../blue/language/NodeDeserializerTest.java | 339 +++++++++++- .../language/NodeToMapListOrValueTest.java | 65 ++- .../java/blue/language/PreprocessorTest.java | 161 +++++- .../language/SchemaVerifierMinLengthTest.java | 3 +- .../blue/language/SchemaVerifierTest.java | 84 ++- .../java/blue/language/SelfReferenceTest.java | 143 +++++- .../SemanticCanonicalizationTest.java | 169 ++++++ src/test/java/blue/language/TestUtils.java | 9 +- .../BlueLanguageConformanceFixtureTest.java | 207 ++++++++ .../mapping/JsonPropertyMappingTest.java | 2 +- ...NodeToObjectConverterNullHandlingTest.java | 27 +- .../mapping/NodeToObjectConverterTest.java | 12 +- .../ContractMappingIntegrationTest.java | 31 +- .../DocumentProcessorBoundaryTest.java | 8 +- .../DocumentProcessorCapabilityTest.java | 15 +- .../processor/DocumentProcessorGasTest.java | 10 +- .../DocumentProcessorGeneralizationTest.java | 9 +- .../DocumentProcessorInitializationTest.java | 9 +- ...umentProcessorSnapshotTransactionTest.java | 10 +- .../DocumentProcessorTerminationTest.java | 8 +- .../processor/DocumentUpdateChannelTest.java | 2 +- .../processor/ProcessEmbeddedTest.java | 23 +- .../processor/TestEventChannelTest.java | 8 +- .../ExternalContractIntegrationTest.java | 2 +- .../BootstrapProviderVerificationTest.java | 105 ++++ .../ProviderCanonicalIngestionTest.java | 136 ++++- .../language/snapshot/FrozenNodeTest.java | 243 ++++++++- .../snapshot/ResolvedSnapshotTest.java | 55 ++ .../language/utils/BlueIdCalculatorTest.java | 239 +++++++-- .../java/blue/language/utils/BlueIdsTest.java | 3 +- .../language/utils/NodePathAccessorTest.java | 50 ++ .../language/utils/NodeTypeMatcherTest.java | 25 +- .../limits/NodeToPathLimitsConverterTest.java | 10 + .../language/utils/limits/PathLimitsTest.java | 5 +- .../blue-language-1.0/fixtures/.gitkeep | 1 + .../fixtures/blueid/B_double_1e0.yaml | 6 + .../fixtures/blueid/B_empty_list.yaml | 6 + .../B_empty_object_list_element_rejected.yaml | 10 + .../fixtures/blueid/B_empty_placeholder.yaml | 10 + .../blueid/B_integer_1_vs_double_1_0.yaml | 7 + .../B_invalid_this_placeholder_rejected.yaml | 7 + ...large_integer_quoted_explicit_integer.yaml | 9 + .../blueid/B_list_sugar_equivalence.yaml | 12 + .../blueid/B_malformed_empty_rejected.yaml | 8 + .../blueid/B_null_list_element_rejected.yaml | 10 + .../blueid/B_object_field_null_removal.yaml | 8 + .../blueid/B_plain_blueid_validation.yaml | 7 + .../fixtures/blueid/B_pos_rejected.yaml | 9 + .../B_previous_invalid_blueid_rejected.yaml | 10 + .../fixtures/blueid/B_replace_rejected.yaml | 10 + .../fixtures/blueid/B_root_empty_object.yaml | 6 + .../fixtures/blueid/B_root_list.yaml | 8 + .../fixtures/blueid/B_root_null_rejected.yaml | 6 + .../blueid/B_root_pure_reference.yaml | 7 + .../fixtures/blueid/B_root_scalar.yaml | 6 + .../blueid/B_scalar_sugar_equivalence.yaml | 8 + ...alias_rejected_in_direct_blueid_input.yaml | 8 + .../B_unquoted_large_integer_rejected.yaml | 7 + .../C_circular_reference_set_ids.yaml | 14 + ...iminary_ids_deterministic_or_rejected.yaml | 12 + ...aceholder_rejected_outside_cyclic_api.yaml | 7 + .../C_three_document_cycle_stable_order.yaml | 18 + ...C_zero_blueid_rejected_in_final_input.yaml | 7 + .../blue-language-1.0/fixtures/manifest.yaml | 177 +++++++ ...ollapse_does_not_produce_mixed_blueid.yaml | 10 + ..._nested_subtree_preserves_node_blueid.yaml | 9 + .../F_collapse_preserves_node_blueid.yaml | 8 + ...F_expand_missing_nested_content_fails.yaml | 9 + ...ested_reference_preserves_node_blueid.yaml | 13 + .../F_expand_preserves_node_blueid.yaml | 11 + ...d_wrong_nested_provider_content_fails.yaml | 11 + .../F_provider_missing_content_fails.yaml | 9 + .../F_provider_wrong_blueid_rejected.yaml | 12 + .../fixtures/resolver/R_blue_imports.yaml | 15 + ...ports_type_itemType_keyType_valueType.yaml | 24 + ..._canonical_overlay_no_previous_no_pos.yaml | 17 + ..._deterministic_for_same_resolved_view.yaml | 11 + ...d_labels_materialize_until_overridden.yaml | 26 + ...tracts_canonicalization_deterministic.yaml | 17 + .../R_contracts_merge_as_content.yaml | 21 + .../resolver/R_enum_integer_vs_double.yaml | 14 + .../R_inherited_append_only_policy.yaml | 15 + .../resolver/R_inherited_item_type.yaml | 13 + .../R_inherited_keyType_valueType.yaml | 15 + .../resolver/R_instance_field_kept.yaml | 11 + ...provider_reference_canonicalizes_back.yaml | 12 + ..._reference_with_overlay_keeps_overlay.yaml | 15 + ...large_integer_minimum_with_type_alias.yaml | 15 + .../resolver/R_schema_value_shapes.yaml | 9 + .../R_source_empty_object_list_to_empty.yaml | 14 + .../resolver/R_source_null_list_to_empty.yaml | 14 + ...l_type_name_description_not_inherited.yaml | 20 + ...liases_removed_from_canonical_overlay.yaml | 15 + .../R_type_derived_field_removed.yaml | 11 + 153 files changed, 7082 insertions(+), 759 deletions(-) create mode 100644 src/main/java/blue/language/BlueConformanceFailure.java create mode 100644 src/main/java/blue/language/BlueConformanceReport.java create mode 100644 src/main/java/blue/language/BlueConformanceSuiteRunner.java create mode 100644 src/main/java/blue/language/BlueFixtureCategory.java create mode 100644 src/main/java/blue/language/preprocess/processor/NormalizeListPlaceholders.java create mode 100644 src/main/java/blue/language/provider/CyclicAwareNodeProvider.java create mode 100644 src/main/java/blue/language/provider/VerifyingNodeProvider.java create mode 100644 src/main/java/blue/language/snapshot/FrozenNodeToBlueIdInput.java create mode 100644 src/main/java/blue/language/utils/CircularBlueIdCalculator.java create mode 100644 src/main/java/blue/language/utils/NodeToBlueIdInput.java create mode 100644 src/main/java/blue/language/utils/SchemaToMapListOrValue.java create mode 100644 src/test/java/blue/language/BlueConformanceReportTest.java create mode 100644 src/test/java/blue/language/conformance/BlueLanguageConformanceFixtureTest.java create mode 100644 src/test/java/blue/language/provider/BootstrapProviderVerificationTest.java create mode 100644 src/test/resources/blue-language-1.0/fixtures/.gitkeep create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_double_1e0.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_list.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_object_list_element_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_placeholder.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_integer_1_vs_double_1_0.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_invalid_this_placeholder_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_large_integer_quoted_explicit_integer.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_list_sugar_equivalence.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_malformed_empty_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_null_list_element_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_object_field_null_removal.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_plain_blueid_validation.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_pos_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_previous_invalid_blueid_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_replace_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_root_empty_object.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_root_list.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_root_null_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_root_pure_reference.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_root_scalar.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_scalar_sugar_equivalence.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_type_alias_rejected_in_direct_blueid_input.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_unquoted_large_integer_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/circular/C_circular_reference_set_ids.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/circular/C_this_placeholder_rejected_outside_cyclic_api.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/circular/C_three_document_cycle_stable_order.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/circular/C_zero_blueid_rejected_in_final_input.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/manifest.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_does_not_produce_mixed_blueid.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_nested_subtree_preserves_node_blueid.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_preserves_node_blueid.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/provider/F_expand_missing_nested_content_fails.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/provider/F_expand_nested_reference_preserves_node_blueid.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/provider/F_expand_preserves_node_blueid.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/provider/F_expand_wrong_nested_provider_content_fails.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/provider/F_provider_missing_content_fails.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/provider/F_provider_wrong_blueid_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_blue_imports.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_blue_imports_type_itemType_keyType_valueType.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_canonical_overlay_no_previous_no_pos.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_canonicalization_deterministic_for_same_resolved_view.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_child_field_labels_materialize_until_overridden.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_canonicalization_deterministic.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_merge_as_content.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_enum_integer_vs_double.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_append_only_policy.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_item_type.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_keyType_valueType.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_instance_field_kept.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_canonicalizes_back.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_with_overlay_keeps_overlay.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_large_integer_minimum_with_type_alias.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_value_shapes.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_source_empty_object_list_to_empty.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_source_null_list_to_empty.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_top_level_type_name_description_not_inherited.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_type_aliases_removed_from_canonical_overlay.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_type_derived_field_removed.yaml diff --git a/README.md b/README.md index cede22f..4e06682 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,6 @@ reference forms. `schema` provides deterministic core validation. Supported keywords include: - `required` -- `allowMultiple` - `minLength` - `maxLength` - `minimum` @@ -879,12 +878,40 @@ For deeper design notes, see: ## Build And Test -Run the test suite: +The project currently compiles for Java 8 source/target compatibility and uses +JUnit 5 for tests. Build and test with a JDK that can run Gradle 8.4. + +Run the full CI-style verification command: + +```bash +./gradlew clean test +``` + +Run the test suite without cleaning: ```bash ./gradlew test ``` +Run only the Blue Language 1.0 conformance fixtures: + +```bash +./gradlew test --tests '*BlueLanguageConformanceFixtureTest' +``` + +At runtime, `new Blue().conformanceReport()` returns static Blue Language 1.0 +metadata: language version, core registry BlueIds, fixture package identity, +fixture IDs, and fixture categories. `new Blue().runConformanceSuite()` executes +the manifest-driven fixture suite and returns passed fixture IDs plus detailed +failures with fixture ID, category, operation, exception class, and message. +The fixture package under `src/test/resources/blue-language-1.0/fixtures` is a +vendored copy of the canonical Blue Language 1.0 fixture package; its manifest +identity must match the fixture package identity published by the Blue Language +1.0 specification release. The current Java fixture package identity is a +SHA-256 content digest over `manifest.yaml` with the identity field blanked plus +each manifest-listed fixture file in manifest order; verify it with +`BlueConformanceReport.fixturePackageIdentityMatchesFixtureFiles()`. + Build jars: ```bash @@ -897,8 +924,11 @@ Publish to local Maven: ./gradlew publishToMavenLocal ``` -The project currently compiles for Java 8 source/target compatibility and uses -JUnit 5 for tests. +The Gradle wrapper uses the distribution declared in +`gradle/wrapper/gradle-wrapper.properties`. Local and CI environments need either +network access for that first wrapper download or a cached Gradle distribution; +offline verification works once the wrapper distribution and normal dependency +cache are already present. ## Project Layout diff --git a/src/main/java/blue/language/Blue.java b/src/main/java/blue/language/Blue.java index 1d41a1d..aa41b3c 100644 --- a/src/main/java/blue/language/Blue.java +++ b/src/main/java/blue/language/Blue.java @@ -11,6 +11,7 @@ import blue.language.merge.NodeResolver; import blue.language.merge.processor.*; import blue.language.model.Node; +import blue.language.model.Schema; import blue.language.processor.DocumentProcessingResult; import blue.language.processor.ContractProcessor; import blue.language.processor.ContractMatchingService; @@ -35,6 +36,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -64,6 +66,7 @@ public class Blue implements NodeResolver { )); private NodeProvider nodeProvider; + private NodeProvider originalNodeProvider; private MergingProcessor mergingProcessor; private TypeClassResolver typeClassResolver; private Map preprocessingAliases = new HashMap<>(); @@ -80,6 +83,7 @@ public Blue() { } public Blue(NodeProvider nodeProvider) { + this.originalNodeProvider = nodeProvider; this.nodeProvider = NodeProviderWrapper.wrap(nodeProvider); this.mergingProcessor = createDefaultNodeProcessor(); this.documentProcessor = createDefaultDocumentProcessor(); @@ -94,6 +98,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 +162,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 +185,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 +265,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)); } @@ -269,6 +377,29 @@ 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<>(Properties.CORE_TYPE_NAME_TO_BLUE_ID_MAP), + fixturePackageIdentity, + fixtureIds, + Collections.emptyList(), + Collections.emptyList(), + fixtureCategories + ); + } + + public BlueConformanceReport runConformanceSuite() { + return BlueConformanceSuiteRunner.run(this); + } + public void extend(Node node, Limits limits) { Limits effectiveLimits = combineWithGlobalLimits(limits); new NodeExtender(nodeProvider).extend(node, effectiveLimits); @@ -304,11 +435,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) { @@ -533,6 +747,7 @@ public Map getPreprocessingAliases() { } public Blue nodeProvider(NodeProvider nodeProvider) { + this.originalNodeProvider = nodeProvider; this.nodeProvider = NodeProviderWrapper.wrap(nodeProvider); clearResolvedSnapshotCache(); refreshDocumentProcessorConformanceEngine(); @@ -619,8 +834,8 @@ 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); + Node canonical = new MergeReverser().reverseToCanonicalOverlay(resolved.clone()); + FrozenNode canonicalRoot = FrozenNode.fromUncheckedCanonicalNode(canonical); return cacheSnapshot(new ResolvedSnapshot(canonicalRoot, resolvedReferenceCache.freezeResolved(resolved), canonicalRoot.blueId())); } @@ -697,12 +912,12 @@ private NodeProvider processorSnapshotNodeProvider() { if (documentProcessor != null) { processorTypeBlueIds.addAll(documentProcessor.getContractRegistry().processors().keySet()); } - return blueId -> { + return NodeProviderWrapper.unverified(blueId -> { if (processorTypeBlueIds.contains(blueId) || !BlueIds.isPotentialBlueId(blueId)) { return Collections.singletonList(new Node().name(blueId)); } - return nodeProvider.fetchByBlueId(blueId); - }; + return originalNodeProvider.fetchByBlueId(blueId); + }); } private ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { diff --git a/src/main/java/blue/language/BlueConformanceFailure.java b/src/main/java/blue/language/BlueConformanceFailure.java new file mode 100644 index 0000000..e227e04 --- /dev/null +++ b/src/main/java/blue/language/BlueConformanceFailure.java @@ -0,0 +1,42 @@ +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; + + public BlueConformanceFailure(String fixtureId, + BlueFixtureCategory 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 BlueFixtureCategory 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/BlueConformanceReport.java b/src/main/java/blue/language/BlueConformanceReport.java new file mode 100644 index 0000000..dc2c9d8 --- /dev/null +++ b/src/main/java/blue/language/BlueConformanceReport.java @@ -0,0 +1,354 @@ +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( + "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_blue_imports", + "R_source_null_list_to_empty", + "R_source_empty_object_list_to_empty", + "R_schema_value_shapes", + "R_schema_large_integer_minimum_with_type_alias", + "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"); + } + + 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..f241d75 --- /dev/null +++ b/src/main/java/blue/language/BlueConformanceSuiteRunner.java @@ -0,0 +1,482 @@ +package blue.language; + +import blue.language.model.Node; +import blue.language.model.Schema; +import blue.language.snapshot.FrozenNode; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.CircularBlueIdCalculator; +import blue.language.utils.Nodes; +import blue.language.utils.UncheckedObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.InputStream; +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" + ))); + + 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) { + 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")); + } + } + + 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; + } + throw new IllegalArgumentException("Unsupported fixture operation: " + operation); + } + + 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); + } + } + + 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()); + } + + 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; + } + throw new IllegalArgumentException("Unsupported fixture operation: " + operation); + } + + 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) { + JsonNode expected = UncheckedObjectMapper.YAML_MAPPER.readTree( + UncheckedObjectMapper.YAML_MAPPER.writeValueAsString(readNode(requireNonNull(spec, field)))); + 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 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/BlueFixtureCategory.java b/src/main/java/blue/language/BlueFixtureCategory.java new file mode 100644 index 0000000..d08a187 --- /dev/null +++ b/src/main/java/blue/language/BlueFixtureCategory.java @@ -0,0 +1,36 @@ +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"); + + 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/conformance/FrozenConformancePlanner.java b/src/main/java/blue/language/conformance/FrozenConformancePlanner.java index d259ec9..2a531a8 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)); } @@ -102,7 +102,8 @@ private ConformanceResult check(FrozenNode node) { return ConformanceResult.conformant(); } try { - new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache).resolve(node.toNode(), Limits.NO_LIMITS); + Node canonical = new MergeReverser().reverse(node.toNode()); + new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache).resolve(canonical, Limits.NO_LIMITS); return ConformanceResult.conformant(); } catch (RuntimeException ex) { return ConformanceResult.nonConformant(ex.getMessage()); @@ -174,8 +175,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..326910b 100644 --- a/src/main/java/blue/language/merge/processor/SchemaPropagator.java +++ b/src/main/java/blue/language/merge/processor/SchemaPropagator.java @@ -33,7 +33,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 +50,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 +77,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 +118,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 +130,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) { diff --git a/src/main/java/blue/language/merge/processor/SchemaVerifier.java b/src/main/java/blue/language/merge/processor/SchemaVerifier.java index f9d75df..6c9bf3d 100644 --- a/src/main/java/blue/language/merge/processor/SchemaVerifier.java +++ b/src/main/java/blue/language/merge/processor/SchemaVerifier.java @@ -9,6 +9,7 @@ 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 +33,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()); + verifyMinLength(schema.getMinLengthExact(), target.getValue()); + verifyMaxLength(schema.getMaxLengthExact(), 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()); + verifyMinItems(schema.getMinItemsExact(), target.getItems()); + verifyMaxItems(schema.getMaxItemsExact(), target.getItems()); verifyUniqueItems(schema.getUniqueItemsValue(), target.getItems()); - verifyMinFields(schema.getMinFieldsValue(), target.getProperties()); - verifyMaxFields(schema.getMaxFieldsValue(), target.getProperties()); + verifyMinFields(schema.getMinFieldsExact(), target.getProperties()); + verifyMaxFields(schema.getMaxFieldsExact(), target.getProperties()); 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 +107,18 @@ 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, Object value) { + if (minLength != null + && value instanceof String + && 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, Object value) { + if (maxLength != null + && value instanceof String + && BigInteger.valueOf(codePointLength((String) value)).compareTo(maxLength) > 0) { throw new IllegalArgumentException("Value \"" + value + "\" is longer than the maximum length of " + maxLength + "."); } } @@ -173,14 +173,15 @@ private void verifyMultipleOf(BigDecimal multipleOf, Object value) { } } - private void verifyMinItems(Integer minItems, List items) { - if (minItems != null && (items == null || items.size() < minItems)) { + private void verifyMinItems(BigInteger minItems, List items) { + int size = items != null ? items.size() : 0; + if (minItems != null && 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, List items) { + if (maxItems != null && 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 + "."); } } @@ -198,16 +199,16 @@ private void verifyUniqueItems(Boolean uniqueItems, List items) { } } - private void verifyMinFields(Integer minFields, Map properties) { + private void verifyMinFields(BigInteger minFields, Map properties) { int fieldCount = properties == null ? 0 : properties.size(); - if (minFields != null && fieldCount < minFields) { + if (minFields != null && 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, Map properties) { int fieldCount = properties == null ? 0 : properties.size(); - if (maxFields != null && fieldCount > maxFields) { + if (maxFields != null && BigInteger.valueOf(fieldCount).compareTo(maxFields) > 0) { throw new IllegalArgumentException("Number of fields " + fieldCount + " is greater than the maximum allowed fields of " + maxFields + "."); } } 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..af06573 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.CORE_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,10 +59,11 @@ public Node preprocessWithDefaultBlue(Node document) { } public Node preprocess(Node document, Node defaultBlue) { - Node processedDocument = document.clone(); + Node processedDocument = new NormalizeListPlaceholders().process(document.clone()); + processedDocument = applyPortableImports(processedDocument); Node blueNode = processedDocument.getBlue(); - if (blueNode == null) { + if (blueNode == null && defaultBlue != null) { blueNode = defaultBlue.clone(); } @@ -78,10 +90,50 @@ public Node preprocess(Node document, Node defaultBlue) { 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 coreBlueId = CORE_TYPE_NAME_TO_BLUE_ID_MAP.get(alias); + if (coreBlueId != null && !coreBlueId.equals(blueId)) { + throw new IllegalArgumentException("\"blue.imports\" cannot redefine core 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 INFER_BASIC_TYPES = "FGYuTXwaoSKfZmpTysLTLsb8WzSqf43384rKZDkXhxD4"; + private static final String REPLACE_INLINE_TYPES = "53yFLQ3dpuGwa2svHubDyzyhYz9RQNmctiJRdi3gRYr7"; + private static final String INFER_BASIC_TYPES = "49hrWpkoXavNmK8PpZag11zB2vYwzhQZahwioz6vDk2i"; @Override public Optional getProcessor(Node transformation) { 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/ContractLoader.java b/src/main/java/blue/language/processor/ContractLoader.java index 95f563a..3559ce4 100644 --- a/src/main/java/blue/language/processor/ContractLoader.java +++ b/src/main/java/blue/language/processor/ContractLoader.java @@ -92,11 +92,7 @@ private ContractBundle build(FrozenNode scopeNode, String scopePath) { if (scopeNode == null) { return builder.build(); } - Map properties = scopeNode.getProperties(); - if (properties == null) { - return builder.build(); - } - FrozenNode contractsNode = properties.get("contracts"); + FrozenNode contractsNode = scopeNode.getContracts(); if (contractsNode == null) { return builder.build(); } @@ -216,6 +212,9 @@ private String nodeSignature(FrozenNode node) { } private FrozenNode property(FrozenNode node, String key) { + if (node != null && "contracts".equals(key)) { + return node.getContracts(); + } return node != null && node.getProperties() != null ? node.getProperties().get(key) : null; } diff --git a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java index 6706f19..681eab4 100644 --- a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java +++ b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java @@ -71,16 +71,24 @@ public DocumentProcessingRuntime(ResolvedSnapshot snapshot, ConformanceEngine conformanceEngine, ProcessingSnapshotManager snapshotManager, ProcessingMetricsSink metrics) { - Objects.requireNonNull(snapshot, "snapshot"); - this.materializedView = new MaterializedDocumentView(snapshot.canonicalRoot()); + ResolvedSnapshot processorSnapshot = processorSnapshot(Objects.requireNonNull(snapshot, "snapshot")); + this.materializedView = new MaterializedDocumentView(processorSnapshot.canonicalRoot()); this.emissionRegistry = new EmissionRegistry(); this.gasMeter = new GasMeter(); this.conformanceEngine = conformanceEngine; this.snapshotManager = snapshotManager; - this.snapshot = snapshot; + this.snapshot = processorSnapshot; this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; } + private ResolvedSnapshot processorSnapshot(ResolvedSnapshot snapshot) { + if (!snapshot.frozenCanonicalRoot().isStrictBlueIdValidation()) { + return snapshot; + } + FrozenNode canonicalRoot = FrozenNode.fromUncheckedCanonicalNode(snapshot.canonicalRoot()); + return new ResolvedSnapshot(canonicalRoot, snapshot.frozenResolvedRoot(), canonicalRoot.blueId()); + } + public Node document() { return materializedView.root(); } diff --git a/src/main/java/blue/language/processor/ProcessorEngine.java b/src/main/java/blue/language/processor/ProcessorEngine.java index 4bf2f2a..82e774a 100644 --- a/src/main/java/blue/language/processor/ProcessorEngine.java +++ b/src/main/java/blue/language/processor/ProcessorEngine.java @@ -269,6 +269,13 @@ static Node nodeAt(Node root, String pointer) { if (segment.isEmpty()) { continue; } + if ("contracts".equals(segment)) { + current = current.getContracts(); + if (current == null) { + return null; + } + continue; + } Map props = current.getProperties(); if (props == null) { return null; diff --git a/src/main/java/blue/language/processor/ScopeExecutor.java b/src/main/java/blue/language/processor/ScopeExecutor.java index a798e97..0455fd4 100644 --- a/src/main/java/blue/language/processor/ScopeExecutor.java +++ b/src/main/java/blue/language/processor/ScopeExecutor.java @@ -104,7 +104,7 @@ void initializeScope(String scopePath, boolean chargeScopeEntry) { } runtime.chargeInitialization(); - String documentId = BlueIdCalculator.calculateBlueId(preInitSnapshot != null ? preInitSnapshot : new Node()); + String documentId = BlueIdCalculator.calculateUncheckedBlueId(preInitSnapshot != null ? preInitSnapshot : new Node()); Node lifecycleEvent = ProcessorEngine.createLifecycleInitiatedEvent(documentId); ProcessorExecutionContext context = execution.createContext(normalizedScope, bundle, lifecycleEvent, false, true); deliverLifecycle(normalizedScope, bundle, lifecycleEvent, true); diff --git a/src/main/java/blue/language/provider/BasicNodeProvider.java b/src/main/java/blue/language/provider/BasicNodeProvider.java index 839799d..fac0fbf 100644 --- a/src/main/java/blue/language/provider/BasicNodeProvider.java +++ b/src/main/java/blue/language/provider/BasicNodeProvider.java @@ -2,6 +2,7 @@ import blue.language.model.Node; import blue.language.preprocess.Preprocessor; +import blue.language.utils.BlueIdCalculator; import blue.language.utils.Nodes; import com.fasterxml.jackson.databind.JsonNode; @@ -10,8 +11,9 @@ import java.util.stream.IntStream; import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; +import static blue.language.utils.UncheckedObjectMapper.JSON_MAPPER; -public class BasicNodeProvider extends PreloadedNodeProvider { +public class BasicNodeProvider extends PreloadedNodeProvider implements CyclicAwareNodeProvider { private Map blueIdToContentMap; private Map blueIdToMultipleDocumentsMap; @@ -46,6 +48,14 @@ private void processSingleNode(Node node) { addToNameMap(node.getName(), parsedContent.blueId); } + private void processSingleNodeUnchecked(Node node) { + Node preprocessed = preprocessor.apply(node); + String blueId = BlueIdCalculator.calculateUncheckedBlueId(preprocessed); + blueIdToContentMap.put(blueId, JSON_MAPPER.valueToTree(preprocessed)); + blueIdToMultipleDocumentsMap.put(blueId, false); + addToNameMap(node.getName(), blueId); + } + private void processNodeWithItems(Node node) { List items = node.getItems(); NodeContentHandler.ParsedContent parsedContent = NodeContentHandler.parseAndCalculateBlueId(items, preprocessor); @@ -77,6 +87,16 @@ protected JsonNode fetchContentByBlueId(String baseBlueId) { return null; } + @Override + public boolean hasVerifiedContentForBlueId(String blueId) { + String baseBlueId = blueId; + int memberSeparator = blueId.indexOf('#'); + if (memberSeparator >= 0) { + baseBlueId = blueId.substring(0, memberSeparator); + } + return blueIdToContentMap.containsKey(baseBlueId); + } + public void addSingleNodes(Node... nodes) { Arrays.stream(nodes).forEach(this::processNode); } @@ -87,6 +107,12 @@ public void addSingleDocs(String... docs) { .forEach(this::processNode); } + public void addSingleDocsUnchecked(String... docs) { + Arrays.stream(docs) + .map(doc -> YAML_MAPPER.readValue(doc, Node.class)) + .forEach(this::processSingleNodeUnchecked); + } + public String getBlueIdByName(String name) { return nameToBlueIdsMap.get(name).get(0); } diff --git a/src/main/java/blue/language/provider/CyclicAwareNodeProvider.java b/src/main/java/blue/language/provider/CyclicAwareNodeProvider.java new file mode 100644 index 0000000..1e59b4c --- /dev/null +++ b/src/main/java/blue/language/provider/CyclicAwareNodeProvider.java @@ -0,0 +1,12 @@ +package blue.language.provider; + +/** + * Marker for providers that resolve cyclic-set member BlueIds as part of their + * own content-addressed ingestion model. + */ +public interface CyclicAwareNodeProvider { + + default boolean hasVerifiedContentForBlueId(String blueId) { + return false; + } +} diff --git a/src/main/java/blue/language/provider/NodeContentHandler.java b/src/main/java/blue/language/provider/NodeContentHandler.java index 74f7b72..19c744d 100644 --- a/src/main/java/blue/language/provider/NodeContentHandler.java +++ b/src/main/java/blue/language/provider/NodeContentHandler.java @@ -103,7 +103,7 @@ private static ParsedContent calculateParsedContent(Node node) { Node preliminary = node.clone(); rewriteThisReferences(preliminary, reference -> ZERO_BLUE_ID); - String blueId = BlueIdCalculator.calculateBlueId(preliminary); + String blueId = BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders(preliminary); return new ParsedContent(blueId, JSON_MAPPER.valueToTree(node), false); } @@ -121,7 +121,8 @@ private static ParsedContent calculateParsedContent(List nodes) { for (int i = 0; i < nodes.size(); i++) { Node preliminary = nodes.get(i).clone(); rewriteThisReferences(preliminary, reference -> ZERO_BLUE_ID); - indexedNodes.add(new IndexedNode(i, nodes.get(i), BlueIdCalculator.calculateBlueId(preliminary))); + indexedNodes.add(new IndexedNode(i, nodes.get(i), + BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders(preliminary))); } indexedNodes.sort(Comparator @@ -143,7 +144,7 @@ private static ParsedContent calculateParsedContent(List nodes) { sortedNodes.add(rewritten); } - String blueId = BlueIdCalculator.calculateBlueId(sortedNodes); + String blueId = BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders(sortedNodes); return new ParsedContent(blueId, JSON_MAPPER.valueToTree(sortedNodes), true); } @@ -248,6 +249,7 @@ private static void collectThisReferences(Node node, List referen collectThisReferences(node.getKeyType(), references); collectThisReferences(node.getValueType(), references); collectThisReferences(node.getBlue(), references); + collectThisReferences(node.getContracts(), references); collectThisReferences(node.getSchema(), references); if (node.getItems() != null) { node.getItems().forEach(item -> collectThisReferences(item, references)); @@ -262,7 +264,6 @@ private static void collectThisReferences(Schema schema, List ref return; } collectThisReferences(schema.getRequired(), references); - collectThisReferences(schema.getAllowMultiple(), references); collectThisReferences(schema.getMinLength(), references); collectThisReferences(schema.getMaxLength(), references); collectThisReferences(schema.getMinimum(), references); @@ -292,6 +293,7 @@ private static void rewriteThisReferences(Node node, java.util.function.Function rewriteThisReferences(node.getKeyType(), replacement); rewriteThisReferences(node.getValueType(), replacement); rewriteThisReferences(node.getBlue(), replacement); + rewriteThisReferences(node.getContracts(), replacement); rewriteThisReferences(node.getSchema(), replacement); if (node.getItems() != null) { node.getItems().forEach(item -> rewriteThisReferences(item, replacement)); @@ -306,7 +308,6 @@ private static void rewriteThisReferences(Schema schema, java.util.function.Func return; } rewriteThisReferences(schema.getRequired(), replacement); - rewriteThisReferences(schema.getAllowMultiple(), replacement); rewriteThisReferences(schema.getMinLength(), replacement); rewriteThisReferences(schema.getMaxLength(), replacement); rewriteThisReferences(schema.getMinimum(), replacement); diff --git a/src/main/java/blue/language/provider/VerifyingNodeProvider.java b/src/main/java/blue/language/provider/VerifyingNodeProvider.java new file mode 100644 index 0000000..1370ac4 --- /dev/null +++ b/src/main/java/blue/language/provider/VerifyingNodeProvider.java @@ -0,0 +1,71 @@ +package blue.language.provider; + +import blue.language.NodeProvider; +import blue.language.model.Node; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.BlueIds; + +import java.util.List; + +public class VerifyingNodeProvider implements NodeProvider { + + private final NodeProvider delegate; + + public VerifyingNodeProvider(NodeProvider delegate) { + this.delegate = delegate; + } + + @Override + public List fetchByBlueId(String blueId) { + String requestedBlueId = BlueIds.requireBlueIdOrCyclicMember(blueId, "provider.fetchByBlueId"); + if (requestedBlueId.contains("#")) { + if (!(delegate instanceof CyclicAwareNodeProvider)) { + throw new UnsupportedOperationException( + "Provider verification for cyclic member BlueIds requires a cyclic-set-aware verifier: " + + requestedBlueId); + } + if (!((CyclicAwareNodeProvider) delegate).hasVerifiedContentForBlueId(requestedBlueId)) { + throw new UnsupportedOperationException( + "Provider verification for cyclic member BlueIds requires verified cyclic-set content: " + + requestedBlueId); + } + return delegate.fetchByBlueId(blueId); + } + + List nodes = delegate.fetchByBlueId(blueId); + if (nodes == null || nodes.isEmpty()) { + return nodes; + } + + verifyPlainContent(requestedBlueId, nodes); + return nodes; + } + + private void verifyPlainContent(String requestedBlueId, List nodes) { + String actualBlueId = nodes.size() == 1 + ? BlueIdCalculator.calculateBlueId(contentWithoutRootIdentity(nodes.get(0))) + : BlueIdCalculator.calculateBlueId(contentWithoutRootIdentity(nodes)); + if (requestedBlueId.equals(actualBlueId)) { + return; + } + + throw new IllegalArgumentException("Provider returned content with BlueId " + actualBlueId + + " for requested BlueId " + requestedBlueId + "."); + } + + private Node contentWithoutRootIdentity(Node node) { + Node canonical = node.clone(); + if (canonical.getBlueId() != null && !canonical.isReferenceOnly()) { + canonical.blueId(null); + } + return canonical; + } + + private List contentWithoutRootIdentity(List nodes) { + List canonical = new java.util.ArrayList<>(nodes.size()); + for (Node node : nodes) { + canonical.add(contentWithoutRootIdentity(node)); + } + return canonical; + } +} diff --git a/src/main/java/blue/language/snapshot/CanonicalOverlayPatchEngine.java b/src/main/java/blue/language/snapshot/CanonicalOverlayPatchEngine.java index 3396c03..75e2092 100644 --- a/src/main/java/blue/language/snapshot/CanonicalOverlayPatchEngine.java +++ b/src/main/java/blue/language/snapshot/CanonicalOverlayPatchEngine.java @@ -34,7 +34,7 @@ public CanonicalPatchResult apply(JsonPatch patch) { } FrozenNode before = read(root, segments, patch.getOp() == JsonPatch.Op.ADD); - FrozenNode value = patch.getOp() == JsonPatch.Op.REMOVE ? null : FrozenNode.fromNode(patch.getVal()); + FrozenNode value = patch.getOp() == JsonPatch.Op.REMOVE ? null : freezePatchValue(patch.getVal()); FrozenNode nextRoot; switch (patch.getOp()) { case ADD: @@ -54,6 +54,24 @@ public CanonicalPatchResult apply(JsonPatch patch) { return new CanonicalPatchResult(nextRoot, before, after, patch.getOp(), path); } + private FrozenNode freezePatchValue(Node value) { + if (root.isStrictCanonical()) { + return root.isStrictBlueIdValidation() + ? FrozenNode.fromNode(value) + : FrozenNode.fromUncheckedCanonicalNode(value); + } + return FrozenNode.fromResolvedNode(value); + } + + private FrozenNode emptyNodeForRootMode() { + if (root.isStrictCanonical()) { + return root.isStrictBlueIdValidation() + ? FrozenNode.empty() + : FrozenNode.fromUncheckedCanonicalNode(new Node()); + } + return FrozenNode.fromResolvedNode(new Node()); + } + private FrozenNode add(FrozenNode node, List segments, FrozenNode value, String path) { return write(node, segments, value, path, WriteMode.ADD); } @@ -98,7 +116,7 @@ private FrozenNode write(FrozenNode node, if (JsonPointer.isArrayIndexSegment(segment)) { throw new IllegalStateException("Expected array element to exist at path: " + path); } - child = FrozenNode.empty(); + child = emptyNodeForRootMode(); } FrozenNode nextChild = write(child, tail, value, path, mode); return node.withProperty(segment, nextChild); diff --git a/src/main/java/blue/language/snapshot/FrozenNode.java b/src/main/java/blue/language/snapshot/FrozenNode.java index 8de24e4..6cff760 100644 --- a/src/main/java/blue/language/snapshot/FrozenNode.java +++ b/src/main/java/blue/language/snapshot/FrozenNode.java @@ -6,7 +6,9 @@ import blue.language.utils.BlueNumbers; import blue.language.utils.BlueIdCalculator; import blue.language.utils.JsonPointer; -import com.fasterxml.jackson.core.type.TypeReference; +import blue.language.utils.NodeToBlueIdInput; +import blue.language.utils.NodeToMapListOrValue; +import blue.language.utils.SchemaToMapListOrValue; import java.math.BigInteger; import java.util.ArrayList; @@ -20,7 +22,6 @@ import java.util.stream.Collectors; import static blue.language.utils.Properties.*; -import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; public final class FrozenNode { @@ -35,6 +36,7 @@ public final class FrozenNode { private final Object value; private final List items; private final Map properties; + private final FrozenNode contracts; private final String referenceBlueId; private final Schema schema; private final String mergePolicy; @@ -43,6 +45,8 @@ public final class FrozenNode { private final FrozenNode blue; private final boolean inlineValue; private final boolean strictCanonical; + private final boolean strictBlueIdValidation; + private final boolean previousAnchorContext; private final String blueId; private FrozenNode(Builder builder) { @@ -53,8 +57,9 @@ private FrozenNode(Builder builder) { this.keyType = builder.keyType; this.valueType = builder.valueType; this.value = builder.nodeValue; - this.items = freezeList(builder.items); + this.items = freezeList(builder.items, builder.strictCanonical); this.properties = freezeMap(builder.properties); + this.contracts = builder.contracts; this.referenceBlueId = builder.referenceBlueId; this.schema = builder.schema != null ? builder.schema.clone() : null; this.mergePolicy = builder.mergePolicy; @@ -63,6 +68,8 @@ private FrozenNode(Builder builder) { this.blue = builder.blue; this.inlineValue = builder.inlineValue; this.strictCanonical = builder.strictCanonical; + this.strictBlueIdValidation = builder.strictBlueIdValidation; + this.previousAnchorContext = builder.previousAnchorContext; validatePayloadShape(); this.blueId = computeBlueId(); } @@ -80,14 +87,30 @@ public static FrozenNode fromResolvedNode(Node node) { } public static FrozenNode fromResolvedNode(Node node, ResolvedReferenceInterner interner) { - return fromNode(node, false, interner); + return fromNode(node, false, interner, false); + } + + public static FrozenNode fromUncheckedCanonicalNode(Node node) { + return fromNode(node, true, null, false); } private static FrozenNode fromNode(Node node, boolean strictCanonical) { - return fromNode(node, strictCanonical, null); + return fromNode(node, strictCanonical, null, true); } private static FrozenNode fromNode(Node node, boolean strictCanonical, ResolvedReferenceInterner interner) { + return fromNode(node, strictCanonical, interner, strictCanonical); + } + + private static FrozenNode fromNode(Node node, boolean strictCanonical, ResolvedReferenceInterner interner, boolean strictBlueIdValidation) { + return fromNode(node, strictCanonical, interner, strictBlueIdValidation, false); + } + + private static FrozenNode fromNode(Node node, + boolean strictCanonical, + ResolvedReferenceInterner interner, + boolean strictBlueIdValidation, + boolean previousAnchorContext) { Objects.requireNonNull(node, "node"); if (!strictCanonical && interner != null && node.getBlueId() != null) { FrozenNode cached = interner.lookup(node.getBlueId()); @@ -98,23 +121,26 @@ private static FrozenNode fromNode(Node node, boolean strictCanonical, ResolvedR FrozenNode frozen = builder() .name(node.getName()) .description(node.getDescription()) - .type(node.getType() != null ? fromNode(node.getType(), strictCanonical, interner) : null) - .itemType(node.getItemType() != null ? fromNode(node.getItemType(), strictCanonical, interner) : null) - .keyType(node.getKeyType() != null ? fromNode(node.getKeyType(), strictCanonical, interner) : null) - .valueType(node.getValueType() != null ? fromNode(node.getValueType(), strictCanonical, interner) : null) + .type(node.getType() != null ? fromNode(node.getType(), strictCanonical, interner, strictBlueIdValidation) : null) + .itemType(node.getItemType() != null ? fromNode(node.getItemType(), strictCanonical, interner, strictBlueIdValidation) : null) + .keyType(node.getKeyType() != null ? fromNode(node.getKeyType(), strictCanonical, interner, strictBlueIdValidation) : null) + .valueType(node.getValueType() != null ? fromNode(node.getValueType(), strictCanonical, interner, strictBlueIdValidation) : null) .value(node.getValue()) .items(node.getItems() != null - ? node.getItems().stream().map(item -> fromNode(item, strictCanonical, interner)).collect(Collectors.toList()) + ? freezeItems(node.getItems(), strictCanonical, interner, strictBlueIdValidation) : null) - .properties(freezeProperties(node.getProperties(), strictCanonical, interner)) + .properties(freezeProperties(node.getProperties(), strictCanonical, interner, strictBlueIdValidation)) + .contracts(node.getContracts() != null ? fromNode(node.getContracts(), strictCanonical, interner, strictBlueIdValidation) : null) .referenceBlueId(node.getBlueId()) .schema(node.getSchema()) .mergePolicy(node.getMergePolicy()) .previousBlueId(node.getPreviousBlueId()) .position(node.getPosition()) - .blue(node.getBlue() != null ? fromNode(node.getBlue(), strictCanonical, interner) : null) + .blue(node.getBlue() != null ? fromNode(node.getBlue(), strictCanonical, interner, strictBlueIdValidation) : null) .inlineValue(node.isInlineValue()) .strictCanonical(strictCanonical) + .strictBlueIdValidation(strictBlueIdValidation) + .previousAnchorContext(previousAnchorContext) .build(); if (!strictCanonical && interner != null && node.getBlueId() != null && !node.isReferenceOnly()) { return interner.intern(node.getBlueId(), frozen); @@ -122,6 +148,17 @@ private static FrozenNode fromNode(Node node, boolean strictCanonical, ResolvedR return frozen; } + private static List freezeItems(List source, + boolean strictCanonical, + ResolvedReferenceInterner interner, + boolean strictBlueIdValidation) { + List result = new ArrayList<>(source.size()); + for (Node item : source) { + result.add(fromNode(item, strictCanonical, interner, strictBlueIdValidation, true)); + } + return result; + } + public static List fromNodes(List nodes) { if (nodes == null) { return null; @@ -133,13 +170,14 @@ public static List fromNodes(List nodes) { private static Map freezeProperties(Map source, boolean strictCanonical, - ResolvedReferenceInterner interner) { + ResolvedReferenceInterner interner, + boolean strictBlueIdValidation) { if (source == null || source.isEmpty()) { return null; } Map result = new LinkedHashMap<>(); for (Map.Entry entry : source.entrySet()) { - FrozenNode child = fromNode(entry.getValue(), strictCanonical, interner); + FrozenNode child = fromNode(entry.getValue(), strictCanonical, interner, strictBlueIdValidation); if (strictCanonical && child.isEmptyNode()) { continue; } @@ -149,7 +187,12 @@ private static Map freezeProperties(Map source } public static String calculateBlueId(List nodes) { - return computeListHash(nodes == null ? Collections.emptyList() : nodes); + List objects = new ArrayList<>((nodes == null ? Collections.emptyList() : nodes).size()); + List source = nodes == null ? Collections.emptyList() : nodes; + for (int i = 0; i < source.size(); i++) { + objects.add(FrozenNodeToBlueIdInput.getListElement(source.get(i), i)); + } + return BlueIdCalculator.INSTANCE.calculate(objects); } public Node toNode() { @@ -167,6 +210,7 @@ public Node toNode() { .previousBlueId(previousBlueId) .position(position) .blue(blue != null ? blue.toNode() : null) + .contracts(contracts != null ? contracts.toNode() : null) .inlineValue(inlineValue); if (items != null) { node.items(items.stream().map(FrozenNode::toNode).collect(Collectors.toList())); @@ -250,7 +294,14 @@ public Map getProperties() { return properties; } + public FrozenNode getContracts() { + return contracts; + } + public FrozenNode property(String key) { + if (OBJECT_CONTRACTS.equals(key)) { + return contracts; + } return properties != null ? properties.get(key) : null; } @@ -276,7 +327,7 @@ public FrozenNode at(List pointerSegments) { if (current == null) { return null; } - if (current.items != null) { + if (current.items != null && !OBJECT_CONTRACTS.equals(segment)) { current = current.item(parseArrayIndex(segment)); } else { current = current.property(segment); @@ -310,6 +361,7 @@ public boolean isReferenceOnly() { && value == null && items == null && properties == null + && contracts == null && schema == null && mergePolicy == null && previousBlueId == null @@ -328,6 +380,7 @@ public boolean isPreviousOnly() { && value == null && items == null && properties == null + && contracts == null && schema == null && mergePolicy == null && position == null @@ -339,6 +392,14 @@ public boolean isStrictCanonical() { return strictCanonical; } + public boolean isStrictBlueIdValidation() { + return strictBlueIdValidation; + } + + boolean isListElementContext() { + return previousAnchorContext; + } + public boolean isEmptyNode() { return name == null && description == null @@ -349,6 +410,7 @@ public boolean isEmptyNode() { && value == null && items == null && properties == null + && contracts == null && referenceBlueId == null && schema == null && mergePolicy == null @@ -358,6 +420,9 @@ public boolean isEmptyNode() { } public FrozenNode withProperty(String key, FrozenNode child) { + if (OBJECT_CONTRACTS.equals(key)) { + return toBuilder().contracts(child == null || (strictCanonical && child.isEmptyNode()) ? null : child).build(); + } Map next = properties != null ? new LinkedHashMap<>(properties) : new LinkedHashMap<>(); @@ -391,22 +456,19 @@ private void validatePayloadShape() { if (strictCanonical && referenceBlueId != null && !isReferenceOnly()) { throw new IllegalArgumentException("\"blueId\" nodes must be reference-only and cannot contain sibling fields."); } - if (strictCanonical && previousBlueId != null && !isPreviousOnly()) { - throw new IllegalArgumentException("\"$previous\" list anchors must be single-key list items."); + if (strictCanonical && previousBlueId != null) { + if (!isPreviousOnly()) { + throw new IllegalArgumentException("\"$previous\" list anchors must be single-key list items."); + } + if (!previousAnchorContext) { + throw new IllegalArgumentException("\"$previous\" is valid only as the first list item in direct BlueId input."); + } } - if (position != null - && payloadKinds == 0 - && name == null - && description == null - && type == null - && itemType == null - && keyType == null - && valueType == null - && schema == null - && mergePolicy == null - && blue == null - && referenceBlueId == null) { - throw new IllegalArgumentException("\"$pos\" items must contain an overlay."); + if (strictCanonical && blue != null) { + throw new IllegalArgumentException("\"blue\" is a preprocessing directive and must not appear in canonical BlueId input."); + } + if (strictCanonical && position != null) { + throw new IllegalArgumentException("\"$pos\" overlays are not valid direct BlueId input."); } } @@ -420,6 +482,9 @@ private void indexPaths(String path, Map index) { if (properties != null) { properties.forEach((key, child) -> child.indexPaths(JsonPointer.append(path, key), index)); } + if (contracts != null) { + contracts.indexPaths(JsonPointer.append(path, OBJECT_CONTRACTS), index); + } } private int parseArrayIndex(String segment) { @@ -442,6 +507,7 @@ private Builder toBuilder() { .value(value) .items(items) .properties(properties) + .contracts(contracts) .referenceBlueId(referenceBlueId) .schema(schema) .mergePolicy(mergePolicy) @@ -449,10 +515,22 @@ private Builder toBuilder() { .position(position) .blue(blue) .inlineValue(inlineValue) - .strictCanonical(strictCanonical); + .strictCanonical(strictCanonical) + .strictBlueIdValidation(strictBlueIdValidation) + .previousAnchorContext(previousAnchorContext); } private String computeBlueId() { + if (strictCanonical) { + if (!strictBlueIdValidation) { + return BlueIdCalculator.calculateUncheckedBlueId(toNode()); + } + return BlueIdCalculator.INSTANCE.calculate(FrozenNodeToBlueIdInput.get(this)); + } + return computeResolvedStructuralBlueId(); + } + + private String computeResolvedStructuralBlueId() { if (isReferenceOnly()) { return referenceBlueId; } @@ -490,6 +568,7 @@ private String computeBlueId() { if (schema != null) { putBlueId(hashes, OBJECT_SCHEMA, BlueIdCalculator.INSTANCE.calculate(schemaObject(schema))); } + putBlueId(hashes, OBJECT_CONTRACTS, contracts); putBlueId(hashes, OBJECT_BLUE, blue); if (properties != null) { properties.forEach((key, child) -> putBlueId(hashes, key, child)); @@ -498,51 +577,14 @@ private String computeBlueId() { } private static String computeListHash(List list) { - if (list == null) { - return HASH.apply(Collections.singletonMap("$list", "empty")); - } - - String accumulator = HASH.apply(Collections.singletonMap("$list", "empty")); - int start = 0; - if (!list.isEmpty() && list.get(0).isPreviousOnly()) { - accumulator = list.get(0).previousBlueId; - start = 1; - } - - List normalized = normalizeListControls(list, start); - for (FrozenNode element : normalized) { - Map cons = new TreeMap<>(String::compareTo); - cons.put("elem", reference(element.blueId())); - cons.put("prev", reference(accumulator)); - accumulator = HASH.apply(Collections.singletonMap("$listCons", cons)); - } - return accumulator; + return BlueIdCalculator.calculateBlueId(toBlueIdInputNodes(list)); } - private static List normalizeListControls(List list, int start) { - Map positioned = new TreeMap<>(); - List appended = new ArrayList<>(); - boolean hasPositions = false; - for (int i = start; i < list.size(); i++) { - FrozenNode item = list.get(i); - if (item.isPreviousOnly()) { - throw new IllegalArgumentException("\"$previous\" must appear only as the first list item."); - } - if (item.position != null) { - hasPositions = true; - if (positioned.put(item.position, item.withoutPosition()) != null) { - throw new IllegalArgumentException("Duplicate \"$pos\" value in list: " + item.position); - } - } else { - appended.add(item); - } - } - if (!hasPositions) { - return list.subList(start, list.size()); - } - List normalized = new ArrayList<>(positioned.values()); - normalized.addAll(appended); - return normalized; + private static List toBlueIdInputNodes(List list) { + return (list == null ? Collections.emptyList() : list).stream() + .map(FrozenNode::toNode) + .map(NodeToBlueIdInput::stripResolvedBlueIdMetadata) + .collect(Collectors.toList()); } private static void putRaw(Map target, String key, Object value) { @@ -605,14 +647,25 @@ private static String inferTypeBlueId(Object value) { } private static Map schemaObject(Schema schema) { - return YAML_MAPPER.convertValue(schema, new TypeReference>() {}); + return SchemaToMapListOrValue.get(schema, NodeToMapListOrValue::get); } - private static List freezeList(List source) { + private static List freezeList(List source, boolean strictCanonical) { if (source == null) { return null; } - return Collections.unmodifiableList(new ArrayList<>(source)); + List result = new ArrayList<>(source.size()); + for (int i = 0; i < source.size(); i++) { + FrozenNode node = source.get(i); + if (strictCanonical && node.isEmptyNode()) { + throw new IllegalArgumentException("Direct BlueId input must use { \"$empty\": true } for empty list placeholders."); + } + if (strictCanonical && node.isPreviousOnly() && i != 0) { + throw new IllegalArgumentException("\"$previous\" must appear only as the first list item."); + } + result.add(node); + } + return Collections.unmodifiableList(result); } private static Map freezeMap(Map source) { @@ -636,6 +689,7 @@ private static final class Builder { private Object nodeValue; private List items; private Map properties; + private FrozenNode contracts; private String referenceBlueId; private Schema schema; private String mergePolicy; @@ -644,6 +698,8 @@ private static final class Builder { private FrozenNode blue; private boolean inlineValue; private boolean strictCanonical = true; + private boolean strictBlueIdValidation = true; + private boolean previousAnchorContext; Builder name(String name) { this.name = name; @@ -690,6 +746,11 @@ Builder properties(Map properties) { return this; } + Builder contracts(FrozenNode contracts) { + this.contracts = contracts; + return this; + } + Builder referenceBlueId(String referenceBlueId) { this.referenceBlueId = referenceBlueId; return this; @@ -730,6 +791,16 @@ Builder strictCanonical(boolean strictCanonical) { return this; } + Builder strictBlueIdValidation(boolean strictBlueIdValidation) { + this.strictBlueIdValidation = strictBlueIdValidation; + return this; + } + + Builder previousAnchorContext(boolean previousAnchorContext) { + this.previousAnchorContext = previousAnchorContext; + return this; + } + FrozenNode build() { return new FrozenNode(this); } diff --git a/src/main/java/blue/language/snapshot/FrozenNodeToBlueIdInput.java b/src/main/java/blue/language/snapshot/FrozenNodeToBlueIdInput.java new file mode 100644 index 0000000..c61ac25 --- /dev/null +++ b/src/main/java/blue/language/snapshot/FrozenNodeToBlueIdInput.java @@ -0,0 +1,327 @@ +package blue.language.snapshot; + +import blue.language.model.Schema; +import blue.language.utils.BlueIds; +import blue.language.utils.BlueNumbers; +import blue.language.utils.NodeToBlueIdInput; +import blue.language.utils.SchemaToMapListOrValue; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static blue.language.utils.Properties.*; + +public final class FrozenNodeToBlueIdInput { + + private FrozenNodeToBlueIdInput() { + } + + public static Object get(FrozenNode node) { + Context context = node != null && node.isListElementContext() ? Context.LIST_ELEMENT : Context.ROOT; + int listIndex = node != null && node.isListElementContext() ? 0 : -1; + return get(node, "/", context, listIndex); + } + + static Object getListElement(FrozenNode node, int index) { + return get(node, "/" + index, Context.LIST_ELEMENT, index); + } + + private enum Context { + ROOT, + OBJECT_FIELD, + LIST_ELEMENT, + METADATA + } + + private static Object get(FrozenNode node, String path, Context context, int listIndex) { + validateBlueIdInput(node, path, context, listIndex); + + if (context == Context.LIST_ELEMENT && isEmptyPlaceholder(node)) { + Map placeholder = new LinkedHashMap<>(); + placeholder.put(LIST_CONTROL_EMPTY, true); + return placeholder; + } + + if (node.isReferenceOnly()) { + String blueId = BlueIds.requireBlueIdOrCyclicMember( + BlueIds.requireNoThisPlaceholderOutsideCyclicApi( + node.getReferenceBlueId(), + appendPath(path, OBJECT_BLUE_ID)), + appendPath(path, OBJECT_BLUE_ID)); + Map reference = new LinkedHashMap<>(); + reference.put(OBJECT_BLUE_ID, blueId); + return reference; + } + + if (node.getPreviousBlueId() != null) { + String previousBlueId = BlueIds.requirePlainBlueId( + node.getPreviousBlueId(), + appendPath(appendPath(path, LIST_CONTROL_PREVIOUS), OBJECT_BLUE_ID)); + Map previous = new LinkedHashMap<>(); + previous.put(OBJECT_BLUE_ID, previousBlueId); + Map result = new LinkedHashMap<>(); + result.put(LIST_CONTROL_PREVIOUS, previous); + return result; + } + + Object value = node.getValue(); + List items = null; + if (node.getItems() != null) { + items = new ArrayList<>(node.getItems().size()); + for (int i = 0; i < node.getItems().size(); i++) { + items.add(get(node.getItems().get(i), appendPath(path, OBJECT_ITEMS, i), Context.LIST_ELEMENT, i)); + } + } + + Map result = new LinkedHashMap<>(); + if (node.getName() != null) { + result.put(OBJECT_NAME, node.getName()); + } + if (node.getDescription() != null) { + result.put(OBJECT_DESCRIPTION, node.getDescription()); + } + + String valueTypeBlueId = null; + if (value != null && node.getType() == null) { + String inferredTypeBlueId = inferTypeBlueId(value); + if (inferredTypeBlueId != null) { + valueTypeBlueId = inferredTypeBlueId; + Map map = new LinkedHashMap<>(); + map.put(OBJECT_BLUE_ID, inferredTypeBlueId); + result.put(OBJECT_TYPE, map); + } + } else if (node.getType() != null) { + valueTypeBlueId = node.getType().getReferenceBlueId(); + result.put(OBJECT_TYPE, get(node.getType(), appendPath(path, OBJECT_TYPE), Context.METADATA, -1)); + } + + if (node.getItemType() != null) { + result.put(OBJECT_ITEM_TYPE, get(node.getItemType(), appendPath(path, OBJECT_ITEM_TYPE), Context.METADATA, -1)); + } + if (node.getKeyType() != null) { + result.put(OBJECT_KEY_TYPE, get(node.getKeyType(), appendPath(path, OBJECT_KEY_TYPE), Context.METADATA, -1)); + } + if (node.getValueType() != null) { + result.put(OBJECT_VALUE_TYPE, get(node.getValueType(), appendPath(path, OBJECT_VALUE_TYPE), Context.METADATA, -1)); + } + if (node.getMergePolicy() != null) { + result.put(OBJECT_MERGE_POLICY, node.getMergePolicy()); + } + if (value != null) { + result.put(OBJECT_VALUE, handleValue(value, valueTypeBlueId)); + } + if (items != null) { + result.put(OBJECT_ITEMS, items); + } + if (node.getSchema() != null) { + Schema schema = node.getSchema(); + validateSchemaNodes(schema, appendPath(path, OBJECT_SCHEMA)); + result.put(OBJECT_SCHEMA, SchemaToMapListOrValue.get( + schema, + child -> NodeToBlueIdInput.get(child))); + } + if (node.getContracts() != null) { + result.put(OBJECT_CONTRACTS, get(node.getContracts(), appendPath(path, OBJECT_CONTRACTS), Context.METADATA, -1)); + } + if (node.getProperties() != null) { + node.getProperties().forEach((key, propertyValue) -> + result.put(key, get(propertyValue, appendPath(path, key), Context.OBJECT_FIELD, -1))); + } + return result; + } + + private static void validateBlueIdInput(FrozenNode node, String path, Context context, int listIndex) { + if (node == null) { + throw new IllegalArgumentException("BlueId input must not contain null nodes. Path: " + path); + } + if (context == Context.METADATA && isTypePosition(path) && node.isInlineValue()) { + throw new IllegalArgumentException("Direct BlueId input must not contain unresolved type aliases. Path: " + path); + } + if (node.getBlue() != null) { + throw new IllegalArgumentException( + "\"blue\" is a preprocessing directive and must not be present in BlueId input. " + + "Call preprocess/canonicalize/calculateSemanticBlueId first. Path: " + path); + } + if (node.getPosition() != null) { + throw new IllegalArgumentException("\"$pos\" overlays are not valid direct BlueId input. Path: " + path); + } + if (node.getProperties() != null && node.getProperties().containsKey(LIST_CONTROL_REPLACE)) { + throw new IllegalArgumentException("\"$replace\" overlays are not valid direct BlueId input. Path: " + path); + } + if (context == Context.LIST_ELEMENT) { + if (node.isEmptyNode()) { + throw new IllegalArgumentException("Direct BlueId input must use { \"$empty\": true } for empty list placeholders. Path: " + path); + } + if (node.getProperties() != null && node.getProperties().containsKey(LIST_CONTROL_EMPTY)) { + validateEmptyPlaceholder(node, path); + } + if (node.getPreviousBlueId() != null && listIndex != 0) { + throw new IllegalArgumentException("\"$previous\" must appear only as the first list item. Path: " + path); + } + } else if (node.getPreviousBlueId() != null) { + throw new IllegalArgumentException("\"$previous\" is valid only as the first list item in direct BlueId input. Path: " + path); + } + validatePayloadKind(node, path); + } + + private static void validatePayloadKind(FrozenNode node, String path) { + int payloadKinds = 0; + if (node.getValue() != null) payloadKinds++; + if (node.getItems() != null) payloadKinds++; + if (node.getProperties() != null && !node.getProperties().isEmpty()) payloadKinds++; + if (payloadKinds > 1) { + throw new IllegalArgumentException("A Blue node may contain only one payload kind: value, items, or object fields. Path: " + path); + } + if (node.getReferenceBlueId() != null && !node.isReferenceOnly()) { + throw new IllegalArgumentException("\"blueId\" nodes must be reference-only and cannot contain sibling fields. Path: " + path); + } + if (node.getPreviousBlueId() != null && (payloadKinds > 0 + || node.getName() != null + || node.getDescription() != null + || node.getType() != null + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null + || node.getSchema() != null + || node.getMergePolicy() != null + || node.getPosition() != null + || node.getContracts() != null + || node.getReferenceBlueId() != null)) { + throw new IllegalArgumentException("\"$previous\" list anchors must be single-key list items. Path: " + path); + } + } + + private static boolean isEmptyPlaceholder(FrozenNode node) { + if (node == null || node.getProperties() == null || node.getProperties().size() != 1) { + return false; + } + FrozenNode marker = node.getProperties().get(LIST_CONTROL_EMPTY); + return marker != null + && Boolean.TRUE.equals(marker.getValue()) + && marker.getName() == null + && marker.getDescription() == null + && marker.getType() == null + && marker.getItemType() == null + && marker.getKeyType() == null + && marker.getValueType() == null + && marker.getItems() == null + && marker.getProperties() == null + && marker.getContracts() == null + && marker.getReferenceBlueId() == null + && marker.getSchema() == null + && marker.getMergePolicy() == null + && marker.getPreviousBlueId() == null + && marker.getPosition() == null + && marker.getBlue() == null + && node.getName() == null + && node.getDescription() == null + && node.getType() == null + && node.getItemType() == null + && node.getKeyType() == null + && node.getValueType() == null + && node.getValue() == null + && node.getItems() == null + && node.getContracts() == null + && node.getReferenceBlueId() == null + && node.getSchema() == null + && node.getMergePolicy() == null + && node.getPreviousBlueId() == null + && node.getPosition() == null + && node.getBlue() == null; + } + + private static void validateEmptyPlaceholder(FrozenNode node, String path) { + if (isEmptyPlaceholder(node)) { + return; + } + throw new IllegalArgumentException("\"$empty\" list placeholder must have exact shape { \"$empty\": true }. Path: " + path); + } + + private static void validateSchemaNodes(Schema schema, String path) { + if (schema == null) { + return; + } + validateSchemaNode(schema.getRequired(), appendPath(path, "required")); + validateSchemaNode(schema.getMinLength(), appendPath(path, "minLength")); + validateSchemaNode(schema.getMaxLength(), appendPath(path, "maxLength")); + validateSchemaNode(schema.getMinimum(), appendPath(path, "minimum")); + validateSchemaNode(schema.getMaximum(), appendPath(path, "maximum")); + validateSchemaNode(schema.getExclusiveMinimum(), appendPath(path, "exclusiveMinimum")); + validateSchemaNode(schema.getExclusiveMaximum(), appendPath(path, "exclusiveMaximum")); + validateSchemaNode(schema.getMultipleOf(), appendPath(path, "multipleOf")); + validateSchemaNode(schema.getMinItems(), appendPath(path, "minItems")); + validateSchemaNode(schema.getMaxItems(), appendPath(path, "maxItems")); + validateSchemaNode(schema.getUniqueItems(), appendPath(path, "uniqueItems")); + validateSchemaNode(schema.getMinFields(), appendPath(path, "minFields")); + validateSchemaNode(schema.getMaxFields(), appendPath(path, "maxFields")); + if (schema.getEnum() != null) { + for (int i = 0; i < schema.getEnum().size(); i++) { + validateSchemaNode(schema.getEnum().get(i), appendPath(path, "enum", i)); + } + } + } + + private static void validateSchemaNode(blue.language.model.Node node, String path) { + if (node != null) { + NodeToBlueIdInput.get(node); + } + } + + private static Object handleValue(Object value, String valueTypeBlueId) { + if (DOUBLE_TYPE_BLUE_ID.equals(valueTypeBlueId)) { + return BlueNumbers.toCanonicalDoubleValue(value); + } + if (value instanceof BigInteger) { + BigInteger bigIntValue = (BigInteger) value; + BigInteger lowerBound = BigInteger.valueOf(-9007199254740991L); + BigInteger upperBound = BigInteger.valueOf(9007199254740991L); + if (bigIntValue.compareTo(lowerBound) < 0 || bigIntValue.compareTo(upperBound) > 0) { + return bigIntValue.toString(); + } + } + return value; + } + + private static String inferTypeBlueId(Object value) { + if (value instanceof String) { + return TEXT_TYPE_BLUE_ID; + } + if (value instanceof BigInteger) { + return INTEGER_TYPE_BLUE_ID; + } + if (value instanceof BigDecimal) { + return DOUBLE_TYPE_BLUE_ID; + } + if (value instanceof Boolean) { + return BOOLEAN_TYPE_BLUE_ID; + } + return null; + } + + private static String appendPath(String path, String segment) { + String prefix = path == null || path.isEmpty() ? "/" : path; + if ("/".equals(prefix)) { + return "/" + escapePathSegment(segment); + } + return prefix + "/" + escapePathSegment(segment); + } + + private static String appendPath(String path, String segment, int index) { + return appendPath(appendPath(path, segment), String.valueOf(index)); + } + + private static boolean isTypePosition(String path) { + return path != null && (path.endsWith("/" + OBJECT_TYPE) + || path.endsWith("/" + OBJECT_ITEM_TYPE) + || path.endsWith("/" + OBJECT_KEY_TYPE) + || path.endsWith("/" + OBJECT_VALUE_TYPE)); + } + + private static String escapePathSegment(String segment) { + return segment.replace("~", "~0").replace("/", "~1"); + } +} diff --git a/src/main/java/blue/language/utils/BlueIdCalculator.java b/src/main/java/blue/language/utils/BlueIdCalculator.java index aa96402..c83776c 100644 --- a/src/main/java/blue/language/utils/BlueIdCalculator.java +++ b/src/main/java/blue/language/utils/BlueIdCalculator.java @@ -2,10 +2,8 @@ import blue.language.model.Node; -import java.math.BigInteger; import java.util.*; import java.util.function.Function; -import java.util.stream.Collectors; import static blue.language.utils.Properties.*; @@ -20,22 +18,44 @@ public BlueIdCalculator(Function hashProvider) { } public static String calculateBlueId(Node node) { + return BlueIdCalculator.INSTANCE.calculate(NodeToBlueIdInput.get(node)); + } + + public static String calculateUncheckedBlueId(Node node) { return BlueIdCalculator.INSTANCE.calculate(NodeToMapListOrValue.get(node)); } + public static String calculateBlueIdAllowingCyclicPlaceholders(Node node) { + return BlueIdCalculator.INSTANCE.calculate(NodeToBlueIdInput.getAllowingCyclicPlaceholders(node)); + } + public static String calculateBlueId(List nodes) { - List objects = nodes.stream() - .map(NodeToMapListOrValue::get) - .collect(Collectors.toList()); + List objects = new ArrayList<>(nodes.size()); + for (int i = 0; i < nodes.size(); i++) { + objects.add(NodeToBlueIdInput.getListElement(nodes.get(i), i)); + } + return BlueIdCalculator.INSTANCE.calculate(objects); + } + + public static String calculateUncheckedBlueId(List nodes) { + List objects = new ArrayList<>(nodes.size()); + for (Node node : nodes) { + objects.add(NodeToMapListOrValue.get(node)); + } + return BlueIdCalculator.INSTANCE.calculate(objects); + } + + public static String calculateBlueIdAllowingCyclicPlaceholders(List nodes) { + List objects = new ArrayList<>(nodes.size()); + for (int i = 0; i < nodes.size(); i++) { + objects.add(NodeToBlueIdInput.getListElementAllowingCyclicPlaceholders(nodes.get(i), i)); + } return BlueIdCalculator.INSTANCE.calculate(objects); } public String calculate(Object object) { // we invoke calculateCleanedObject method only once (for root) - Object cleaned = cleanStructure(object); - if (cleaned == null) { - return hashProvider.apply(Collections.emptyMap()); - } + Object cleaned = cleanRoot(object); return calculateCleanedObject(cleaned); } @@ -87,79 +107,101 @@ private String calculateList(List list) { return accumulator; } - private Object cleanStructure(Object obj) { + private Object cleanRoot(Object obj) { + if (obj == null) { + throw new IllegalArgumentException("Root null is not valid BlueId input."); + } + if (obj instanceof Map) { + return cleanMap((Map) obj, true); + } + if (obj instanceof List) { + return cleanList((List) obj); + } + return obj; + } + + private Object cleanObjectField(Object obj) { if (obj == null) { return null; - } else if (obj instanceof Map) { + } + if (obj instanceof Map) { + Map cleaned = cleanMap((Map) obj, false); + return ((Map) cleaned).isEmpty() ? null : cleaned; + } + if (obj instanceof List) { + return cleanList((List) obj); + } + return obj; + } + + private Object cleanListElement(Object obj, int index) { + if (obj == null) { + throw new IllegalArgumentException("Direct BlueId input must use { \"$empty\": true } for null list placeholders."); + } + if (obj instanceof Map) { Map map = (Map) obj; - Map cleanedMap = new LinkedHashMap<>(); - for (Map.Entry entry : map.entrySet()) { - Object cleanedValue = cleanStructure(entry.getValue()); - if (cleanedValue != null) { - cleanedMap.put(entry.getKey(), cleanedValue); - } + if (map.containsKey(LIST_CONTROL_EMPTY)) { + validateEmptyPlaceholder(map); } - return cleanedMap.isEmpty() ? null : cleanedMap; - } else if (obj instanceof List) { - List list = (List) obj; - List cleanedList = new ArrayList<>(); - for (Object item : list) { - Object cleanedItem = cleanStructure(item); - if (cleanedItem != null) { - cleanedList.add(cleanedItem); - } + if (map.isEmpty()) { + throw new IllegalArgumentException("Direct BlueId input must use { \"$empty\": true } for empty object list placeholders."); } - return normalizeListControls(cleanedList); - } else { - return obj; + Object cleaned = cleanMap(map, false); + if (((Map) cleaned).isEmpty()) { + throw new IllegalArgumentException("Direct BlueId input must use { \"$empty\": true } for empty object list placeholders."); + } + return cleaned; + } + if (obj instanceof List) { + return cleanList((List) obj); } + return obj; } - private Object normalizeListControls(List list) { - if (list.isEmpty()) { - return list; + private Map cleanMap(Map map, boolean root) { + if (map.containsKey(LIST_CONTROL_POS)) { + throw new IllegalArgumentException("\"$pos\" overlays are not valid direct BlueId input."); } - - List result = new ArrayList<>(); - int start = 0; - if (isPreviousControl(list.get(0))) { - result.add(list.get(0)); - start = 1; + if (map.containsKey(LIST_CONTROL_REPLACE)) { + throw new IllegalArgumentException("\"$replace\" overlays are not valid direct BlueId input."); } + if (map.containsKey(LIST_CONTROL_PREVIOUS) && !isPreviousControl(map)) { + throw new IllegalArgumentException("\"$previous\" must have shape { blueId: } and appear only as the first list item."); + } + Map cleanedMap = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + Object cleanedValue = cleanObjectField(entry.getValue()); + if (cleanedValue != null) { + cleanedMap.put(entry.getKey(), cleanedValue); + } + } + if (root || !cleanedMap.isEmpty()) { + return cleanedMap; + } + return cleanedMap; + } - Map positioned = new TreeMap<>(); - List appended = new ArrayList<>(); - boolean hasPositionControls = false; - - for (int i = start; i < list.size(); i++) { + private Object cleanList(List list) { + List cleanedList = new ArrayList<>(); + for (int i = 0; i < list.size(); i++) { Object item = list.get(i); - if (hasInvalidPreviousControl(item)) { - throw new IllegalArgumentException("\"$previous\" must have shape { blueId: } and appear only as the first list item."); + if (i == 0 && isPreviousControl(item)) { + cleanedList.add(item); + continue; } - if (isPreviousControl(item)) { + if (hasInvalidPreviousControl(item) || isPreviousControl(item)) { throw new IllegalArgumentException("\"$previous\" must appear only as the first list item."); } - if (isPositionControl(item)) { - hasPositionControls = true; - int position = positionValue(item); - if (positioned.put(position, withoutPosition(item)) != null) { - throw new IllegalArgumentException("Duplicate \"$pos\" value in list: " + position); - } - } else { - appended.add(item); - } - } - - if (!hasPositionControls) { - result.addAll(list.subList(start, list.size())); - return result; + cleanedList.add(cleanListElement(item, i)); } + return cleanedList; + } - for (Map.Entry entry : positioned.entrySet()) { - result.add(entry.getValue()); + private void validateEmptyPlaceholder(Map map) { + if (map.size() == 1 && Boolean.TRUE.equals(map.get(LIST_CONTROL_EMPTY))) { + return; } - result.addAll(appended); - return result; + throw new IllegalArgumentException("\"$empty\" list placeholder must have exact shape { \"$empty\": true }."); } private boolean isPreviousControl(Object item) { @@ -187,33 +229,4 @@ private String previousBlueId(Object item) { return (String) previous.get(OBJECT_BLUE_ID); } - private boolean isPositionControl(Object item) { - return item instanceof Map && ((Map) item).containsKey(LIST_CONTROL_POS); - } - - private int positionValue(Object item) { - Object value = ((Map) item).get(LIST_CONTROL_POS); - BigInteger position; - if (value instanceof BigInteger) { - position = (BigInteger) value; - } else if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) { - position = BigInteger.valueOf(((Number) value).longValue()); - } else { - throw new IllegalArgumentException("\"$pos\" must be a non-negative integer."); - } - if (position.signum() < 0 || position.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) { - throw new IllegalArgumentException("\"$pos\" must be a non-negative integer."); - } - return position.intValue(); - } - - private Object withoutPosition(Object item) { - Map map = new LinkedHashMap<>((Map) item); - map.remove(LIST_CONTROL_POS); - if (map.isEmpty()) { - throw new IllegalArgumentException("\"$pos\" items must contain an overlay."); - } - return map; - } - } diff --git a/src/main/java/blue/language/utils/BlueIds.java b/src/main/java/blue/language/utils/BlueIds.java index 52ddff7..d9a494f 100644 --- a/src/main/java/blue/language/utils/BlueIds.java +++ b/src/main/java/blue/language/utils/BlueIds.java @@ -7,52 +7,70 @@ public class BlueIds { - private static final int MIN_BLUE_ID_LENGTH = 43; - private static final int MAX_BLUE_ID_LENGTH = 45; - private static final Pattern BLUE_ID_PATTERN = Pattern.compile("^[1-9A-HJ-NP-Za-km-z]{43,45}(?:#\\d+)?$"); + private static final Pattern PLAIN_BLUE_ID_PATTERN = Pattern.compile("^[1-9A-HJ-NP-Za-km-z]+$"); + private static final Pattern CYCLIC_MEMBER_PATTERN = Pattern.compile("^([1-9A-HJ-NP-Za-km-z]+)#(0|[1-9]\\d*)$"); + private static final Pattern THIS_MEMBER_PATTERN = Pattern.compile("^this#(0|[1-9]\\d*)$"); + private static final Pattern ZERO_PLACEHOLDER_PATTERN = Pattern.compile("^0{44}$"); public static boolean isPotentialBlueId(String value) { if (value == null || value.isEmpty()) { return false; } - if (!BLUE_ID_PATTERN.matcher(value).matches()) { + try { + requireBlueIdOrCyclicMember(value, "blueId"); + return true; + } catch (IllegalArgumentException e) { return false; } + } - String[] parts = value.split("#"); - String blueIdPart = parts[0]; - - int blueIdLength = blueIdPart.length(); - if (blueIdLength < MIN_BLUE_ID_LENGTH || blueIdLength > MAX_BLUE_ID_LENGTH) { - return false; + public static String requirePlainBlueId(String value, String path) { + if (value == null || value.isEmpty() || !PLAIN_BLUE_ID_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Expected canonical Base58 SHA-256 BlueId at " + path + "."); } - + byte[] decoded; try { - byte[] decoded = Base58.decode(blueIdPart); - if (decoded.length != 32) { - return false; - } + decoded = Base58.decode(value); } catch (IllegalArgumentException e) { - return false; + throw new IllegalArgumentException("Expected canonical Base58 SHA-256 BlueId at " + path + ".", e); + } + if (decoded.length != 32 || !Base58.encode(decoded).equals(value)) { + throw new IllegalArgumentException("Expected canonical Base58 SHA-256 BlueId at " + path + "."); } + return value; + } - if (parts.length > 1) { - try { - int index = Integer.parseInt(parts[1]); - if (index < 0) { - return false; - } - } catch (NumberFormatException e) { - return false; - } + public static String requireBlueIdOrCyclicMember(String value, String path) { + if (value == null) { + throw new IllegalArgumentException("Expected BlueId at " + path + "."); + } + java.util.regex.Matcher cyclic = CYCLIC_MEMBER_PATTERN.matcher(value); + if (cyclic.matches()) { + requirePlainBlueId(cyclic.group(1), path); + return value; } + if (value.indexOf('#') >= 0) { + throw new IllegalArgumentException("Invalid cyclic BlueId member syntax at " + path + "."); + } + return requirePlainBlueId(value, path); + } + + public static String requireNoThisPlaceholderOutsideCyclicApi(String value, String path) { + if (value != null && ("this".equals(value) || THIS_MEMBER_PATTERN.matcher(value).matches())) { + throw new IllegalArgumentException("\"this\" BlueId placeholders are valid only inside cyclic BlueId calculation APIs. Path: " + path); + } + return value; + } - return true; + public static boolean isCyclicCalculationPlaceholder(String value) { + return value != null && ("this".equals(value) + || THIS_MEMBER_PATTERN.matcher(value).matches() + || ZERO_PLACEHOLDER_PATTERN.matcher(value).matches()); } public static Optional getBlueId(Class clazz) { return Optional.ofNullable(BlueIdResolver.resolveBlueId(clazz)); } -} \ No newline at end of file +} diff --git a/src/main/java/blue/language/utils/CircularBlueIdCalculator.java b/src/main/java/blue/language/utils/CircularBlueIdCalculator.java new file mode 100644 index 0000000..c161488 --- /dev/null +++ b/src/main/java/blue/language/utils/CircularBlueIdCalculator.java @@ -0,0 +1,202 @@ +package blue.language.utils; + +import blue.language.model.Node; +import blue.language.model.Schema; +import blue.language.provider.NodeContentHandler; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class CircularBlueIdCalculator { + + private static final Pattern THIS_REFERENCE_PATTERN = Pattern.compile("^this(#\\d+)?$"); + private static final Pattern THIS_INDEX_REFERENCE_PATTERN = Pattern.compile("^this#(\\d+)$"); + + private CircularBlueIdCalculator() { + } + + public static List calculateCircularSetBlueIds(List documents) { + if (documents == null || documents.isEmpty()) { + throw new IllegalArgumentException("Circular BlueId calculation requires at least one document."); + } + List references = findThisReferences(documents); + if (references.isEmpty()) { + throw new IllegalArgumentException("Circular BlueId calculation requires at least one internal this reference."); + } + validateMultiDocumentReferences(references, documents.size()); + + List indexedNodes = new ArrayList<>(); + for (int i = 0; i < documents.size(); i++) { + Node preliminary = documents.get(i).clone(); + rewriteThisReferences(preliminary, reference -> NodeContentHandler.ZERO_BLUE_ID); + indexedNodes.add(new IndexedNode(i, documents.get(i), + BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders(preliminary))); + } + + indexedNodes.sort(Comparator + .comparing((IndexedNode indexedNode) -> indexedNode.preliminaryBlueId) + .thenComparingInt(indexedNode -> indexedNode.originalIndex)); + + Map originalIndexToSortedIndex = new HashMap<>(); + for (int sortedIndex = 0; sortedIndex < indexedNodes.size(); sortedIndex++) { + originalIndexToSortedIndex.put(indexedNodes.get(sortedIndex).originalIndex, sortedIndex); + } + + List sortedNodes = new ArrayList<>(); + for (IndexedNode indexedNode : indexedNodes) { + Node rewritten = indexedNode.node.clone(); + rewriteThisReferences(rewritten, reference -> { + int targetIndex = parseThisIndex(reference); + return "this#" + originalIndexToSortedIndex.get(targetIndex); + }); + sortedNodes.add(rewritten); + } + + String masterBlueId = BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders(sortedNodes); + List result = new ArrayList<>(documents.size()); + for (int originalIndex = 0; originalIndex < documents.size(); originalIndex++) { + result.add(masterBlueId + "#" + originalIndexToSortedIndex.get(originalIndex)); + } + return result; + } + + private static void validateMultiDocumentReferences(List references, int documentCount) { + for (ThisReference reference : references) { + Matcher matcher = THIS_INDEX_REFERENCE_PATTERN.matcher(reference.value); + if (!matcher.matches()) { + throw new IllegalArgumentException("Cyclic BlueId calculation requires indexed 'this#' references."); + } + int targetIndex = Integer.parseInt(matcher.group(1)); + if (targetIndex >= documentCount) { + throw new IllegalArgumentException("'this#" + targetIndex + "' points outside the cyclic document set."); + } + } + } + + private static int parseThisIndex(String reference) { + Matcher matcher = THIS_INDEX_REFERENCE_PATTERN.matcher(reference); + if (!matcher.matches()) { + throw new IllegalArgumentException("Expected indexed this reference but found: " + reference); + } + return Integer.parseInt(matcher.group(1)); + } + + private static List findThisReferences(List nodes) { + List references = new ArrayList<>(); + nodes.forEach(node -> collectThisReferences(node, references)); + return references; + } + + private static void collectThisReferences(Node node, List references) { + if (node == null) { + return; + } + if (node.getBlueId() != null && THIS_REFERENCE_PATTERN.matcher(node.getBlueId()).matches()) { + references.add(new ThisReference(node.getBlueId())); + } + collectThisReferences(node.getType(), references); + collectThisReferences(node.getItemType(), references); + collectThisReferences(node.getKeyType(), references); + collectThisReferences(node.getValueType(), references); + collectThisReferences(node.getBlue(), references); + collectThisReferences(node.getContracts(), references); + collectThisReferences(node.getSchema(), references); + if (node.getItems() != null) { + node.getItems().forEach(item -> collectThisReferences(item, references)); + } + if (node.getProperties() != null) { + node.getProperties().values().forEach(value -> collectThisReferences(value, references)); + } + } + + private static void collectThisReferences(Schema schema, List references) { + if (schema == null) { + return; + } + collectThisReferences(schema.getRequired(), references); + collectThisReferences(schema.getMinLength(), references); + collectThisReferences(schema.getMaxLength(), references); + collectThisReferences(schema.getMinimum(), references); + collectThisReferences(schema.getMaximum(), references); + collectThisReferences(schema.getExclusiveMinimum(), references); + collectThisReferences(schema.getExclusiveMaximum(), references); + collectThisReferences(schema.getMultipleOf(), references); + collectThisReferences(schema.getMinItems(), references); + collectThisReferences(schema.getMaxItems(), references); + collectThisReferences(schema.getUniqueItems(), references); + collectThisReferences(schema.getMinFields(), references); + collectThisReferences(schema.getMaxFields(), references); + if (schema.getEnum() != null) { + schema.getEnum().forEach(node -> collectThisReferences(node, references)); + } + } + + private static void rewriteThisReferences(Node node, java.util.function.Function replacement) { + if (node == null) { + return; + } + if (node.getBlueId() != null && THIS_REFERENCE_PATTERN.matcher(node.getBlueId()).matches()) { + node.blueId(replacement.apply(node.getBlueId())); + } + rewriteThisReferences(node.getType(), replacement); + rewriteThisReferences(node.getItemType(), replacement); + rewriteThisReferences(node.getKeyType(), replacement); + rewriteThisReferences(node.getValueType(), replacement); + rewriteThisReferences(node.getBlue(), replacement); + rewriteThisReferences(node.getContracts(), replacement); + rewriteThisReferences(node.getSchema(), replacement); + if (node.getItems() != null) { + node.getItems().forEach(item -> rewriteThisReferences(item, replacement)); + } + if (node.getProperties() != null) { + node.getProperties().values().forEach(value -> rewriteThisReferences(value, replacement)); + } + } + + private static void rewriteThisReferences(Schema schema, java.util.function.Function replacement) { + if (schema == null) { + return; + } + rewriteThisReferences(schema.getRequired(), replacement); + rewriteThisReferences(schema.getMinLength(), replacement); + rewriteThisReferences(schema.getMaxLength(), replacement); + rewriteThisReferences(schema.getMinimum(), replacement); + rewriteThisReferences(schema.getMaximum(), replacement); + rewriteThisReferences(schema.getExclusiveMinimum(), replacement); + rewriteThisReferences(schema.getExclusiveMaximum(), replacement); + rewriteThisReferences(schema.getMultipleOf(), replacement); + rewriteThisReferences(schema.getMinItems(), replacement); + rewriteThisReferences(schema.getMaxItems(), replacement); + rewriteThisReferences(schema.getUniqueItems(), replacement); + rewriteThisReferences(schema.getMinFields(), replacement); + rewriteThisReferences(schema.getMaxFields(), replacement); + if (schema.getEnum() != null) { + schema.getEnum().forEach(node -> rewriteThisReferences(node, replacement)); + } + } + + private static final class ThisReference { + private final String value; + + private ThisReference(String value) { + this.value = value; + } + } + + private static final class IndexedNode { + private final int originalIndex; + private final Node node; + private final String preliminaryBlueId; + + private IndexedNode(int originalIndex, Node node, String preliminaryBlueId) { + this.originalIndex = originalIndex; + this.node = node; + this.preliminaryBlueId = preliminaryBlueId; + } + } +} diff --git a/src/main/java/blue/language/utils/FrozenTypeMatcher.java b/src/main/java/blue/language/utils/FrozenTypeMatcher.java index 73efa30..970dc07 100644 --- a/src/main/java/blue/language/utils/FrozenTypeMatcher.java +++ b/src/main/java/blue/language/utils/FrozenTypeMatcher.java @@ -406,7 +406,6 @@ private boolean matchesSchema(FrozenNode node, Schema schema) { try { verifyWellFormed(schema); return verifyRequired(schema, node) - && verifyAllowMultiple(schema, node) && verifyMinLength(schema, node) && verifyMaxLength(schema, node) && verifyMinimum(schema, node) @@ -426,15 +425,15 @@ && verifyMaxFields(schema, node) } private void verifyWellFormed(Schema schema) { - verifyNonNegative(schema.getMinLengthValue()); - verifyNonNegative(schema.getMaxLengthValue()); - verifyMinLessThanOrEqualMax(schema.getMinLengthValue(), schema.getMaxLengthValue()); - verifyNonNegative(schema.getMinItemsValue()); - verifyNonNegative(schema.getMaxItemsValue()); - verifyMinLessThanOrEqualMax(schema.getMinItemsValue(), schema.getMaxItemsValue()); - verifyNonNegative(schema.getMinFieldsValue()); - verifyNonNegative(schema.getMaxFieldsValue()); - verifyMinLessThanOrEqualMax(schema.getMinFieldsValue(), schema.getMaxFieldsValue()); + verifyNonNegative(schema.getMinLengthExact()); + verifyNonNegative(schema.getMaxLengthExact()); + verifyMinLessThanOrEqualMax(schema.getMinLengthExact(), schema.getMaxLengthExact()); + verifyNonNegative(schema.getMinItemsExact()); + verifyNonNegative(schema.getMaxItemsExact()); + verifyMinLessThanOrEqualMax(schema.getMinItemsExact(), schema.getMaxItemsExact()); + verifyNonNegative(schema.getMinFieldsExact()); + verifyNonNegative(schema.getMaxFieldsExact()); + verifyMinLessThanOrEqualMax(schema.getMinFieldsExact(), schema.getMaxFieldsExact()); if (schema.getMinimumValue() != null && schema.getMaximumValue() != null && schema.getMinimumValue().compareTo(schema.getMaximumValue()) > 0) { @@ -451,14 +450,14 @@ private void verifyWellFormed(Schema schema) { } } - private void verifyNonNegative(Integer value) { - if (value != null && value < 0) { + private void verifyNonNegative(BigInteger value) { + if (value != null && value.signum() < 0) { throw new IllegalArgumentException("schema value must be non-negative"); } } - private void verifyMinLessThanOrEqualMax(Integer min, Integer max) { - if (min != null && max != null && min > max) { + private void verifyMinLessThanOrEqualMax(BigInteger min, BigInteger max) { + if (min != null && max != null && min.compareTo(max) > 0) { throw new IllegalArgumentException("schema min must be <= max"); } } @@ -467,23 +466,18 @@ private boolean verifyRequired(Schema schema, FrozenNode node) { return !Boolean.TRUE.equals(schema.getRequiredValue()) || hasPayload(node); } - private boolean verifyAllowMultiple(Schema schema, FrozenNode node) { - List items = node.getItems(); - return Boolean.TRUE.equals(schema.getAllowMultipleValue()) || items == null || items.size() <= 1; - } - private boolean verifyMinLength(Schema schema, FrozenNode node) { - Integer minLength = schema.getMinLengthValue(); + BigInteger minLength = schema.getMinLengthExact(); Object value = node.getValue(); return minLength == null || !(value instanceof String) - || ((String) value).codePointCount(0, ((String) value).length()) >= minLength; + || BigInteger.valueOf(((String) value).codePointCount(0, ((String) value).length())).compareTo(minLength) >= 0; } private boolean verifyMaxLength(Schema schema, FrozenNode node) { - Integer maxLength = schema.getMaxLengthValue(); + BigInteger maxLength = schema.getMaxLengthExact(); Object value = node.getValue(); return maxLength == null || !(value instanceof String) - || ((String) value).codePointCount(0, ((String) value).length()) <= maxLength; + || BigInteger.valueOf(((String) value).codePointCount(0, ((String) value).length())).compareTo(maxLength) <= 0; } private boolean verifyMinimum(Schema schema, FrozenNode node) { @@ -521,15 +515,15 @@ private int compareNumber(Object value, BigDecimal bound) { } private boolean verifyMinItems(Schema schema, FrozenNode node) { - Integer minItems = schema.getMinItemsValue(); + BigInteger minItems = schema.getMinItemsExact(); int size = node.getItems() != null ? node.getItems().size() : 0; - return minItems == null || size >= minItems; + return minItems == null || BigInteger.valueOf(size).compareTo(minItems) >= 0; } private boolean verifyMaxItems(Schema schema, FrozenNode node) { - Integer maxItems = schema.getMaxItemsValue(); + BigInteger maxItems = schema.getMaxItemsExact(); int size = node.getItems() != null ? node.getItems().size() : 0; - return maxItems == null || size <= maxItems; + return maxItems == null || BigInteger.valueOf(size).compareTo(maxItems) <= 0; } private boolean verifyUniqueItems(Schema schema, FrozenNode node) { @@ -546,15 +540,15 @@ private boolean verifyUniqueItems(Schema schema, FrozenNode node) { } private boolean verifyMinFields(Schema schema, FrozenNode node) { - Integer minFields = schema.getMinFieldsValue(); + BigInteger minFields = schema.getMinFieldsExact(); int size = node.getProperties() != null ? node.getProperties().size() : 0; - return minFields == null || size >= minFields; + return minFields == null || BigInteger.valueOf(size).compareTo(minFields) >= 0; } private boolean verifyMaxFields(Schema schema, FrozenNode node) { - Integer maxFields = schema.getMaxFieldsValue(); + BigInteger maxFields = schema.getMaxFieldsExact(); int size = node.getProperties() != null ? node.getProperties().size() : 0; - return maxFields == null || size <= maxFields; + return maxFields == null || BigInteger.valueOf(size).compareTo(maxFields) <= 0; } private boolean verifyEnum(Schema schema, FrozenNode node) { @@ -730,6 +724,7 @@ private void stripLabels(Node node) { stripLabels(node.getKeyType()); stripLabels(node.getValueType()); stripLabels(node.getBlue()); + stripLabels(node.getContracts()); if (node.getItems() != null) { node.getItems().forEach(this::stripLabels); } @@ -744,7 +739,6 @@ private void stripSchemaLabels(Schema schema) { return; } stripLabels(schema.getRequired()); - stripLabels(schema.getAllowMultiple()); stripLabels(schema.getMinLength()); stripLabels(schema.getMaxLength()); stripLabels(schema.getMinimum()); diff --git a/src/main/java/blue/language/utils/MergeReverser.java b/src/main/java/blue/language/utils/MergeReverser.java index 6a963a7..412da89 100644 --- a/src/main/java/blue/language/utils/MergeReverser.java +++ b/src/main/java/blue/language/utils/MergeReverser.java @@ -12,13 +12,29 @@ public class MergeReverser { + /** + * @deprecated Use {@link #reverseToCanonicalOverlay(Node)} for Content + * BlueId identity or {@link #reverseToMinimizedOverlay(Node)} for + * author-facing minimized output. + */ + @Deprecated public Node reverse(Node mergedNode) { + return reverseToMinimizedOverlay(mergedNode); + } + + public Node reverseToMinimizedOverlay(Node mergedNode) { Node minimalNode = new Node(); - reverseNode(minimalNode, mergedNode, mergedNode.getType()); + reverseNode(minimalNode, mergedNode, mergedNode.getType(), false); return minimalNode; } - private void reverseNode(Node minimal, Node merged, Node fromType) { + public Node reverseToCanonicalOverlay(Node mergedNode) { + Node minimalNode = new Node(); + reverseNode(minimalNode, mergedNode, mergedNode.getType(), true); + return minimalNode; + } + + private void reverseNode(Node minimal, Node merged, Node fromType, boolean canonicalOverlay) { if (merged.getBlueId() != null && fromType != null && merged.getBlueId().equals(fromType.getBlueId())) { return; @@ -44,7 +60,7 @@ private void reverseNode(Node minimal, Node merged, Node fromType) { minimal.description(merged.getDescription()); } - if (merged.getBlueId() != null && (fromType == null || !merged.getBlueId().equals(fromType.getBlueId()))) { + if (merged.isReferenceOnly() && (fromType == null || !merged.getBlueId().equals(fromType.getBlueId()))) { minimal.blueId(merged.getBlueId()); } if (merged.getMergePolicy() != null && (fromType == null || !merged.getMergePolicy().equals(fromType.getMergePolicy()))) { @@ -53,10 +69,31 @@ private void reverseNode(Node minimal, Node merged, Node fromType) { if (merged.getSchema() != null && (fromType == null || !sameSchema(merged.getSchema(), fromType.getSchema()))) { minimal.schema(merged.getSchema().clone()); } + if (merged.getContracts() != null) { + Node fromTypeContracts = fromType != null ? fromType.getContracts() : null; + if (!sameNodeBlueId(merged.getContracts(), fromTypeContracts)) { + Node minimalContracts = new Node(); + reverseNode(minimalContracts, merged.getContracts(), fromTypeContracts, canonicalOverlay); + if (!Nodes.isEmptyNode(minimalContracts)) { + minimal.contracts(minimalContracts); + } + } + } if (merged.getItems() != null) { List minimalItems = new ArrayList<>(); - if (fromType != null && fromType.getItems() != null) { + if (canonicalOverlay) { + for (Node item : merged.getItems()) { + Node minimalItem = new Node(); + reverseNode(minimalItem, item, null, true); + if (Nodes.isEmptyNode(minimalItem)) { + minimalItems.add(Nodes.emptyPlaceholder()); + } else { + minimalItems.add(minimalItem); + } + } + minimal.items(minimalItems); + } else if (fromType != null && fromType.getItems() != null) { List inheritedItems = fromType.getItems(); int inheritedSize = inheritedItems.size(); if (merged.getItems().size() < inheritedSize) { @@ -69,7 +106,7 @@ private void reverseNode(Node minimal, Node merged, Node fromType) { continue; } Node minimalItem = new Node(); - reverseNode(minimalItem, merged.getItems().get(i), inheritedItems.get(i)); + reverseNode(minimalItem, merged.getItems().get(i), inheritedItems.get(i), false); if (!Nodes.isEmptyNode(minimalItem)) { minimalItem.position(i); minimalItems.add(minimalItem); @@ -78,7 +115,7 @@ private void reverseNode(Node minimal, Node merged, Node fromType) { for (int i = inheritedSize; i < merged.getItems().size(); i++) { Node minimalItem = new Node(); - reverseNode(minimalItem, merged.getItems().get(i), null); + reverseNode(minimalItem, merged.getItems().get(i), null, false); minimalItems.add(minimalItem); } @@ -90,7 +127,7 @@ private void reverseNode(Node minimal, Node merged, Node fromType) { } else { for (Node item : merged.getItems()) { Node minimalItem = new Node(); - reverseNode(minimalItem, item, null); + reverseNode(minimalItem, item, null, false); minimalItems.add(minimalItem); } minimal.items(minimalItems); @@ -98,7 +135,7 @@ private void reverseNode(Node minimal, Node merged, Node fromType) { } if (merged.getProperties() != null) { - Map minimalProperties = new HashMap<>(); + Map minimalProperties = new LinkedHashMap<>(); for (Map.Entry entry : merged.getProperties().entrySet()) { String key = entry.getKey(); Node mergedProperty = entry.getValue(); @@ -110,7 +147,7 @@ private void reverseNode(Node minimal, Node merged, Node fromType) { continue; } Node minimalProperty = new Node(); - reverseNode(minimalProperty, mergedProperty, fromTypeProperty); + reverseNode(minimalProperty, mergedProperty, fromTypeProperty, canonicalOverlay); if (!Nodes.isEmptyNode(minimalProperty)) { minimalProperties.put(key, minimalProperty); } @@ -140,7 +177,11 @@ private boolean sameNodeBlueId(Node left, Node right) { if (left == null || right == null) { return false; } - return BlueIdCalculator.calculateBlueId(left).equals(BlueIdCalculator.calculateBlueId(right)); + return comparisonBlueId(left).equals(comparisonBlueId(right)); + } + + private String comparisonBlueId(Node node) { + return BlueIdCalculator.INSTANCE.calculate(NodeToBlueIdInput.getWithResolvedBlueIdMetadata(node)); } private void setTypeIfDifferent(Node merged, Node fromType, Node minimal, diff --git a/src/main/java/blue/language/utils/NodeExtender.java b/src/main/java/blue/language/utils/NodeExtender.java index 9cd9171..c83083c 100644 --- a/src/main/java/blue/language/utils/NodeExtender.java +++ b/src/main/java/blue/language/utils/NodeExtender.java @@ -76,6 +76,9 @@ private void extendNode(Node currentNode, Limits currentLimits, String currentSe if (currentNode.getValueType() != null) { extendNode(currentNode.getValueType(), currentLimits, "valueType", true); } + if (currentNode.getContracts() != null) { + extendNode(currentNode.getContracts(), currentLimits, "contracts", false); + } Map properties = currentNode.getProperties(); if (properties != null) { @@ -148,6 +151,7 @@ private void mergeNodes(Node target, Node source) { target.value(source.getValue()); target.items(source.getItems()); target.properties(source.getProperties()); + target.contracts(source.getContracts()); target.schema(source.getSchema()); target.mergePolicy(source.getMergePolicy()); target.previousBlueId(source.getPreviousBlueId()); diff --git a/src/main/java/blue/language/utils/NodePathAccessor.java b/src/main/java/blue/language/utils/NodePathAccessor.java index 9ffe627..910ae91 100644 --- a/src/main/java/blue/language/utils/NodePathAccessor.java +++ b/src/main/java/blue/language/utils/NodePathAccessor.java @@ -78,7 +78,9 @@ private static Node getNodeForSegment(Node node, String segment, Function items = node.getItems(); @@ -106,7 +113,7 @@ private static void setChild(Node node, String segment, Node value) { } Map properties = node.getProperties(); if (properties == null) { - node.properties(new HashMap<>()); + node.properties(new LinkedHashMap<>()); properties = node.getProperties(); } properties.put(segment, value); diff --git a/src/main/java/blue/language/utils/NodePathSelector.java b/src/main/java/blue/language/utils/NodePathSelector.java index 4efc585..6afd620 100644 --- a/src/main/java/blue/language/utils/NodePathSelector.java +++ b/src/main/java/blue/language/utils/NodePathSelector.java @@ -87,6 +87,11 @@ private static void traverseAllChildren(Node current, currentPath.remove(currentPath.size() - 1); } } + if (current.getContracts() != null) { + currentPath.add("contracts"); + select(current.getContracts(), pattern, index + 1, currentPath, predicate, selected); + currentPath.remove(currentPath.size() - 1); + } } private static void traverseListItems(Node current, @@ -121,6 +126,9 @@ private static Node childAtOrNull(Node node, String segment) { if ("blue".equals(segment)) { return node.getBlue(); } + if ("contracts".equals(segment)) { + return node.getContracts(); + } if (node.getItems() != null && isListIndex(segment)) { int index = Integer.parseInt(segment); return index < node.getItems().size() ? node.getItems().get(index) : null; diff --git a/src/main/java/blue/language/utils/NodeProviderWrapper.java b/src/main/java/blue/language/utils/NodeProviderWrapper.java index ea44560..b5eefda 100644 --- a/src/main/java/blue/language/utils/NodeProviderWrapper.java +++ b/src/main/java/blue/language/utils/NodeProviderWrapper.java @@ -3,16 +3,55 @@ import blue.language.NodeProvider; import blue.language.provider.BootstrapProvider; import blue.language.provider.SequentialNodeProvider; +import blue.language.provider.VerifyingNodeProvider; import java.util.Arrays; public class NodeProviderWrapper { public static NodeProvider wrap(NodeProvider originalProvider) { + if (isAlreadyWrapped(originalProvider)) { + return originalProvider; + } + if (originalProvider instanceof UnverifiedNodeProvider) { + return new SequentialNodeProvider( + Arrays.asList( + BootstrapProvider.INSTANCE, + originalProvider + ) + ); + } return new SequentialNodeProvider( Arrays.asList( BootstrapProvider.INSTANCE, - originalProvider + new VerifyingNodeProvider(originalProvider) ) ); } -} \ No newline at end of file + + public static NodeProvider unverified(NodeProvider originalProvider) { + return new UnverifiedNodeProvider(originalProvider); + } + + private static boolean isAlreadyWrapped(NodeProvider originalProvider) { + if (!(originalProvider instanceof SequentialNodeProvider)) { + return false; + } + return ((SequentialNodeProvider) originalProvider).getNodeProviders().stream() + .anyMatch(provider -> provider == BootstrapProvider.INSTANCE + || provider instanceof VerifyingNodeProvider + || provider instanceof UnverifiedNodeProvider); + } + + private static class UnverifiedNodeProvider implements NodeProvider { + private final NodeProvider delegate; + + private UnverifiedNodeProvider(NodeProvider delegate) { + this.delegate = delegate; + } + + @Override + public java.util.List fetchByBlueId(String blueId) { + return delegate.fetchByBlueId(blueId); + } + } +} diff --git a/src/main/java/blue/language/utils/NodeToBlueIdInput.java b/src/main/java/blue/language/utils/NodeToBlueIdInput.java new file mode 100644 index 0000000..82dcd5e --- /dev/null +++ b/src/main/java/blue/language/utils/NodeToBlueIdInput.java @@ -0,0 +1,339 @@ +package blue.language.utils; + +import blue.language.model.Node; +import blue.language.model.Schema; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static blue.language.utils.Properties.*; + +public final class NodeToBlueIdInput { + + private NodeToBlueIdInput() { + } + + public static Object get(Node node) { + return get(node, "/", Context.ROOT, -1, false); + } + + public static Object getAllowingCyclicPlaceholders(Node node) { + return get(node, "/", Context.ROOT, -1, true); + } + + static Object getListElement(Node node, int index) { + return get(node, "/" + index, Context.LIST_ELEMENT, index, false); + } + + static Object getListElementAllowingCyclicPlaceholders(Node node, int index) { + return get(node, "/" + index, Context.LIST_ELEMENT, index, true); + } + + public static Object getWithResolvedBlueIdMetadata(Node node) { + return get(stripResolvedBlueIdMetadata(node.clone()), "/", Context.ROOT, -1, false); + } + + public static Node stripResolvedBlueIdMetadata(Node node) { + if (node == null) { + return null; + } + if (node.getBlueId() != null && !node.isReferenceOnly()) { + node.blueId(null); + } + stripResolvedBlueIdMetadata(node.getType()); + stripResolvedBlueIdMetadata(node.getItemType()); + stripResolvedBlueIdMetadata(node.getKeyType()); + stripResolvedBlueIdMetadata(node.getValueType()); + stripResolvedBlueIdMetadata(node.getBlue()); + stripResolvedBlueIdMetadata(node.getContracts()); + if (node.getItems() != null) { + node.getItems().forEach(NodeToBlueIdInput::stripResolvedBlueIdMetadata); + } + if (node.getProperties() != null) { + node.getProperties().values().forEach(NodeToBlueIdInput::stripResolvedBlueIdMetadata); + } + stripResolvedBlueIdMetadata(node.getSchema()); + return node; + } + + private static void stripResolvedBlueIdMetadata(Schema schema) { + if (schema == null) { + return; + } + stripResolvedBlueIdMetadata(schema.getRequired()); + stripResolvedBlueIdMetadata(schema.getMinLength()); + stripResolvedBlueIdMetadata(schema.getMaxLength()); + stripResolvedBlueIdMetadata(schema.getMinimum()); + stripResolvedBlueIdMetadata(schema.getMaximum()); + stripResolvedBlueIdMetadata(schema.getExclusiveMinimum()); + stripResolvedBlueIdMetadata(schema.getExclusiveMaximum()); + stripResolvedBlueIdMetadata(schema.getMultipleOf()); + stripResolvedBlueIdMetadata(schema.getMinItems()); + stripResolvedBlueIdMetadata(schema.getMaxItems()); + stripResolvedBlueIdMetadata(schema.getUniqueItems()); + stripResolvedBlueIdMetadata(schema.getMinFields()); + stripResolvedBlueIdMetadata(schema.getMaxFields()); + if (schema.getEnum() != null) { + schema.getEnum().forEach(NodeToBlueIdInput::stripResolvedBlueIdMetadata); + } + } + + private enum Context { + ROOT, + OBJECT_FIELD, + LIST_ELEMENT, + METADATA + } + + private static Object get(Node node, String path, Context context, int listIndex, boolean allowCyclicPlaceholders) { + validateBlueIdInput(node, path, context, listIndex); + + if (context == Context.LIST_ELEMENT && Nodes.isEmptyPlaceholder(node)) { + Map placeholder = new LinkedHashMap<>(); + placeholder.put(LIST_CONTROL_EMPTY, true); + return placeholder; + } + + if (node.isReferenceOnly()) { + String blueId = validateReferenceBlueId(node.getBlueId(), appendPath(path, OBJECT_BLUE_ID), allowCyclicPlaceholders); + Map reference = new LinkedHashMap<>(); + reference.put(OBJECT_BLUE_ID, blueId); + return reference; + } + + if (node.getPreviousBlueId() != null) { + String previousBlueId = BlueIds.requirePlainBlueId( + node.getPreviousBlueId(), + appendPath(appendPath(path, LIST_CONTROL_PREVIOUS), OBJECT_BLUE_ID)); + Map previous = new LinkedHashMap<>(); + previous.put(OBJECT_BLUE_ID, previousBlueId); + Map result = new LinkedHashMap<>(); + result.put(LIST_CONTROL_PREVIOUS, previous); + return result; + } + + Object value = node.getValue(); + List items = null; + if (node.getItems() != null) { + items = new ArrayList<>(node.getItems().size()); + for (int i = 0; i < node.getItems().size(); i++) { + items.add(get(node.getItems().get(i), appendPath(path, OBJECT_ITEMS, i), Context.LIST_ELEMENT, i, allowCyclicPlaceholders)); + } + } + + Map result = new LinkedHashMap<>(); + if (node.getName() != null) + result.put(OBJECT_NAME, node.getName()); + if (node.getDescription() != null) + result.put(OBJECT_DESCRIPTION, node.getDescription()); + + String valueTypeBlueId = null; + if (value != null && node.getType() == null) { + String inferredTypeBlueId = inferTypeBlueId(value); + if (inferredTypeBlueId != null) { + valueTypeBlueId = inferredTypeBlueId; + Map map = new LinkedHashMap<>(); + map.put(OBJECT_BLUE_ID, inferredTypeBlueId); + result.put(OBJECT_TYPE, map); + } + } else if (node.getType() != null) { + valueTypeBlueId = node.getType().getBlueId(); + result.put(OBJECT_TYPE, get(node.getType(), appendPath(path, OBJECT_TYPE), Context.METADATA, -1, allowCyclicPlaceholders)); + } + + if (node.getItemType() != null) + result.put(OBJECT_ITEM_TYPE, get(node.getItemType(), appendPath(path, OBJECT_ITEM_TYPE), Context.METADATA, -1, allowCyclicPlaceholders)); + if (node.getKeyType() != null) + result.put(OBJECT_KEY_TYPE, get(node.getKeyType(), appendPath(path, OBJECT_KEY_TYPE), Context.METADATA, -1, allowCyclicPlaceholders)); + if (node.getValueType() != null) + result.put(OBJECT_VALUE_TYPE, get(node.getValueType(), appendPath(path, OBJECT_VALUE_TYPE), Context.METADATA, -1, allowCyclicPlaceholders)); + if (node.getMergePolicy() != null) + result.put(OBJECT_MERGE_POLICY, node.getMergePolicy()); + if (value != null) + result.put(OBJECT_VALUE, handleValue(value, valueTypeBlueId)); + if (items != null) + result.put(OBJECT_ITEMS, items); + if (node.getSchema() != null) { + validateSchemaNodes(node.getSchema(), appendPath(path, OBJECT_SCHEMA)); + result.put(OBJECT_SCHEMA, SchemaToMapListOrValue.get( + node.getSchema(), + child -> get(child, appendPath(path, OBJECT_SCHEMA), Context.METADATA, -1, allowCyclicPlaceholders))); + } + if (node.getContracts() != null) { + result.put(OBJECT_CONTRACTS, get(node.getContracts(), appendPath(path, OBJECT_CONTRACTS), Context.METADATA, -1, allowCyclicPlaceholders)); + } + if (node.getProperties() != null) { + node.getProperties().forEach((key, propertyValue) -> + result.put(key, get(propertyValue, appendPath(path, key), Context.OBJECT_FIELD, -1, allowCyclicPlaceholders))); + } + return result; + } + + private static String validateReferenceBlueId(String blueId, String path, boolean allowCyclicPlaceholders) { + if (allowCyclicPlaceholders && BlueIds.isCyclicCalculationPlaceholder(blueId)) { + return blueId; + } + return BlueIds.requireBlueIdOrCyclicMember( + BlueIds.requireNoThisPlaceholderOutsideCyclicApi(blueId, path), + path); + } + + private static void validateBlueIdInput(Node node, String path, Context context, int listIndex) { + if (node == null) { + throw new IllegalArgumentException("BlueId input must not contain null nodes. Path: " + path); + } + if (context == Context.METADATA && isTypePosition(path) && node.isInlineValue()) { + throw new IllegalArgumentException("Direct BlueId input must not contain unresolved type aliases. Path: " + path); + } + if (node.getBlue() != null) { + throw new IllegalArgumentException( + "\"blue\" is a preprocessing directive and must not be present in BlueId input. " + + "Call preprocess/canonicalize/calculateSemanticBlueId first. Path: " + path); + } + if (node.getPosition() != null) { + throw new IllegalArgumentException("\"$pos\" overlays are not valid direct BlueId input. Path: " + path); + } + if (node.getProperties() != null && node.getProperties().containsKey(LIST_CONTROL_REPLACE)) { + throw new IllegalArgumentException("\"$replace\" overlays are not valid direct BlueId input. Path: " + path); + } + if (context == Context.LIST_ELEMENT) { + if (Nodes.isEmptyNode(node)) { + throw new IllegalArgumentException("Direct BlueId input must use { \"$empty\": true } for empty list placeholders. Path: " + path); + } + if (node.getProperties() != null && node.getProperties().containsKey(LIST_CONTROL_EMPTY)) { + Nodes.validateEmptyPlaceholder(node, path); + } + if (node.getPreviousBlueId() != null && listIndex != 0) { + throw new IllegalArgumentException("\"$previous\" must appear only as the first list item. Path: " + path); + } + } else if (node.getPreviousBlueId() != null) { + throw new IllegalArgumentException("\"$previous\" is valid only as the first list item in direct BlueId input. Path: " + path); + } + validatePayloadKind(node, path); + } + + private static void validatePayloadKind(Node node, String path) { + int payloadKinds = 0; + if (node.getValue() != null) payloadKinds++; + if (node.getItems() != null) payloadKinds++; + if (node.getProperties() != null && !node.getProperties().isEmpty()) payloadKinds++; + if (payloadKinds > 1) { + throw new IllegalArgumentException("A Blue node may contain only one payload kind: value, items, or object fields. Path: " + path); + } + if (node.getBlueId() != null && !node.isReferenceOnly()) { + throw new IllegalArgumentException("\"blueId\" nodes must be reference-only and cannot contain sibling fields. Path: " + path); + } + if (node.getPreviousBlueId() != null && (payloadKinds > 0 + || node.getName() != null + || node.getDescription() != null + || node.getType() != null + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null + || node.getSchema() != null + || node.getMergePolicy() != null + || node.getPosition() != null + || node.getContracts() != null + || node.getBlueId() != null)) { + throw new IllegalArgumentException("\"$previous\" list anchors must be single-key list items. Path: " + path); + } + if (node.getPosition() != null && payloadKinds == 0 + && node.getName() == null + && node.getDescription() == null + && node.getType() == null + && node.getItemType() == null + && node.getKeyType() == null + && node.getValueType() == null + && node.getSchema() == null + && node.getMergePolicy() == null + && node.getBlueId() == null) { + throw new IllegalArgumentException("\"$pos\" items must contain an overlay. Path: " + path); + } + } + + private static void validateSchemaNodes(Schema schema, String path) { + if (schema == null) { + return; + } + validateSchemaNode(schema.getRequired(), appendPath(path, "required")); + validateSchemaNode(schema.getMinLength(), appendPath(path, "minLength")); + validateSchemaNode(schema.getMaxLength(), appendPath(path, "maxLength")); + validateSchemaNode(schema.getMinimum(), appendPath(path, "minimum")); + validateSchemaNode(schema.getMaximum(), appendPath(path, "maximum")); + validateSchemaNode(schema.getExclusiveMinimum(), appendPath(path, "exclusiveMinimum")); + validateSchemaNode(schema.getExclusiveMaximum(), appendPath(path, "exclusiveMaximum")); + validateSchemaNode(schema.getMultipleOf(), appendPath(path, "multipleOf")); + validateSchemaNode(schema.getMinItems(), appendPath(path, "minItems")); + validateSchemaNode(schema.getMaxItems(), appendPath(path, "maxItems")); + validateSchemaNode(schema.getUniqueItems(), appendPath(path, "uniqueItems")); + validateSchemaNode(schema.getMinFields(), appendPath(path, "minFields")); + validateSchemaNode(schema.getMaxFields(), appendPath(path, "maxFields")); + if (schema.getEnum() != null) { + for (int i = 0; i < schema.getEnum().size(); i++) { + validateSchemaNode(schema.getEnum().get(i), appendPath(path, "enum", i)); + } + } + } + + private static void validateSchemaNode(Node node, String path) { + if (node != null) { + validateBlueIdInput(node, path, Context.METADATA, -1); + } + } + + private static Object handleValue(Object value, String valueTypeBlueId) { + if (DOUBLE_TYPE_BLUE_ID.equals(valueTypeBlueId)) { + return BlueNumbers.toCanonicalDoubleValue(value); + } + if (value instanceof BigInteger) { + BigInteger bigIntValue = (BigInteger) value; + BigInteger lowerBound = BigInteger.valueOf(-9007199254740991L); + BigInteger upperBound = BigInteger.valueOf(9007199254740991L); + + if (bigIntValue.compareTo(lowerBound) < 0 || bigIntValue.compareTo(upperBound) > 0) { + return bigIntValue.toString(); + } + } + return value; + } + + private static String inferTypeBlueId(Object value) { + if (value instanceof String) { + return TEXT_TYPE_BLUE_ID; + } else if (value instanceof BigInteger) { + return INTEGER_TYPE_BLUE_ID; + } else if (value instanceof BigDecimal) { + return DOUBLE_TYPE_BLUE_ID; + } else if (value instanceof Boolean) { + return BOOLEAN_TYPE_BLUE_ID; + } + return null; + } + + private static String appendPath(String path, String segment) { + String prefix = path == null || path.isEmpty() ? "/" : path; + if ("/".equals(prefix)) { + return "/" + escapePathSegment(segment); + } + return prefix + "/" + escapePathSegment(segment); + } + + private static String appendPath(String path, String segment, int index) { + return appendPath(appendPath(path, segment), String.valueOf(index)); + } + + private static boolean isTypePosition(String path) { + return path != null && (path.endsWith("/" + OBJECT_TYPE) + || path.endsWith("/" + OBJECT_ITEM_TYPE) + || path.endsWith("/" + OBJECT_KEY_TYPE) + || path.endsWith("/" + OBJECT_VALUE_TYPE)); + } + + private static String escapePathSegment(String segment) { + return segment.replace("~", "~0").replace("/", "~1"); + } +} diff --git a/src/main/java/blue/language/utils/NodeToMapListOrValue.java b/src/main/java/blue/language/utils/NodeToMapListOrValue.java index a72aae1..8430892 100644 --- a/src/main/java/blue/language/utils/NodeToMapListOrValue.java +++ b/src/main/java/blue/language/utils/NodeToMapListOrValue.java @@ -1,11 +1,8 @@ package blue.language.utils; import blue.language.model.Node; -import com.fasterxml.jackson.core.type.TypeReference; - import java.math.BigDecimal; import java.math.BigInteger; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -13,7 +10,6 @@ import static blue.language.utils.NodeToMapListOrValue.Strategy.*; import static blue.language.utils.Properties.*; -import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; public class NodeToMapListOrValue { @@ -29,6 +25,12 @@ public static Object get(Node node) { public static Object get(Node node, Strategy strategy) { validatePayloadKind(node); + if (Nodes.isEmptyPlaceholder(node)) { + Map placeholder = new LinkedHashMap<>(); + placeholder.put(LIST_CONTROL_EMPTY, true); + return placeholder; + } + if (node.isReferenceOnly()) { Map reference = new LinkedHashMap<>(); reference.put(OBJECT_BLUE_ID, node.getBlueId()); @@ -66,7 +68,7 @@ public static Object get(Node node, Strategy strategy) { String inferredTypeBlueId = inferTypeBlueId(value); if (inferredTypeBlueId != null) { valueTypeBlueId = inferredTypeBlueId; - Map map = new HashMap<>(); + Map map = new LinkedHashMap<>(); map.put(OBJECT_BLUE_ID, inferredTypeBlueId); result.put(OBJECT_TYPE, map); } @@ -90,9 +92,11 @@ public static Object get(Node node, Strategy strategy) { if (items != null) result.put(OBJECT_ITEMS, items); if (node.getSchema() != null) - result.put(OBJECT_SCHEMA, YAML_MAPPER.convertValue(node.getSchema(), new TypeReference>() {})); + result.put(OBJECT_SCHEMA, SchemaToMapListOrValue.get(node.getSchema(), child -> get(child, strategy))); + if (node.getContracts() != null) + result.put(OBJECT_CONTRACTS, get(node.getContracts(), strategy)); if (node.getBlue() != null) - result.put(OBJECT_BLUE, node.getBlue()); + result.put(OBJECT_BLUE, get(node.getBlue(), strategy)); if (node.getProperties() != null) node.getProperties().forEach((key, propertyValue) -> result.put(key, get(propertyValue, strategy))); return result; @@ -117,6 +121,7 @@ private static void validatePayloadKind(Node node) { || node.getMergePolicy() != null || node.getPosition() != null || node.getBlue() != null + || node.getContracts() != null || node.getBlueId() != null)) { throw new IllegalArgumentException("\"$previous\" list anchors must be single-key list items."); } diff --git a/src/main/java/blue/language/utils/NodeTransformer.java b/src/main/java/blue/language/utils/NodeTransformer.java index 5c9c244..9d7ada6 100644 --- a/src/main/java/blue/language/utils/NodeTransformer.java +++ b/src/main/java/blue/language/utils/NodeTransformer.java @@ -1,8 +1,9 @@ package blue.language.utils; import blue.language.model.Node; +import blue.language.model.Schema; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -15,6 +16,9 @@ public static Node transform(Node node, Function nodeTransformer) { } Node transformedNode = nodeTransformer.apply(node.clone()); + if (Nodes.isEmptyPlaceholder(transformedNode)) { + return transformedNode; + } if (transformedNode.getType() != null) { transformedNode.type(transform(transformedNode.getType(), nodeTransformer)); @@ -32,6 +36,10 @@ public static Node transform(Node node, Function nodeTransformer) { transformedNode.valueType(transform(transformedNode.getValueType(), nodeTransformer)); } + if (transformedNode.getContracts() != null) { + transformedNode.contracts(transform(transformedNode.getContracts(), nodeTransformer)); + } + if (transformedNode.getItems() != null) { List transformedItems = transformedNode.getItems().stream() .map(item -> transform(item, nodeTransformer)) @@ -40,13 +48,39 @@ public static Node transform(Node node, Function nodeTransformer) { } if (transformedNode.getProperties() != null) { - Map transformedProperties = new HashMap<>(); + Map transformedProperties = new LinkedHashMap<>(); for (Map.Entry entry : transformedNode.getProperties().entrySet()) { transformedProperties.put(entry.getKey(), transform(entry.getValue(), nodeTransformer)); } transformedNode.properties(transformedProperties); } + transformSchema(transformedNode.getSchema(), nodeTransformer); + return transformedNode; } -} \ No newline at end of file + + private static void transformSchema(Schema schema, Function nodeTransformer) { + if (schema == null) { + return; + } + schema.required(transform(schema.getRequired(), nodeTransformer)); + schema.minLength(transform(schema.getMinLength(), nodeTransformer)); + schema.maxLength(transform(schema.getMaxLength(), nodeTransformer)); + schema.minimum(transform(schema.getMinimum(), nodeTransformer)); + schema.maximum(transform(schema.getMaximum(), nodeTransformer)); + schema.exclusiveMinimum(transform(schema.getExclusiveMinimum(), nodeTransformer)); + schema.exclusiveMaximum(transform(schema.getExclusiveMaximum(), nodeTransformer)); + schema.multipleOf(transform(schema.getMultipleOf(), nodeTransformer)); + schema.minItems(transform(schema.getMinItems(), nodeTransformer)); + schema.maxItems(transform(schema.getMaxItems(), nodeTransformer)); + schema.uniqueItems(transform(schema.getUniqueItems(), nodeTransformer)); + schema.minFields(transform(schema.getMinFields(), nodeTransformer)); + schema.maxFields(transform(schema.getMaxFields(), nodeTransformer)); + if (schema.getEnum() != null) { + schema.enumValues(schema.getEnum().stream() + .map(value -> transform(value, nodeTransformer)) + .collect(Collectors.toList())); + } + } +} diff --git a/src/main/java/blue/language/utils/NodeTypeMatcher.java b/src/main/java/blue/language/utils/NodeTypeMatcher.java index 4880fcf..9d1240a 100644 --- a/src/main/java/blue/language/utils/NodeTypeMatcher.java +++ b/src/main/java/blue/language/utils/NodeTypeMatcher.java @@ -272,16 +272,15 @@ private Integer integerSegment(String segment) { private boolean schemaNeedsItems(Schema schema) { return schema != null - && (schema.getAllowMultipleValue() != null - || schema.getMinItemsValue() != null - || schema.getMaxItemsValue() != null + && (schema.getMinItemsExact() != null + || schema.getMaxItemsExact() != null || schema.getUniqueItemsValue() != null); } private boolean schemaNeedsFields(Schema schema) { return schema != null - && (schema.getMinFieldsValue() != null - || schema.getMaxFieldsValue() != null); + && (schema.getMinFieldsExact() != null + || schema.getMaxFieldsExact() != null); } private boolean shouldAttemptBundleReconstruction(List candidateItems, List targetItems) { diff --git a/src/main/java/blue/language/utils/Nodes.java b/src/main/java/blue/language/utils/Nodes.java index 7b15ef2..d4f52c9 100644 --- a/src/main/java/blue/language/utils/Nodes.java +++ b/src/main/java/blue/language/utils/Nodes.java @@ -21,6 +21,7 @@ public enum NodeField { ITEM_TYPE, VALUE, PROPERTIES, + CONTRACTS, BLUE, ITEMS, SCHEMA, @@ -33,6 +34,56 @@ public static boolean isEmptyNode(Node node) { return hasFieldsAndMayHaveFields(node, EnumSet.noneOf(NodeField.class), EnumSet.noneOf(NodeField.class)); } + public static Node emptyPlaceholder() { + return new Node().properties(LIST_CONTROL_EMPTY, new Node().value(true).inlineValue(true)); + } + + public static boolean isEmptyPlaceholder(Node node) { + if (node == null || node.getProperties() == null || node.getProperties().size() != 1) { + return false; + } + Node marker = node.getProperties().get(LIST_CONTROL_EMPTY); + return marker != null + && Boolean.TRUE.equals(marker.getValue()) + && marker.getName() == null + && marker.getDescription() == null + && marker.getType() == null + && marker.getItemType() == null + && marker.getKeyType() == null + && marker.getValueType() == null + && marker.getItems() == null + && marker.getProperties() == null + && marker.getContracts() == null + && marker.getBlueId() == null + && marker.getSchema() == null + && marker.getMergePolicy() == null + && marker.getPreviousBlueId() == null + && marker.getPosition() == null + && marker.getBlue() == null + && node.getName() == null + && node.getDescription() == null + && node.getType() == null + && node.getItemType() == null + && node.getKeyType() == null + && node.getValueType() == null + && node.getValue() == null + && node.getItems() == null + && node.getContracts() == null + && node.getBlueId() == null + && node.getSchema() == null + && node.getMergePolicy() == null + && node.getPreviousBlueId() == null + && node.getPosition() == null + && node.getBlue() == null; + } + + public static void validateEmptyPlaceholder(Node node, String path) { + if (isEmptyPlaceholder(node)) { + return; + } + throw new IllegalArgumentException("\"$empty\" list placeholder must have exact shape { \"$empty\": true }. Path: " + path); + } + public static boolean hasBlueIdOnly(Node node) { return hasFieldsAndMayHaveFields(node, EnumSet.of(NodeField.BLUE_ID), EnumSet.noneOf(NodeField.class)); } @@ -79,6 +130,7 @@ private static Object getFieldValue(Node node, NodeField field) { case VALUE: return node.getValue(); case DESCRIPTION: return node.getDescription(); case PROPERTIES: return node.getProperties(); + case CONTRACTS: return node.getContracts(); case BLUE: return node.getBlue(); case ITEMS: return node.getItems(); case SCHEMA: return node.getSchema(); diff --git a/src/main/java/blue/language/utils/Properties.java b/src/main/java/blue/language/utils/Properties.java index 5b5c53c..356e2ea 100644 --- a/src/main/java/blue/language/utils/Properties.java +++ b/src/main/java/blue/language/utils/Properties.java @@ -15,6 +15,7 @@ public class Properties { public static final String OBJECT_KEY_TYPE = "keyType"; public static final String OBJECT_VALUE_TYPE = "valueType"; public static final String OBJECT_SCHEMA = "schema"; + public static final String OBJECT_CONTRACTS = "contracts"; public static final String OBJECT_MERGE_POLICY = "mergePolicy"; public static final String OBJECT_VALUE = "value"; public static final String OBJECT_ITEMS = "items"; @@ -24,6 +25,7 @@ public class Properties { public static final String LIST_MERGE_POLICY_APPEND_ONLY = "append-only"; public static final String LIST_CONTROL_PREVIOUS = "$previous"; public static final String LIST_CONTROL_POS = "$pos"; + public static final String LIST_CONTROL_REPLACE = "$replace"; public static final String LIST_CONTROL_EMPTY = "$empty"; public static final String TEXT_TYPE = "Text"; diff --git a/src/main/java/blue/language/utils/SchemaToMapListOrValue.java b/src/main/java/blue/language/utils/SchemaToMapListOrValue.java new file mode 100644 index 0000000..cde0824 --- /dev/null +++ b/src/main/java/blue/language/utils/SchemaToMapListOrValue.java @@ -0,0 +1,82 @@ +package blue.language.utils; + +import blue.language.model.Node; +import blue.language.model.Schema; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public final class SchemaToMapListOrValue { + + private SchemaToMapListOrValue() { + } + + public static Map get(Schema schema, Function nodeConverter) { + Map result = new LinkedHashMap<>(); + put(result, "required", schema.getRequired() == null ? null : schema.getRequiredValue()); + put(result, "minLength", countValue(schema.getMinLength())); + put(result, "maxLength", countValue(schema.getMaxLength())); + put(result, "minimum", numericValue(schema.getMinimum(), nodeConverter)); + put(result, "maximum", numericValue(schema.getMaximum(), nodeConverter)); + put(result, "exclusiveMinimum", numericValue(schema.getExclusiveMinimum(), nodeConverter)); + put(result, "exclusiveMaximum", numericValue(schema.getExclusiveMaximum(), nodeConverter)); + put(result, "multipleOf", numericValue(schema.getMultipleOf(), nodeConverter)); + put(result, "minItems", countValue(schema.getMinItems())); + put(result, "maxItems", countValue(schema.getMaxItems())); + put(result, "uniqueItems", schema.getUniqueItems() == null ? null : schema.getUniqueItemsValue()); + put(result, "minFields", countValue(schema.getMinFields())); + put(result, "maxFields", countValue(schema.getMaxFields())); + if (schema.getEnum() != null) { + List values = new ArrayList<>(schema.getEnum().size()); + for (Node value : schema.getEnum()) { + values.add(scalarOrExplicitNode(value, nodeConverter)); + } + result.put("enum", values); + } + return result; + } + + private static Object countValue(Node node) { + return node == null ? null : node.getValue(); + } + + private static Object numericValue(Node node, Function nodeConverter) { + if (node == null) { + return null; + } + return isPlainScalar(node) ? node.getValue() : nodeConverter.apply(node); + } + + private static Object scalarOrExplicitNode(Node node, Function nodeConverter) { + return isPlainScalar(node) ? node.getValue() : nodeConverter.apply(node); + } + + private static boolean isPlainScalar(Node node) { + return node != null + && node.getValue() != null + && node.getName() == null + && node.getDescription() == null + && node.getType() == 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; + } + + private static void put(Map result, String key, Object value) { + if (value != null) { + result.put(key, value); + } + } +} diff --git a/src/main/java/blue/language/utils/TypeUtils.java b/src/main/java/blue/language/utils/TypeUtils.java index 87316ae..0f1b3fa 100644 --- a/src/main/java/blue/language/utils/TypeUtils.java +++ b/src/main/java/blue/language/utils/TypeUtils.java @@ -27,6 +27,16 @@ public static Integer getIntegerFromObject(Object obj) { } } + public static BigInteger getBigIntegerFromObject(Object obj) { + if (obj instanceof BigInteger) { + return (BigInteger) obj; + } else if (obj instanceof BigDecimal) { + return ((BigDecimal) obj).toBigIntegerExact(); + } else { + throw new IllegalArgumentException("Object is not a BigInteger or BigDecimal"); + } + } + public static BigDecimal getBigDecimalFromObject(Object obj) { if (obj instanceof BigInteger) { return new BigDecimal((BigInteger) obj); diff --git a/src/main/java/blue/language/utils/Types.java b/src/main/java/blue/language/utils/Types.java index bbaa0e7..d58cba6 100644 --- a/src/main/java/blue/language/utils/Types.java +++ b/src/main/java/blue/language/utils/Types.java @@ -7,7 +7,7 @@ import java.util.Map; import java.util.stream.Collectors; -import static blue.language.utils.BlueIdCalculator.calculateBlueId; +import static blue.language.utils.BlueIdCalculator.calculateUncheckedBlueId; import static blue.language.utils.Properties.*; public class Types { @@ -23,15 +23,15 @@ public static boolean isSubtype(Node subtype, Node supertype, NodeProvider nodeP if (subtype == null || supertype == null) { return false; } - String subtypeBlueId = calculateBlueId(subtype); - String supertypeBlueId = calculateBlueId(supertype); + String subtypeBlueId = typeBlueId(subtype); + String supertypeBlueId = typeBlueId(supertype); if (sameType(subtype, supertype, subtypeBlueId, supertypeBlueId)) return true; if (CORE_TYPE_BLUE_IDS.contains(subtypeBlueId)) { Node current = supertype; while (current != null) { - String currentBlueId = calculateBlueId(current); + String currentBlueId = typeBlueId(current); if (sameType(current, subtype, currentBlueId, subtypeBlueId)) return true; current = getType(current, nodeProvider); @@ -41,7 +41,7 @@ public static boolean isSubtype(Node subtype, Node supertype, NodeProvider nodeP Node current = firstSubtypeTraversalNode(subtype, nodeProvider); while (current != null) { - String blueId = calculateBlueId(current); + String blueId = typeBlueId(current); if (sameType(current, supertype, blueId, supertypeBlueId)) return true; current = getType(current, nodeProvider); @@ -82,6 +82,10 @@ private static boolean sameType(Node left, Node right, String leftBlueId, String return compatibilityBlueId(left).equals(compatibilityBlueId(right)); } + private static String typeBlueId(Node node) { + return node.getBlueId() != null ? node.getBlueId() : calculateUncheckedBlueId(node); + } + private static String compatibilityBlueId(Node node) { if (node.getBlueId() != null && node.isReferenceOnly()) { return node.getBlueId(); @@ -94,7 +98,7 @@ private static String compatibilityBlueId(Node node) { } Node stripped = node.clone(); stripLabels(stripped); - return calculateBlueId(stripped); + return calculateUncheckedBlueId(stripped); } private static boolean isBareCoreTypeName(Node node) { @@ -108,6 +112,7 @@ private static boolean isBareCoreTypeName(Node node) { && node.getValue() == null && node.getItems() == null && node.getProperties() == null + && node.getContracts() == null && node.getBlueId() == null && node.getSchema() == null && node.getMergePolicy() == null @@ -130,8 +135,15 @@ private static void stripLabels(Node node) { stripLabels(node.getKeyType()); stripLabels(node.getValueType()); stripLabels(node.getBlue()); + stripLabels(node.getContracts()); if (node.getItems() != null) { - node.getItems().forEach(Types::stripLabels); + for (int i = 0; i < node.getItems().size(); i++) { + Node item = node.getItems().get(i); + stripLabels(item); + if (Nodes.isEmptyNode(item)) { + node.getItems().set(i, Nodes.emptyPlaceholder()); + } + } } if (node.getProperties() != null) { node.getProperties().values().forEach(Types::stripLabels); @@ -144,7 +156,6 @@ private static void stripSchemaLabels(blue.language.model.Schema schema) { return; } stripLabels(schema.getRequired()); - stripLabels(schema.getAllowMultiple()); stripLabels(schema.getMinLength()); stripLabels(schema.getMaxLength()); stripLabels(schema.getMinimum()); diff --git a/src/main/java/blue/language/utils/UncheckedObjectMapper.java b/src/main/java/blue/language/utils/UncheckedObjectMapper.java index 6a0ddc2..566cec8 100644 --- a/src/main/java/blue/language/utils/UncheckedObjectMapper.java +++ b/src/main/java/blue/language/utils/UncheckedObjectMapper.java @@ -3,43 +3,47 @@ import blue.language.model.*; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.annotation.JsonSetter; -import com.fasterxml.jackson.annotation.Nulls; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadFeature; import com.fasterxml.jackson.core.TreeNode; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; -import com.fasterxml.jackson.dataformat.yaml.YAMLParser; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; import static com.fasterxml.jackson.databind.DeserializationFeature.*; import static com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT; import static com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature.MINIMIZE_QUOTES; -import static com.fasterxml.jackson.dataformat.yaml.YAMLParser.Feature.EMPTY_STRING_AS_NULL; public class UncheckedObjectMapper extends ObjectMapper { + private static final Pattern YAML_TAG_PATTERN = Pattern.compile("(^|[\\s\\[{,])![^\\s]+"); + private static final Pattern YAML_ANCHOR_OR_ALIAS_PATTERN = Pattern.compile("(^|\\s)[&*][A-Za-z0-9_-]+"); + public static final UncheckedObjectMapper YAML_MAPPER = new UncheckedObjectMapper( - new YAMLFactory() + YAMLFactory.builder() .enable(MINIMIZE_QUOTES) - .enable(EMPTY_STRING_AS_NULL)); + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .build()); public static final UncheckedObjectMapper JSON_MAPPER = new UncheckedObjectMapper( - new JsonFactory()); + JsonFactory.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .build()); private UncheckedObjectMapper(JsonFactory jsonFactory) { super(jsonFactory); - setDefaultSetterInfo(JsonSetter.Value.forValueNulls(Nulls.AS_EMPTY)); - setVisibility(getSerializationConfig().getDefaultVisibilityChecker() .withFieldVisibility(JsonAutoDetect.Visibility.ANY) .withGetterVisibility(JsonAutoDetect.Visibility.NONE) @@ -51,7 +55,6 @@ private UncheckedObjectMapper(JsonFactory jsonFactory) { setSerializationInclusion(Include.NON_NULL); enable(USE_BIG_DECIMAL_FOR_FLOATS); enable(USE_BIG_INTEGER_FOR_INTS); - enable(ACCEPT_EMPTY_STRING_AS_NULL_OBJECT); SimpleModule module = new SimpleModule(); module.setSerializerModifier(new BlueAnnotationsBeanSerializerModifier()); @@ -82,7 +85,8 @@ public String writeValueAsString(Object value) { @Override public T readValue(String content, Class valueType) { try { - return super.readValue(content, valueType); + rejectYamlOnlySyntax(content); + return rejectRootNull(super.readValue(content, valueType), valueType); } catch (IOException e) { throw new JsonException(e); } @@ -91,7 +95,10 @@ public T readValue(String content, Class valueType) { @Override public T readValue(InputStream src, Class valueType) { try { - return super.readValue(src, valueType); + if (getFactory() instanceof YAMLFactory) { + return readValue(readUtf8(src), valueType); + } + return rejectRootNull(super.readValue(src, valueType), valueType); } catch (IOException e) { throw new JsonException(e); } @@ -100,6 +107,9 @@ public T readValue(InputStream src, Class valueType) { @Override public T readValue(InputStream src, TypeReference valueTypeRef) { try { + if (getFactory() instanceof YAMLFactory) { + return readValue(readUtf8(src), valueTypeRef); + } return super.readValue(src, valueTypeRef); } catch (IOException e) { throw new JsonException(e); @@ -109,6 +119,7 @@ public T readValue(InputStream src, TypeReference valueTypeRef) { @Override public JsonNode readTree(String content) { try { + rejectYamlOnlySyntax(content); return super.readTree(content); } catch (IOException e) { throw new JsonException(e); @@ -118,6 +129,7 @@ public JsonNode readTree(String content) { @Override public T readValue(String content, TypeReference valueTypeRef) { try { + rejectYamlOnlySyntax(content); return super.readValue(content, valueTypeRef); } catch (IOException e) { throw new JsonException(e); @@ -127,6 +139,7 @@ public T readValue(String content, TypeReference valueTypeRef) { @Override public T readValue(String content, JavaType valueType) { try { + rejectYamlOnlySyntax(content); return super.readValue(content, valueType); } catch (IOException e) { throw new JsonException(e); @@ -162,12 +175,41 @@ public T convertValue(Object fromValue, TypeReference toValueTypeRef) { @Override public T treeToValue(TreeNode n, Class valueType) { try { - return super.treeToValue(n, valueType); + return rejectRootNull(super.treeToValue(n, valueType), valueType); } catch (IllegalArgumentException | JsonProcessingException e) { throw new JsonException(e); } } + private T rejectRootNull(T result, Class valueType) { + if (result == null && Node.class.equals(valueType)) { + throw new JsonException(new IllegalArgumentException("Root null is not a valid Blue document.")); + } + return result; + } + + private void rejectYamlOnlySyntax(String content) { + if (!(getFactory() instanceof YAMLFactory) || content == null) { + return; + } + if (YAML_TAG_PATTERN.matcher(content).find()) { + throw new JsonException(new IllegalArgumentException("YAML tags are not part of the Blue JSON data model.")); + } + if (YAML_ANCHOR_OR_ALIAS_PATTERN.matcher(content).find()) { + throw new JsonException(new IllegalArgumentException("YAML anchors and aliases are not part of the Blue JSON data model.")); + } + } + + private String readUtf8(InputStream src) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = src.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + public T nestedConvertValue(Object fromValue, Class toValueType) { try { return super.convertValue(fromValue, toValueType); diff --git a/src/main/java/blue/language/utils/limits/NodeToPathLimitsConverter.java b/src/main/java/blue/language/utils/limits/NodeToPathLimitsConverter.java index 77a95e8..8a1565e 100644 --- a/src/main/java/blue/language/utils/limits/NodeToPathLimitsConverter.java +++ b/src/main/java/blue/language/utils/limits/NodeToPathLimitsConverter.java @@ -18,11 +18,17 @@ private static void traverseNode(Node node, String currentPath, PathLimits.Build return; } - if ((node.getProperties() == null || node.getProperties().isEmpty()) && node.getItems() == null) { + if ((node.getProperties() == null || node.getProperties().isEmpty()) + && node.getItems() == null + && node.getContracts() == null) { builder.addPath(currentPath); return; } + if (node.getContracts() != null) { + traverseNode(node.getContracts(), JsonPointer.append(currentPath, "contracts"), builder); + } + if (node.getProperties() != null) { for (Map.Entry entry : node.getProperties().entrySet()) { String newPath = JsonPointer.append(currentPath, entry.getKey()); diff --git a/src/main/resources/transformation/DefaultBlue.blue b/src/main/resources/transformation/DefaultBlue.blue index 61a62b7..0bf8a35 100644 --- a/src/main/resources/transformation/DefaultBlue.blue +++ b/src/main/resources/transformation/DefaultBlue.blue @@ -1,5 +1,5 @@ - type: - blueId: 27B7fuxQCS1VAptiCPc2RMkKoutP5qxkh3uDxZ7dr6Eo + blueId: 53yFLQ3dpuGwa2svHubDyzyhYz9RQNmctiJRdi3gRYr7 mappings: Text: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K Double: 7pwXmXYCJtWnd348c2JQGBkm9C4renmZRwxbfaypsx5y @@ -8,4 +8,4 @@ List: 6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY Dictionary: G7fBT9PSod1RfHLHkpafAGBDVAJMrMhAMY51ERcyXNrj - type: - blueId: FGYuTXwaoSKfZmpTysLTLsb8WzSqf43384rKZDkXhxD4 + blueId: 49hrWpkoXavNmK8PpZag11zB2vYwzhQZahwioz6vDk2i diff --git a/src/main/resources/transformation/InferBasicTypesForUntypedValues.blue b/src/main/resources/transformation/InferBasicTypesForUntypedValues.blue index fe57b40..39198b1 100644 --- a/src/main/resources/transformation/InferBasicTypesForUntypedValues.blue +++ b/src/main/resources/transformation/InferBasicTypesForUntypedValues.blue @@ -1,4 +1,4 @@ name: Infer Basic Types For Untyped Values type: - blueId: Ct1SGRGw1i47qjzm1ruiUdSZofeV6WevPTGuieVvbRS4 -description: This transformation infers type details for Text, Integer, Number and Boolean. \ No newline at end of file + blueId: AFidhDk8aBDwV7J5X2GjujPfVXBuHvHLcrXMBxxoCSf9 +description: This transformation infers type details for Text, Integer, Number and Boolean. diff --git a/src/main/resources/transformation/ReplaceInlineTypesWithBlueIds.blue b/src/main/resources/transformation/ReplaceInlineTypesWithBlueIds.blue index e14a640..3bfe86a 100644 --- a/src/main/resources/transformation/ReplaceInlineTypesWithBlueIds.blue +++ b/src/main/resources/transformation/ReplaceInlineTypesWithBlueIds.blue @@ -1,4 +1,4 @@ name: Replace Inline Types with BlueIds type: - blueId: Ct1SGRGw1i47qjzm1ruiUdSZofeV6WevPTGuieVvbRS4 -description: This transformation replaces \ No newline at end of file + blueId: AFidhDk8aBDwV7J5X2GjujPfVXBuHvHLcrXMBxxoCSf9 +description: Replaces inline type, itemType, keyType, and valueType aliases with canonical BlueId references. diff --git a/src/main/resources/transformation/Transformation.blue b/src/main/resources/transformation/Transformation.blue index 89387cb..fdc0176 100644 --- a/src/main/resources/transformation/Transformation.blue +++ b/src/main/resources/transformation/Transformation.blue @@ -1,2 +1,2 @@ name: Transformation -description: TODO \ No newline at end of file +description: Defines a Blue preprocessing transformation document. diff --git a/src/test/java/blue/language/BlueConformanceReportTest.java b/src/test/java/blue/language/BlueConformanceReportTest.java new file mode 100644 index 0000000..7dec36d --- /dev/null +++ b/src/test/java/blue/language/BlueConformanceReportTest.java @@ -0,0 +1,308 @@ +package blue.language; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BlueConformanceReportTest { + private static final Set KNOWN_FIXTURE_OPERATIONS = new HashSet<>(Arrays.asList( + "parseSource", + "parseBlueIdInput", + "calculateBlueId", + "calculateCircularSetBlueIds", + "preprocess", + "resolve", + "canonicalize", + "calculateContentBlueId", + "calculateSemanticBlueId", + "expand", + "collapse", + "assertSameNodeBlueId" + )); + + @Test + void languageVersionIsBlueLanguage10() { + assertEquals("1.0", new Blue().languageVersion()); + } + + @Test + void conformanceReportHasNoProfiles() { + for (Method method : Blue.class.getMethods()) { + assertFalse(method.getName().toLowerCase().contains("profile")); + } + for (Method method : BlueConformanceReport.class.getMethods()) { + assertFalse(method.getName().toLowerCase().contains("profile")); + } + } + + @Test + void conformanceReportLoadsFixtureIdentity() { + Blue blue = new Blue(); + BlueConformanceReport report = blue.conformanceReport(); + + assertEquals(BlueConformanceReport.computeFixturePackageIdentity(), report.getFixturePackageIdentity()); + assertTrue(report.isReleaseGradeFixtureIdentity()); + assertTrue(BlueConformanceReport.fixturePackageIdentityMatchesFixtureFiles()); + } + + @Test + void conformanceReportListsPassedAndFailedFixtureIds() { + BlueConformanceReport report = new BlueConformanceReport( + "1.0", + Collections.emptyMap(), + "blue-language-1.0-fixtures:test", + Arrays.asList("B_root_scalar", "B_root_list"), + Collections.singletonList("B_root_scalar"), + Collections.singletonList("B_root_list"), + Collections.singletonMap("B_root_scalar", BlueFixtureCategory.BLUE_ID)); + + assertEquals(Collections.singletonList("B_root_scalar"), report.getPassedFixtureIds()); + assertEquals(Collections.singletonList("B_root_list"), report.getFailedFixtureIds()); + assertTrue(report.getFailures().isEmpty()); + } + + @Test + void conformanceReportExposesDetailedFailureMetadata() { + BlueConformanceFailure failure = new BlueConformanceFailure( + "B_bad", + BlueFixtureCategory.BLUE_ID, + "calculateBlueId", + IllegalArgumentException.class.getName(), + "bad fixture"); + BlueConformanceReport report = new BlueConformanceReport( + "1.0", + Collections.emptyMap(), + "sha256:e579c14256b470ef5c987c282c760dff8865d68ecd55bce0dc1bbdb5cdb19a50", + Collections.singletonList("B_bad"), + Collections.emptyList(), + Collections.emptyList(), + Collections.singletonMap("B_bad", BlueFixtureCategory.BLUE_ID), + Collections.singletonList(failure)); + + assertEquals(Collections.singletonList("B_bad"), report.getFailedFixtureIds()); + assertEquals("B_bad", report.getFailures().get(0).getFixtureId()); + assertEquals("calculateBlueId", report.getFailures().get(0).getOperation()); + assertEquals(IllegalArgumentException.class.getName(), report.getFailures().get(0).getExceptionClass()); + } + + @Test + void conformanceReportLoadsFixtureIdsAndCategories() { + BlueConformanceReport report = new Blue().conformanceReport(); + + assertTrue(report.getFixtureIds().contains("B_root_scalar")); + assertTrue(report.getFixtureIds().contains("F_provider_wrong_blueid_rejected")); + assertEquals(BlueFixtureCategory.BLUE_ID, report.getFixtureCategories().get("B_root_scalar")); + assertEquals(BlueFixtureCategory.PROVIDER, report.getFixtureCategories().get("F_provider_wrong_blueid_rejected")); + } + + @Test + void runConformanceSuitePopulatesPassedAndFailedFixtureIds() { + BlueConformanceReport report = new Blue().runConformanceSuite(); + + assertEquals(report.getFixtureIds(), report.getPassedFixtureIds()); + assertTrue(report.getFailedFixtureIds().isEmpty()); + assertTrue(report.getFailures().isEmpty()); + assertTrue(report.hasRequiredFixtureCoverage()); + } + + @Test + void staticConformanceReportDoesNotPretendFixturesPassed() { + BlueConformanceReport report = new Blue().conformanceReport(); + + assertTrue(report.getPassedFixtureIds().isEmpty()); + assertTrue(report.getFailedFixtureIds().isEmpty()); + assertTrue(report.getFailures().isEmpty()); + assertTrue(report.hasRequiredFixtureCoverage()); + } + + @Test + void fixtureCategoriesAreNotConformanceProfiles() { + assertEquals(BlueFixtureCategory.BLUE_ID, BlueFixtureCategory.fromLabel("BlueId")); + assertEquals(BlueFixtureCategory.RESOLUTION, BlueFixtureCategory.fromLabel("Resolution")); + assertEquals("BlueId", BlueFixtureCategory.BLUE_ID.getLabel()); + } + + @Test + void releaseGradeFixtureIdentityRejectsLocalDevPendingUnavailableAndBlank() { + assertFalse(BlueConformanceReport.isReleaseGradeFixtureIdentity("blue-language-1.0-fixtures:local-dev")); + assertFalse(BlueConformanceReport.isReleaseGradeFixtureIdentity("blue-language-1.0-fixtures:pending")); + assertFalse(BlueConformanceReport.isReleaseGradeFixtureIdentity("blue-language-1.0-fixtures:unavailable")); + assertFalse(BlueConformanceReport.isReleaseGradeFixtureIdentity("")); + assertFalse(BlueConformanceReport.isReleaseGradeFixtureIdentity(null)); + assertFalse(BlueConformanceReport.isReleaseGradeFixtureIdentity("sha256:bad")); + assertTrue(BlueConformanceReport.isReleaseGradeFixtureIdentity( + "sha256:e579c14256b470ef5c987c282c760dff8865d68ecd55bce0dc1bbdb5cdb19a50")); + assertTrue(BlueConformanceReport.isReleaseGradeFixtureIdentity("blueId:B123")); + } + + @Test + void requiredFixtureCoverageChecksAllLanguageFixtures() { + BlueConformanceReport report = new Blue().conformanceReport(); + + assertTrue(report.hasRequiredFixtureCoverage()); + } + + @Test + void requiredFixtureCoveragePassesOnlyWhenAllLanguageFixturesArePresent() { + Map categories = new LinkedHashMap<>(); + for (String id : BlueConformanceReport.requiredFixtureIdsForBlueLanguage10()) { + categories.put(id, BlueFixtureCategory.BLUE_ID); + } + BlueConformanceReport complete = new BlueConformanceReport( + "1.0", + Collections.emptyMap(), + "blue-language-1.0-fixtures:B123", + Arrays.asList(categories.keySet().toArray(new String[0])), + Collections.emptyList(), + Collections.emptyList(), + categories); + + assertTrue(complete.hasRequiredFixtureCoverage()); + } + + @Test + void exactRequiredFixtureSetRejectsExtraOrMissing() { + Map categories = new LinkedHashMap<>(); + for (String id : BlueConformanceReport.requiredFixtureIdsForBlueLanguage10()) { + categories.put(id, BlueFixtureCategory.BLUE_ID); + } + BlueConformanceReport exact = new BlueConformanceReport( + "1.0", + Collections.emptyMap(), + "sha256:e579c14256b470ef5c987c282c760dff8865d68ecd55bce0dc1bbdb5cdb19a50", + Arrays.asList(categories.keySet().toArray(new String[0])), + Collections.emptyList(), + Collections.emptyList(), + categories); + + assertTrue(exact.hasRequiredFixtureCoverage()); + assertTrue(exact.hasExactRequiredFixtureSet()); + + List withExtra = new java.util.ArrayList<>(exact.getFixtureIds()); + withExtra.add("EXTRA_fixture"); + BlueConformanceReport extra = new BlueConformanceReport( + "1.0", + Collections.emptyMap(), + "sha256:e579c14256b470ef5c987c282c760dff8865d68ecd55bce0dc1bbdb5cdb19a50", + withExtra, + Collections.emptyList(), + Collections.emptyList(), + categories); + assertTrue(extra.hasRequiredFixtureCoverage()); + assertFalse(extra.hasExactRequiredFixtureSet()); + + BlueConformanceReport missing = new BlueConformanceReport( + "1.0", + Collections.emptyMap(), + "sha256:e579c14256b470ef5c987c282c760dff8865d68ecd55bce0dc1bbdb5cdb19a50", + Collections.singletonList(exact.getFixtureIds().get(0)), + Collections.emptyList(), + Collections.emptyList(), + categories); + assertFalse(missing.hasRequiredFixtureCoverage()); + assertFalse(missing.hasExactRequiredFixtureSet()); + } + + @Test + void conformanceManifestAndRequiredFixtureSetAreAligned() throws Exception { + URL resource = getClass().getClassLoader().getResource("blue-language-1.0/fixtures"); + assertTrue(resource != null); + Path fixtureRoot = Paths.get(resource.toURI()); + com.fasterxml.jackson.databind.JsonNode manifest = YAML_MAPPER.readTree( + new String(Files.readAllBytes(fixtureRoot.resolve("manifest.yaml")))); + Set manifestIds = new LinkedHashSet<>(); + Set manifestPaths = new LinkedHashSet<>(); + for (com.fasterxml.jackson.databind.JsonNode fixture : manifest.get("fixtures")) { + assertFalse(fixture.has("profile")); + assertTrue(fixture.hasNonNull("id")); + assertTrue(fixture.hasNonNull("category")); + assertTrue(fixture.hasNonNull("path")); + BlueFixtureCategory.fromLabel(fixture.get("category").asText()); + assertTrue(manifestIds.add(fixture.get("id").asText()), "Duplicate fixture id: " + fixture.get("id").asText()); + Path fixturePath = fixtureRoot.resolve(fixture.get("path").asText()).normalize(); + assertTrue(Files.isRegularFile(fixturePath), "Missing fixture file: " + fixturePath); + manifestPaths.add(fixturePath.toAbsolutePath().normalize()); + + com.fasterxml.jackson.databind.JsonNode fixtureContent = YAML_MAPPER.readTree( + new String(Files.readAllBytes(fixturePath))); + assertFalse(fixtureContent.has("profile"), "Fixture metadata must use category, not profile: " + fixturePath); + assertTrue(fixtureContent.hasNonNull("id"), "Fixture missing id: " + fixturePath); + assertTrue(fixtureContent.hasNonNull("category"), "Fixture missing category: " + fixturePath); + assertEquals(fixture.get("id").asText(), fixtureContent.get("id").asText(), "Fixture id mismatch: " + fixturePath); + assertEquals( + BlueFixtureCategory.fromLabel(fixture.get("category").asText()), + BlueFixtureCategory.fromLabel(fixtureContent.get("category").asText()), + "Fixture category mismatch: " + fixturePath); + assertTrue(fixtureContent.hasNonNull("operation"), "Fixture missing operation: " + fixturePath); + assertTrue(KNOWN_FIXTURE_OPERATIONS.contains(fixtureContent.get("operation").asText()), + "Unknown fixture operation in " + fixturePath + ": " + fixtureContent.get("operation").asText()); + } + + assertEquals(BlueConformanceReport.requiredFixtureIdsForBlueLanguage10(), manifestIds); + + List fixtureFiles; + try (Stream paths = Files.walk(fixtureRoot)) { + fixtureFiles = paths + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".yaml")) + .filter(path -> !"manifest.yaml".equals(path.getFileName().toString())) + .map(path -> path.toAbsolutePath().normalize()) + .collect(Collectors.toList()); + } + assertEquals(new HashSet<>(manifestPaths), new HashSet<>(fixtureFiles)); + } + + @Test + void mainResourcesDoNotContainTodoDescriptions() throws Exception { + Path resourceRoot = Paths.get("src/main/resources"); + try (Stream paths = Files.walk(resourceRoot)) { + List incomplete = paths + .filter(Files::isRegularFile) + .filter(path -> { + try { + String content = new String(Files.readAllBytes(path)); + return content.contains("TODO") + || content.contains("description: This transformation replaces"); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toList()); + assertEquals(Collections.emptyList(), incomplete); + } + } + + @Test + void readmeLinksPointToExistingFiles() throws Exception { + Path readme = Paths.get("README.md"); + String content = new String(Files.readAllBytes(readme)); + Matcher matcher = Pattern.compile("\\[[^\\]]+]\\((docs/[^)]+\\.md)\\)").matcher(content); + while (matcher.find()) { + Path target = readme.getParent() == null + ? Paths.get(matcher.group(1)) + : readme.getParent().resolve(matcher.group(1)); + assertTrue(Files.isRegularFile(target), "README link target is missing: " + matcher.group(1)); + } + } +} diff --git a/src/test/java/blue/language/DictionaryProcessorTest.java b/src/test/java/blue/language/DictionaryProcessorTest.java index 350120d..0a96d4a 100644 --- a/src/test/java/blue/language/DictionaryProcessorTest.java +++ b/src/test/java/blue/language/DictionaryProcessorTest.java @@ -26,7 +26,7 @@ public void testKeyTypeAndValueTypeAssignment() { .keyType("Text") .valueType("Integer"); Node dictB = new Node().name("DictB") - .type(new Node().blueId(calculateBlueId(dictA))); + .type(new Node().blueId(new Blue().calculateSemanticBlueId(dictA))); BasicNodeProvider nodeProvider = new BasicNodeProvider(Arrays.asList(dictA, dictB)); MergingProcessor mergingProcessor = new SequentialMergingProcessor( @@ -164,4 +164,4 @@ public void testNonDictionaryTypeWithKeyTypeOrValueType() throws Exception { assertThrows(IllegalArgumentException.class, () -> merger.resolve(nonDictNode)); } -} \ No newline at end of file +} diff --git a/src/test/java/blue/language/ListControlFormsTest.java b/src/test/java/blue/language/ListControlFormsTest.java index de19b40..ee14018 100644 --- a/src/test/java/blue/language/ListControlFormsTest.java +++ b/src/test/java/blue/language/ListControlFormsTest.java @@ -78,6 +78,7 @@ void standaloneListCanUsePreviousAnchorAsItsBase() { @Test void previousAnchorMustMatchInheritedList() { BasicNodeProvider nodeProvider = new BasicNodeProvider(); + String wrongButValidBlueId = BlueIdCalculator.calculateBlueId(new Node().value("stale")); nodeProvider.addSingleDocs( "name: Base\n" + "type:\n" + @@ -90,7 +91,7 @@ void previousAnchorMustMatchInheritedList() { " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + "items:\n" + " - $previous:\n" + - " blueId: staleHash\n" + + " blueId: " + wrongButValidBlueId + "\n" + " - B"); assertThrows(IllegalArgumentException.class, @@ -107,16 +108,16 @@ void appendOnlyListRejectsPositionalOverlay() { "mergePolicy: append-only\n" + "items:\n" + " - A"); - nodeProvider.addSingleDocs( + Node derived = YAML_MAPPER.readValue( "name: Derived\n" + "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + "items:\n" + " - $pos: 0\n" + - " value: B"); + " value: B", Node.class); assertThrows(IllegalArgumentException.class, - () -> new Blue(nodeProvider).resolve(nodeProvider.getNodeByName("Derived"))); + () -> new Blue(nodeProvider).resolve(derived)); } @Test @@ -172,16 +173,16 @@ void positionalListOverlaysInheritedIndexAndAppendsNormalItems() { "items:\n" + " - $empty: true\n" + " - B"); - nodeProvider.addSingleDocs( + Node derived = YAML_MAPPER.readValue( "name: Derived\n" + "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + "items:\n" + " - $pos: 0\n" + " value: A\n" + - " - C"); + " - C", Node.class); - Node resolved = new Blue(nodeProvider).resolve(nodeProvider.getNodeByName("Derived")); + Node resolved = new Blue(nodeProvider).resolve(derived); assertEquals(Arrays.asList("A", "B", "C"), Arrays.asList( resolved.getItems().get(0).getValue(), @@ -230,16 +231,16 @@ void positionalObjectOverlayReplacesEmptyPlaceholder() { " blueId: " + LIST_TYPE_BLUE_ID + "\n" + "items:\n" + " - $empty: true"); - nodeProvider.addSingleDocs( + Node derived = YAML_MAPPER.readValue( "name: Derived\n" + "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + "items:\n" + " - $pos: 0\n" + " name: Real item\n" + - " x: A"); + " x: A", Node.class); - Node resolved = new Blue(nodeProvider).resolve(nodeProvider.getNodeByName("Derived")); + Node resolved = new Blue(nodeProvider).resolve(derived); Node item = resolved.getItems().get(0); assertEquals("Real item", item.getName()); @@ -248,27 +249,14 @@ void positionalObjectOverlayReplacesEmptyPlaceholder() { } @Test - void falseEmptyPropertyIsNotTreatedAsPlaceholder() { + void malformedEmptyPlaceholderIsRejected() { BasicNodeProvider nodeProvider = new BasicNodeProvider(); - nodeProvider.addSingleDocs( + assertThrows(IllegalArgumentException.class, () -> nodeProvider.addSingleDocs( "name: Base\n" + "type:\n" + " blueId: " + LIST_TYPE_BLUE_ID + "\n" + "items:\n" + - " - $empty: false"); - nodeProvider.addSingleDocs( - "name: Derived\n" + - "type:\n" + - " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + - "items:\n" + - " - $pos: 0\n" + - " x: A"); - - Node resolved = new Blue(nodeProvider).resolve(nodeProvider.getNodeByName("Derived")); - Node item = resolved.getItems().get(0); - - assertEquals(false, item.getProperties().get("$empty").getValue()); - assertEquals("A", item.getProperties().get("x").getValue()); + " - $empty: false")); } @Test @@ -290,16 +278,16 @@ void positionalOverlayCanRefineInheritedItemType() { "items:\n" + " - type:\n" + " blueId: " + nodeProvider.getBlueIdByName("B")); - nodeProvider.addSingleDocs( + Node derived = YAML_MAPPER.readValue( "name: Derived\n" + "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + "items:\n" + " - $pos: 0\n" + " type:\n" + - " blueId: " + nodeProvider.getBlueIdByName("C")); + " blueId: " + nodeProvider.getBlueIdByName("C"), Node.class); - Node resolved = new Blue(nodeProvider).resolve(nodeProvider.getNodeByName("Derived")); + Node resolved = new Blue(nodeProvider).resolve(derived); assertEquals("C", resolved.getItems().get(0).getType().getName()); } @@ -314,15 +302,15 @@ void positionalListCanOverlayNonZeroInheritedIndex() { "items:\n" + " - A\n" + " - $empty: true"); - nodeProvider.addSingleDocs( + Node derived = YAML_MAPPER.readValue( "name: Derived\n" + "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + "items:\n" + " - $pos: 1\n" + - " value: B"); + " value: B", Node.class); - Node resolved = new Blue(nodeProvider).resolve(nodeProvider.getNodeByName("Derived")); + Node resolved = new Blue(nodeProvider).resolve(derived); assertEquals(Arrays.asList("A", "B"), Arrays.asList( resolved.getItems().get(0).getValue(), @@ -341,7 +329,7 @@ void previousAnchorCanBeCombinedWithPositionalOverlayAndAppend() { " - $empty: true"); Node base = nodeProvider.getNodeByName("Base"); String baseItemsBlueId = BlueIdCalculator.calculateBlueId(base.getItems()); - nodeProvider.addSingleDocs( + Node derived = YAML_MAPPER.readValue( "name: Derived\n" + "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + @@ -350,9 +338,9 @@ void previousAnchorCanBeCombinedWithPositionalOverlayAndAppend() { " blueId: " + baseItemsBlueId + "\n" + " - $pos: 1\n" + " value: B\n" + - " - C"); + " - C", Node.class); - Node resolved = new Blue(nodeProvider).resolve(nodeProvider.getNodeByName("Derived")); + Node resolved = new Blue(nodeProvider).resolve(derived); assertEquals(Arrays.asList("A", "B", "C"), Arrays.asList( resolved.getItems().get(0).getValue(), @@ -361,13 +349,13 @@ void previousAnchorCanBeCombinedWithPositionalOverlayAndAppend() { } @Test - void listHashAcceptsSparsePositionControlsForProviderIngestion() { + void directListHashRejectsSparsePositionControls() { String sparsePosition = "items:\n" + " - $pos: 1\n" + " value: B"; - BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue(sparsePosition, Node.class)); - // nothing should be thrown + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue(sparsePosition, Node.class))); } @Test @@ -393,6 +381,147 @@ void positionalListRejectsDuplicatePosition() { () -> new Blue(nodeProvider).resolve(derived)); } + @Test + void posReplaceObjectReplacesInheritedObject() { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + nodeProvider.addSingleDocs( + "name: Base\n" + + "type:\n" + + " blueId: " + LIST_TYPE_BLUE_ID + "\n" + + "items:\n" + + " - inherited: yes\n"); + Node derived = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + + "items:\n" + + " - $pos: 0\n" + + " $replace:\n" + + " replacement: replaced", Node.class); + + Node resolved = new Blue(nodeProvider).resolve(derived); + + assertFalse(resolved.getItems().get(0).getProperties().containsKey("inherited")); + assertEquals("replaced", resolved.getItems().get(0).getAsText("/replacement")); + } + + @Test + void posReplaceListReplacesInheritedList() { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + nodeProvider.addSingleDocs( + "name: Base\n" + + "type:\n" + + " blueId: " + LIST_TYPE_BLUE_ID + "\n" + + "items:\n" + + " - items:\n" + + " - A\n"); + Node derived = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + + "items:\n" + + " - $pos: 0\n" + + " $replace:\n" + + " items:\n" + + " - B\n" + + " - C", Node.class); + + Node resolved = new Blue(nodeProvider).resolve(derived); + + assertEquals(Arrays.asList("B", "C"), Arrays.asList( + resolved.getItems().get(0).getItems().get(0).getValue(), + resolved.getItems().get(0).getItems().get(1).getValue())); + } + + @Test + void posReplacePureReferenceReplacesInheritedReference() { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + nodeProvider.addSingleDocs("name: Referenced\nvalue: R"); + String referenceBlueId = nodeProvider.getBlueIdByName("Referenced"); + nodeProvider.addSingleDocs( + "name: Base\n" + + "type:\n" + + " blueId: " + LIST_TYPE_BLUE_ID + "\n" + + "items:\n" + + " - A\n"); + Node derived = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + + "items:\n" + + " - $pos: 0\n" + + " $replace:\n" + + " blueId: " + referenceBlueId, Node.class); + + Node resolved = new Blue(nodeProvider).resolve(derived); + + assertEquals(null, resolved.getItems().get(0).getValue()); + assertEquals(referenceBlueId, resolved.getItems().get(0).getBlueId()); + } + + @Test + void valueShorthandForScalarWorksAndRejectsCollectionValues() { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + nodeProvider.addSingleDocs( + "name: Base\n" + + "type:\n" + + " blueId: " + LIST_TYPE_BLUE_ID + "\n" + + "items:\n" + + " - A"); + Node scalarOverlay = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + + "items:\n" + + " - $pos: 0\n" + + " value: B", Node.class); + + Node resolved = new Blue(nodeProvider).resolve(scalarOverlay); + + assertEquals("B", resolved.getItems().get(0).getValue()); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "items:\n" + + " - $pos: 0\n" + + " value:\n" + + " x: y", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "items:\n" + + " - $pos: 0\n" + + " value:\n" + + " - A", Node.class)); + } + + @Test + void mapOverlayOnScalarInheritedItemIsRejected() { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + nodeProvider.addSingleDocs( + "name: Base\n" + + "type:\n" + + " blueId: " + LIST_TYPE_BLUE_ID + "\n" + + "items:\n" + + " - A"); + Node objectOverlay = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + + "items:\n" + + " - $pos: 0\n" + + " x: B", Node.class); + + assertThrows(IllegalArgumentException.class, + () -> new Blue(nodeProvider).resolve(objectOverlay)); + } + + @Test + void replaceWithoutPosAndReplaceWithSiblingOverlayAreRejected() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "items:\n" + + " - $replace:\n" + + " value: A", Node.class)); + + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "items:\n" + + " - $pos: 0\n" + + " $replace:\n" + + " value: A\n" + + " sibling: B", Node.class)); + } + @Test void positionalListRejectsOutOfRangePosition() { BasicNodeProvider nodeProvider = new BasicNodeProvider(); diff --git a/src/test/java/blue/language/ListProcessorTest.java b/src/test/java/blue/language/ListProcessorTest.java index 16cfb0a..bb4e909 100644 --- a/src/test/java/blue/language/ListProcessorTest.java +++ b/src/test/java/blue/language/ListProcessorTest.java @@ -27,7 +27,7 @@ public void testItemTypeAssignment() { .type("List") .itemType("Integer"); Node listB = new Node().name("ListB") - .type(new Node().blueId(calculateBlueId(listA))); + .type(new Node().blueId(new Blue().calculateSemanticBlueId(listA))); List nodes = Arrays.asList(listA, listB); MergingProcessor mergingProcessor = new SequentialMergingProcessor( @@ -100,15 +100,19 @@ public void testListWithInvalidItemType() throws Exception { nodeProvider.addSingleDocs(a); String b = "name: B\n" + - "type: " + nodeProvider.getBlueIdByName("A"); + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("A"); nodeProvider.addSingleDocs(b); String listOfB = "name: ListOfB\n" + "type: List\n" + - "itemType: " + nodeProvider.getBlueIdByName("B") + "\n" + + "itemType:\n" + + " blueId: " + nodeProvider.getBlueIdByName("B") + "\n" + "items:\n" + - " - type: " + nodeProvider.getBlueIdByName("B") + "\n" + - " - type: " + nodeProvider.getBlueIdByName("A"); // This should cause an error + " - type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("B") + "\n" + + " - type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("A"); // This should cause an error nodeProvider.addSingleDocs(listOfB); MergingProcessor mergingProcessor = new SequentialMergingProcessor( @@ -260,8 +264,10 @@ public void testNonListTypeWithItemType() throws Exception { nodeProvider.addSingleDocs(a); String nonListWithItemType = "name: NonListWithItemType\n" + - "type: " + nodeProvider.getBlueIdByName("A") + "\n" + - "itemType: " + nodeProvider.getBlueIdByName("A"); + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("A") + "\n" + + "itemType:\n" + + " blueId: " + nodeProvider.getBlueIdByName("A"); nodeProvider.addSingleDocs(nonListWithItemType); MergingProcessor mergingProcessor = new SequentialMergingProcessor( @@ -277,4 +283,4 @@ public void testNonListTypeWithItemType() throws Exception { assertThrows(IllegalArgumentException.class, () -> merger.resolve(nonListNode)); } -} \ No newline at end of file +} diff --git a/src/test/java/blue/language/ListTest.java b/src/test/java/blue/language/ListTest.java index 807c51c..2cdb397 100644 --- a/src/test/java/blue/language/ListTest.java +++ b/src/test/java/blue/language/ListTest.java @@ -204,11 +204,10 @@ public void testDifferentFlavoursOfAList2() throws Exception { Node x1Extended = preprocessAndExtend(x1); Node x2Extended = preprocessAndExtend(x2); - Node x5Extended = preprocessAndExtend(x5); + assertThrows(IllegalArgumentException.class, () -> preprocessAndExtend(x5)); assertEquals(3, x1Extended.getItems().size()); assertEquals(3, x2Extended.getItems().size()); - assertEquals(3, x5Extended.getItems().size()); } diff --git a/src/test/java/blue/language/MergeReverserTest.java b/src/test/java/blue/language/MergeReverserTest.java index 8455a82..3630cd3 100644 --- a/src/test/java/blue/language/MergeReverserTest.java +++ b/src/test/java/blue/language/MergeReverserTest.java @@ -7,6 +7,7 @@ import blue.language.utils.Properties; import org.junit.jupiter.api.Test; +import java.math.BigInteger; import java.util.Arrays; import static org.junit.jupiter.api.Assertions.*; @@ -217,7 +218,7 @@ public void preservesInheritedListPositionalReplacementDuringReverseMinimization Node inheritedList = blue.resolve(nodeProvider.getNodeByName("Base")).getAsNode("/list"); String previousBlueId = BlueIdCalculator.calculateBlueId(inheritedList.getItems()); nodeProvider.addListAndItsItems(inheritedList.getItems()); - nodeProvider.addSingleDocs( + Node derived = blue.yamlToNode( "name: Derived\n" + "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + @@ -229,7 +230,7 @@ public void preservesInheritedListPositionalReplacementDuringReverseMinimization " - $pos: 1\n" + " value: C"); - Node resolved = blue.resolve(nodeProvider.getNodeByName("Derived")); + Node resolved = blue.resolve(derived); Node reversed = new MergeReverser().reverse(resolved); Node reversedList = reversed.getAsNode("/list"); @@ -255,7 +256,7 @@ public void preservesMultipleInheritedListReplacementsAndAppendsDuringReverseMin Node inheritedList = blue.resolve(nodeProvider.getNodeByName("Base")).getAsNode("/list"); String previousBlueId = BlueIdCalculator.calculateBlueId(inheritedList.getItems()); nodeProvider.addListAndItsItems(inheritedList.getItems()); - nodeProvider.addSingleDocs( + Node derived = blue.yamlToNode( "name: Derived\n" + "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + @@ -270,7 +271,7 @@ public void preservesMultipleInheritedListReplacementsAndAppendsDuringReverseMin " value: Z\n" + " - D"); - Node reversed = new MergeReverser().reverse(blue.resolve(nodeProvider.getNodeByName("Derived"))); + Node reversed = new MergeReverser().reverse(blue.resolve(derived)); Node reversedList = reversed.getAsNode("/list"); assertEquals(4, reversedList.getItems().size()); @@ -305,7 +306,7 @@ public void preservesNestedInheritedListItemOverlayDuringReverseMinimization() t Node inheritedList = blue.resolve(nodeProvider.getNodeByName("Base")).getAsNode("/list"); String previousBlueId = BlueIdCalculator.calculateBlueId(inheritedList.getItems()); nodeProvider.addListAndItsItems(inheritedList.getItems()); - nodeProvider.addSingleDocs( + Node derived = blue.yamlToNode( "name: Derived\n" + "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + @@ -318,7 +319,7 @@ public void preservesNestedInheritedListItemOverlayDuringReverseMinimization() t " details:\n" + " color: red"); - Node reversed = new MergeReverser().reverse(blue.resolve(nodeProvider.getNodeByName("Derived"))); + Node reversed = new MergeReverser().reverse(blue.resolve(derived)); Node overlay = reversed.getAsNode("/list").getItems().get(1); assertEquals(Integer.valueOf(0), overlay.getPosition()); @@ -343,7 +344,7 @@ public void preservesReplacementOfInheritedEmptyListPlaceholder() throws Excepti Node inheritedList = blue.resolve(nodeProvider.getNodeByName("Base")).getAsNode("/list"); String previousBlueId = BlueIdCalculator.calculateBlueId(inheritedList.getItems()); nodeProvider.addListAndItsItems(inheritedList.getItems()); - nodeProvider.addSingleDocs( + Node derived = blue.yamlToNode( "name: Derived\n" + "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + @@ -355,7 +356,7 @@ public void preservesReplacementOfInheritedEmptyListPlaceholder() throws Excepti " - $pos: 0\n" + " value: A"); - Node reversed = new MergeReverser().reverse(blue.resolve(nodeProvider.getNodeByName("Derived"))); + Node reversed = new MergeReverser().reverse(blue.resolve(derived)); Node overlay = reversed.getAsNode("/list").getItems().get(1); assertEquals(Integer.valueOf(0), overlay.getPosition()); @@ -363,6 +364,44 @@ public void preservesReplacementOfInheritedEmptyListPlaceholder() throws Excepti assertEquals("A", blue.resolve(reversed).getAsNode("/list").getItems().get(0).getValue()); } + @Test + public void canonicalOverlayDoesNotSerializePreviousOrPos() throws Exception { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + nodeProvider.addSingleDocs( + "name: Base\n" + + "list:\n" + + " type: List\n" + + " items:\n" + + " - A\n" + + " - B"); + Blue blue = new Blue(nodeProvider); + Node inheritedList = blue.resolve(nodeProvider.getNodeByName("Base")).getAsNode("/list"); + String previousBlueId = BlueIdCalculator.calculateBlueId(inheritedList.getItems()); + nodeProvider.addListAndItsItems(inheritedList.getItems()); + Node derived = blue.yamlToNode( + "name: Derived\n" + + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Base") + "\n" + + "list:\n" + + " type: List\n" + + " items:\n" + + " - $previous:\n" + + " blueId: " + previousBlueId + "\n" + + " - $pos: 1\n" + + " value: C"); + + Node canonical = new MergeReverser().reverseToCanonicalOverlay(blue.resolve(derived)); + Node canonicalList = canonical.getAsNode("/list"); + + assertEquals(2, canonicalList.getItems().size()); + assertEquals("A", canonicalList.getItems().get(0).getValue()); + assertEquals("C", canonicalList.getItems().get(1).getValue()); + canonicalList.getItems().forEach(item -> { + assertNull(item.getPreviousBlueId()); + assertNull(item.getPosition()); + }); + } + @Test public void preservesScalarOverrideThatDiffersFromType() throws Exception { BasicNodeProvider nodeProvider = new BasicNodeProvider(); @@ -399,7 +438,7 @@ public void preservesSchemaOverrideThatDiffersFromType() throws Exception { Node reversed = new MergeReverser().reverse(resolved); assertNotNull(reversed.getSchema()); - assertEquals(3, reversed.getSchema().getMinLengthValue()); + assertEquals(BigInteger.valueOf(3), reversed.getSchema().getMinLengthExact()); } } diff --git a/src/test/java/blue/language/NodeDeserializerTest.java b/src/test/java/blue/language/NodeDeserializerTest.java index 67632ea..34a1d85 100644 --- a/src/test/java/blue/language/NodeDeserializerTest.java +++ b/src/test/java/blue/language/NodeDeserializerTest.java @@ -3,13 +3,16 @@ import blue.language.model.Schema; import blue.language.model.Node; import blue.language.utils.Properties; +import blue.language.utils.BlueIdCalculator; import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.math.BigInteger; +import static blue.language.utils.Properties.BOOLEAN_TYPE_BLUE_ID; import static blue.language.utils.Properties.DOUBLE_TYPE_BLUE_ID; import static blue.language.utils.Properties.INTEGER_TYPE_BLUE_ID; +import static blue.language.utils.UncheckedObjectMapper.JSON_MAPPER; import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; import static org.junit.jupiter.api.Assertions.*; @@ -90,6 +93,35 @@ public void testPayloadKindExclusivity() { " - def", Node.class)); } + @Test + public void contractsAreReservedIdentityContent() throws Exception { + Node valueWithContracts = YAML_MAPPER.readValue( + "value: abc\n" + + "contracts:\n" + + " audit:\n" + + " value: enabled", Node.class); + assertEquals("abc", valueWithContracts.getValue()); + assertNotNull(valueWithContracts.getContracts()); + assertFalse(valueWithContracts.getProperties() != null + && valueWithContracts.getProperties().containsKey("contracts")); + assertEquals("enabled", valueWithContracts.getAsText("/contracts/audit/value")); + + Node itemsWithContracts = YAML_MAPPER.readValue( + "items:\n" + + " - abc\n" + + "contracts:\n" + + " audit:\n" + + " value: enabled", Node.class); + assertEquals(1, itemsWithContracts.getItems().size()); + assertEquals("enabled", itemsWithContracts.getAsText("/contracts/audit/value")); + + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("contracts: false", Node.class)); + + String baseId = BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue("value: abc", Node.class)); + String contractsId = BlueIdCalculator.calculateBlueId(valueWithContracts); + assertNotEquals(baseId, contractsId); + } + @Test public void testListControlMetadata() throws Exception { String doc = "type: List\n" + @@ -165,7 +197,7 @@ public void testInternalPropertiesFieldIsRejected() { @Test public void testNumbers() throws Exception { String doc = "int1: 9007199254740991\n" + - "int2: 132452345234524739582739458723948572934875\n" + + "int2: \"132452345234524739582739458723948572934875\"\n" + "int3:\n" + " type:\n" + " blueId: " + INTEGER_TYPE_BLUE_ID + "\n" + @@ -178,12 +210,18 @@ public void testNumbers() throws Exception { Node node = YAML_MAPPER.readValue(doc, Node.class); assertEquals(new BigInteger("9007199254740991"), node.getProperties().get("int1").getValue()); - assertEquals(new BigInteger("132452345234524739582739458723948572934875"), node.getProperties().get("int2").getValue()); + assertEquals("132452345234524739582739458723948572934875", node.getProperties().get("int2").getValue()); assertEquals(new BigInteger("132452345234524739582739458723948572934875"), node.getProperties().get("int3").getValue()); assertEquals(new BigDecimal("132452345234524739582739458723948572934875.132452345234524739582739458723948572934875"), node.getProperties().get("dec1").getValue()); assertEquals(new BigDecimal("1.3245234523452473E+41"), node.getProperties().get("dec2").getValue()); } + @Test + public void testUnquotedLargeIntegerIsRejected() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "x: 132452345234524739582739458723948572934875", Node.class)); + } + @Test public void testTypedDoubleCanonicalizesNumericFormsToBinary64() throws Exception { String doc = "fromInteger:\n" + @@ -218,6 +256,27 @@ public void testTypedDoubleRejectsNonFiniteStrings() throws Exception { assertThrows(IllegalArgumentException.class, () -> node.getProperties().get("x").getValue()); } + @Test + public void explicitBooleanTextValuesAreParsedStrictly() { + Node trueNode = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + BOOLEAN_TYPE_BLUE_ID + "\n" + + "value: \"true\"", Node.class); + assertEquals(true, trueNode.getValue()); + + Node falseNode = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + BOOLEAN_TYPE_BLUE_ID + "\n" + + "value: \"false\"", Node.class); + assertEquals(false, falseNode.getValue()); + + Node invalid = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + BOOLEAN_TYPE_BLUE_ID + "\n" + + "value: \"anything\"", Node.class); + assertThrows(IllegalArgumentException.class, invalid::getValue); + } + @Test public void testType() throws Exception { String doc = "a:\n" + @@ -291,7 +350,6 @@ public void testSchema() throws Exception { String doc = "name: name\n" + "schema:\n" + " required: true\n" + - " allowMultiple: false\n" + " minLength: 5\n" + " maxLength: 10\n" + " minimum: 1.01\n" + @@ -305,32 +363,29 @@ public void testSchema() throws Exception { " minFields: 1\n" + " maxFields: 3\n" + " enum:\n" + - " - blueId: 84ZWw2aoqB6dWRM6N1qWwgcXGrjfeKexTNdWxxAEcECH\n" + - " - name: name2\n" + - " description: description2"; + " - red\n" + + " - value: blue"; Node node = YAML_MAPPER.readValue(doc, Node.class); Schema schema = node.getSchema(); assertTrue(schema.getRequiredValue()); - assertEquals(false, schema.getAllowMultipleValue()); - assertEquals((Integer) 5, schema.getMinLengthValue()); - assertEquals((Integer) 10, schema.getMaxLengthValue()); + assertEquals(BigInteger.valueOf(5), schema.getMinLengthExact()); + assertEquals(BigInteger.valueOf(10), schema.getMaxLengthExact()); assertEquals(new BigDecimal("1.01"), schema.getMinimumValue()); assertEquals(new BigDecimal("100.01"), schema.getMaximumValue()); assertEquals(new BigDecimal("0.01"), schema.getExclusiveMinimumValue()); assertEquals(new BigDecimal("101.01"), schema.getExclusiveMaximumValue()); assertEquals(new BigDecimal("2.01"), schema.getMultipleOfValue()); - assertEquals((Integer) 1, schema.getMinItemsValue()); - assertEquals((Integer) 5, schema.getMaxItemsValue()); + assertEquals(BigInteger.ONE, schema.getMinItemsExact()); + assertEquals(BigInteger.valueOf(5), schema.getMaxItemsExact()); assertEquals(true, schema.getUniqueItemsValue()); - assertEquals((Integer) 1, schema.getMinFieldsValue()); - assertEquals((Integer) 3, schema.getMaxFieldsValue()); + assertEquals(BigInteger.ONE, schema.getMinFieldsExact()); + assertEquals(BigInteger.valueOf(3), schema.getMaxFieldsExact()); assertEquals(2, schema.getEnum().size()); - assertEquals("84ZWw2aoqB6dWRM6N1qWwgcXGrjfeKexTNdWxxAEcECH", schema.getEnum().get(0).getBlueId()); - assertEquals("name2", schema.getEnum().get(1).getName()); - assertEquals("description2", schema.getEnum().get(1).getDescription()); + assertEquals("red", schema.getEnum().get(0).getValue()); + assertEquals("blue", schema.getEnum().get(1).getValue()); } @Test @@ -344,32 +399,35 @@ public void testSchemaPatternIsRejected() { } @Test - public void testLegacySchemaOptionsMigratesToEnum() throws Exception { + public void testInvalidSchemaOptionsKeyIsRejected() { String doc = "name: name\n" + "schema:\n" + " options:\n" + " - value: red\n" + " - value: blue"; - Node node = YAML_MAPPER.readValue(doc, Node.class); - - assertNotNull(node.getSchema().getEnum()); - assertEquals(2, node.getSchema().getEnum().size()); - assertEquals("red", node.getSchema().getEnum().get(0).getValue()); - assertSame(node.getSchema().getEnum(), node.getSchema().getOptions()); + RuntimeException exception = assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue(doc, Node.class)); + assertTrue(exception.getMessage().contains("schema.options")); } @Test - public void testLegacyConstraintsMigratesToSchema() throws Exception { + public void testInvalidConstraintsKeyIsRejected() { String doc = "name: name\n" + "constraints:\n" + " minLength: 5"; - Node node = YAML_MAPPER.readValue(doc, Node.class); + RuntimeException exception = assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue(doc, Node.class)); + assertTrue(exception.getMessage().contains("\"constraints\" is not part of the Blue Language 1.0")); + } - assertNotNull(node.getSchema()); - assertEquals((Integer) 5, node.getSchema().getMinLengthValue()); - assertNull(node.getProperties()); + @Test + public void testSchemaAllowMultipleIsRejected() { + String doc = "name: name\n" + + "schema:\n" + + " allowMultiple: true"; + + RuntimeException exception = assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue(doc, Node.class)); + assertTrue(exception.getMessage().contains("schema.allowMultiple")); } @Test @@ -381,4 +439,229 @@ public void testSchemaAndConstraintsConflictIsRejected() { " maxLength: 10", Node.class)); } + @Test + public void rootNullIsRejected() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("null", Node.class)); + } + + @Test + public void rootScalarListObjectAndReferenceAreAccepted() { + assertEquals("abc", YAML_MAPPER.readValue("abc", Node.class).getValue()); + assertNotNull(YAML_MAPPER.readValue("[]", Node.class).getItems()); + assertNotNull(YAML_MAPPER.readValue("{}", Node.class)); + assertTrue(YAML_MAPPER.readValue("blueId: abc", Node.class).isReferenceOnly()); + } + + @Test + public void rejectsWrongReservedFieldTypes() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("name: true", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("description: 123", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("blueId: 123", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("mergePolicy: true", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema: []", Node.class)); + } + + @Test + public void rejectsObjectValuedItems() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "items:\n" + + " blueId: abc", Node.class)); + } + + @Test + public void nestedBlueAndRootBlueListAreRejected() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "child:\n" + + " blue: x", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "blue:\n" + + " - x", Node.class)); + } + + @Test + public void schemaKeywordValueShapesAreStrict() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n required: \"true\"", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n required:\n value: true", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n uniqueItems:\n value: true", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n minItems: \"1\"", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n minItems: 9007199254740992", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n minItems:\n type: Integer\n value: \"5\"", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n minLength:\n type: Integer\n value: \"5\"", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n enum: red", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n enum:\n - null", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n enum:\n - {}", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n enum:\n - $empty: true", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n enum:\n - blueId: abc", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n enum:\n - blueId: this#0", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n enum:\n - value: 1\n contracts: {}", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n enum:\n - name: one\n value: 1", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n enum:\n - value: 1\n schema:\n minimum: 0", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n minimum: \"9007199254740992\"", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n minimum: 9007199254740992", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n minimum:\n type: Integer\n value: \"1\"\n contracts: {}", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema:\n minimum:\n type: Integer\n value: \"1\"\n name: one", Node.class)); + + Node node = YAML_MAPPER.readValue( + "schema:\n" + + " minimum:\n" + + " type:\n" + + " blueId: " + INTEGER_TYPE_BLUE_ID + "\n" + + " value: \"9007199254740992\"", Node.class); + assertEquals(new BigInteger("9007199254740992"), node.getSchema().getMinimum().getValue()); + + Node safeLargeCount = YAML_MAPPER.readValue("schema:\n minItems: 9007199254740991", Node.class); + assertEquals(new BigInteger("9007199254740991"), safeLargeCount.getSchema().getMinItems().getValue()); + + Node enumNode = YAML_MAPPER.readValue( + "schema:\n" + + " enum:\n" + + " - type:\n" + + " blueId: " + INTEGER_TYPE_BLUE_ID + "\n" + + " value: \"9007199254740992\"", Node.class); + assertEquals(new BigInteger("9007199254740992"), enumNode.getSchema().getEnum().get(0).getValue()); + } + + @Test + public void schemaEnumRejectsContractsOnExplicitScalar() { + assertThrows(RuntimeException.class, + () -> YAML_MAPPER.readValue("schema:\n enum:\n - value: 1\n contracts: {}", Node.class)); + } + + @Test + public void schemaEnumRejectsNameDescriptionOnExplicitScalar() { + assertThrows(RuntimeException.class, + () -> YAML_MAPPER.readValue("schema:\n enum:\n - name: one\n value: 1", Node.class)); + assertThrows(RuntimeException.class, + () -> YAML_MAPPER.readValue("schema:\n enum:\n - description: one\n value: 1", Node.class)); + } + + @Test + public void schemaEnumRejectsSchemaOnExplicitScalar() { + assertThrows(RuntimeException.class, + () -> YAML_MAPPER.readValue("schema:\n enum:\n - value: 1\n schema:\n minimum: 0", Node.class)); + } + + @Test + public void schemaMinimumRejectsContractsOnExplicitNumericNode() { + assertThrows(RuntimeException.class, + () -> YAML_MAPPER.readValue("schema:\n minimum:\n type: Integer\n value: \"1\"\n contracts: {}", Node.class)); + } + + @Test + public void schemaMinItemsExplicitNodeRejected() { + assertThrows(RuntimeException.class, + () -> YAML_MAPPER.readValue("schema:\n minItems:\n type: Integer\n value: \"5\"", Node.class)); + } + + @Test + public void schemaMinLengthExplicitNodeRejected() { + assertThrows(RuntimeException.class, + () -> YAML_MAPPER.readValue("schema:\n minLength:\n type: Integer\n value: \"5\"", Node.class)); + } + + @Test + public void schemaMinimumTypedLargeIntegerAliasIsAcceptedAndPreprocessed() { + Node parsed = YAML_MAPPER.readValue( + "schema:\n" + + " minimum:\n" + + " type: Integer\n" + + " value: \"9007199254740992\"", Node.class); + + assertEquals("Integer", parsed.getSchema().getMinimum().getType().getValue()); + + Node preprocessed = new Blue().preprocess(parsed); + assertEquals(INTEGER_TYPE_BLUE_ID, preprocessed.getSchema().getMinimum().getType().getBlueId()); + assertEquals(new BigInteger("9007199254740992"), preprocessed.getSchema().getMinimum().getValue()); + } + + @Test + public void schemaCountKeywordsExposeExactSafeLargeIntegerValues() { + Node parsed = YAML_MAPPER.readValue( + "schema:\n" + + " minItems: 2147483648\n" + + " maxItems: 9007199254740991\n" + + " minLength: 2147483648\n" + + " maxLength: 9007199254740991\n" + + " minFields: 2147483648\n" + + " maxFields: 9007199254740991", Node.class); + + assertEquals(new BigInteger("2147483648"), parsed.getSchema().getMinItemsExact()); + assertEquals(new BigInteger("9007199254740991"), parsed.getSchema().getMaxItemsExact()); + assertEquals(new BigInteger("2147483648"), parsed.getSchema().getMinLengthExact()); + assertEquals(new BigInteger("9007199254740991"), parsed.getSchema().getMaxLengthExact()); + assertEquals(new BigInteger("2147483648"), parsed.getSchema().getMinFieldsExact()); + assertEquals(new BigInteger("9007199254740991"), parsed.getSchema().getMaxFieldsExact()); + assertTrue(parsed.getSchema().toString().contains("9007199254740991")); + } + + @Test + public void schemaVerifierHandlesLargeButSafeCountDeterministically() { + Node parsed = YAML_MAPPER.readValue( + "items: []\n" + + "schema:\n" + + " minItems: 2147483648", Node.class); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> new Blue().resolve(parsed)); + assertTrue(error.getMessage().contains("minimum required items")); + } + + @Test + public void reservedNullFieldsAreRejected() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("name: null", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("description: null", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("mergePolicy: null", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("value: null", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("items: null", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("type: null", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("schema: null", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("contracts: null", Node.class)); + } + + @Test + public void nullAndEmptyStringParsingPreservesBlueSemantics() { + Node objectNull = YAML_MAPPER.readValue("x: null", Node.class); + assertTrue(objectNull.getProperties().containsKey("x")); + assertNull(objectNull.getProperties().get("x").getValue()); + + Node listNull = YAML_MAPPER.readValue("items:\n - null", Node.class); + assertEquals(1, listNull.getItems().size()); + assertNull(listNull.getItems().get(0).getValue()); + + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("null", Node.class)); + assertEquals("", YAML_MAPPER.readValue("value: \"\"", Node.class).getValue()); + } + + @Test + public void duplicateKeysAreRejected() { + assertThrows(RuntimeException.class, () -> JSON_MAPPER.readValue("{\"x\":1,\"x\":2}", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("x: 1\nx: 2", Node.class)); + } + + @Test + public void yamlCustomTagsAreRejected() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("value: !custom tagged", Node.class)); + } + + @Test + public void yamlAnchorsAreRejected() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("x: &shared abc\ny: *shared", Node.class)); + } + + @Test + public void yamlOnlyTagsAreRejected() { + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("value: !!binary SGVsbG8=", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("value: !!set\n ? a\n ? b", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue("value: !!omap\n - a: 1", Node.class)); + } + + @Test + public void yamlTimestampAndEmptyStringStayInJsonDataModel() { + Node timestamp = YAML_MAPPER.readValue("value: 2026-05-24", Node.class); + assertEquals("2026-05-24", timestamp.getValue()); + + Node empty = YAML_MAPPER.readValue("value: \"\"", Node.class); + assertEquals("", empty.getValue()); + } + } diff --git a/src/test/java/blue/language/NodeToMapListOrValueTest.java b/src/test/java/blue/language/NodeToMapListOrValueTest.java index 03cb7bd..f7235af 100644 --- a/src/test/java/blue/language/NodeToMapListOrValueTest.java +++ b/src/test/java/blue/language/NodeToMapListOrValueTest.java @@ -190,7 +190,6 @@ public void testListDomainMappingStrategy() throws Exception { public void testNodeWithSchemaMappingStrategy() throws Exception { Schema schema = new Schema() .required(true) - .allowMultiple(false) .minLength( new Node().name("Min smth").value(5) ) @@ -217,19 +216,18 @@ public void testNodeWithSchemaMappingStrategy() throws Exception { Schema resultSchema = fromObject.getSchema(); assertEquals(true, resultSchema.getRequiredValue()); - assertEquals(false, resultSchema.getAllowMultipleValue()); - assertEquals(5, resultSchema.getMinLengthValue()); - assertEquals(10, resultSchema.getMaxLengthValue()); + assertEquals(BigInteger.valueOf(5), resultSchema.getMinLengthExact()); + assertEquals(BigInteger.valueOf(10), resultSchema.getMaxLengthExact()); assertEquals(0, new BigDecimal("1.0").compareTo(resultSchema.getMinimumValue())); assertEquals(0, new BigDecimal("100.0").compareTo(resultSchema.getMaximumValue())); assertEquals(0, new BigDecimal("0.0").compareTo(resultSchema.getExclusiveMinimumValue())); assertEquals(0, new BigDecimal("101.0").compareTo(resultSchema.getExclusiveMaximumValue())); assertEquals(0, new BigDecimal("2.0").compareTo(resultSchema.getMultipleOfValue())); - assertEquals(1, resultSchema.getMinItemsValue()); - assertEquals(5, resultSchema.getMaxItemsValue()); + assertEquals(BigInteger.ONE, resultSchema.getMinItemsExact()); + assertEquals(BigInteger.valueOf(5), resultSchema.getMaxItemsExact()); assertEquals(true, resultSchema.getUniqueItemsValue()); - assertEquals(1, resultSchema.getMinFieldsValue()); - assertEquals(3, resultSchema.getMaxFieldsValue()); + assertEquals(BigInteger.ONE, resultSchema.getMinFieldsExact()); + assertEquals(BigInteger.valueOf(3), resultSchema.getMaxFieldsExact()); assertEquals("red", resultSchema.getEnum().get(0).getValue()); assertEquals("blue", resultSchema.getEnum().get(1).getValue()); } @@ -262,10 +260,42 @@ public void testListControlSerialization() { } @Test - public void canonicalSchemaSerializationEmitsEnumAndNotLegacyOptions() throws Exception { + public void nodeToMapSerializesBlueDirectiveRecursively() { + Object object = NodeToMapListOrValue.get(new Node() + .blue(new Node().properties("imports", new Node().properties( + "Person", new Node().blueId("abc")))) + .value("hello")); + + Map result = (Map) object; + assertInstanceOf(Map.class, result.get("blue")); + Map blue = (Map) result.get("blue"); + assertInstanceOf(Map.class, blue.get("imports")); + assertEquals(Collections.singletonMap("blueId", "abc"), + ((Map) blue.get("imports")).get("Person")); + } + + @Test + public void nodeToMapAllowsContractsAlongsideValueAndItems() { + Node valueWithContracts = new Node() + .value("abc") + .properties("contracts", new Node().properties("audit", new Node().value("on"))); + Map valueResult = (Map) NodeToMapListOrValue.get(valueWithContracts); + assertEquals("abc", valueResult.get("value")); + assertTrue(valueResult.containsKey("contracts")); + + Node itemsWithContracts = new Node() + .items(new Node().value("abc")) + .properties("contracts", new Node().properties("audit", new Node().value("on"))); + Map itemsResult = (Map) NodeToMapListOrValue.get(itemsWithContracts); + assertTrue(itemsResult.containsKey("items")); + assertTrue(itemsResult.containsKey("contracts")); + } + + @Test + public void canonicalSchemaSerializationEmitsEnumAndNoInvalidOptionsKey() throws Exception { Node node = new Blue().yamlToNode( "schema:\n" + - " options:\n" + + " enum:\n" + " - red\n" + " - blue"); @@ -277,6 +307,21 @@ public void canonicalSchemaSerializationEmitsEnumAndNotLegacyOptions() throws Ex assertEquals("blue", node.getSchema().getEnum().get(1).getValue()); } + @Test + public void schemaToMapPlainScalarDoesNotIgnoreContracts() { + Node node = new Node().schema(new Schema().enumValues(Collections.singletonList( + new Node() + .value("red") + .contracts(new Node().properties("audit", new Node().value(true)))))); + + Map result = (Map) NodeToMapListOrValue.get(node); + Map schema = (Map) result.get("schema"); + List enumValues = (List) schema.get("enum"); + + assertInstanceOf(Map.class, enumValues.get(0)); + assertTrue(((Map) enumValues.get(0)).containsKey("contracts")); + } + @Test public void testInvalidProgrammaticPreviousControlSerializationIsRejected() { Node invalid = new Node() diff --git a/src/test/java/blue/language/PreprocessorTest.java b/src/test/java/blue/language/PreprocessorTest.java index 09f7848..bd4ef21 100644 --- a/src/test/java/blue/language/PreprocessorTest.java +++ b/src/test/java/blue/language/PreprocessorTest.java @@ -50,17 +50,15 @@ public void testItemsAsBlueId() throws Exception { "items:\n" + " blueId: 84ZWw2aoqB6dWRM6N1qWwgcXGrjfeKexTNdWxxAEcECH"; - Blue blue = new Blue(); - Node node = blue.preprocess(blue.yamlToNode(doc)); - assertEquals("84ZWw2aoqB6dWRM6N1qWwgcXGrjfeKexTNdWxxAEcECH", node.getItems().get(0).getBlueId()); + assertThrows(RuntimeException.class, () -> new Blue().yamlToNode(doc)); } @Test public void testPreprocessWithCustomBlueExtendingDefaultBlue() throws Exception { String doc = "blue:\n" + - " - blueId:\n" + - " " + DEFAULT_BLUE_BLUE_ID + "\n" + - " - name: MyTestTransformation\n" + + " items:\n" + + " - blueId: " + DEFAULT_BLUE_BLUE_ID + "\n" + + " - name: MyTestTransformation\n" + "x:\n" + " type: Integer\n" + "y: ABC"; @@ -84,6 +82,35 @@ public void testPreprocessWithCustomBlueExtendingDefaultBlue() throws Exception assertEquals("XYZ", result.getAsText("/y/value")); } + @Test + public void preprocessorPreprocessAppliesDefaultBaselineWhenBlueOmitted() { + Node raw = YAML_MAPPER.readValue("x: 1", Node.class); + + Node result = new Preprocessor(BootstrapProvider.INSTANCE).preprocess(raw); + + assertEquals(INTEGER_TYPE_BLUE_ID, result.getAsText("/x/type/blueId")); + } + + @Test + public void preprocessorPreprocessWithDefaultBlueMatchesBluePreprocess() { + Node raw = YAML_MAPPER.readValue("x: 1", Node.class); + + Node direct = new Preprocessor(BootstrapProvider.INSTANCE).preprocessWithDefaultBlue(raw); + Node viaBlue = new Blue().preprocess(raw.clone()); + + assertEquals(BlueIdCalculator.calculateBlueId(direct), BlueIdCalculator.calculateBlueId(viaBlue)); + } + + @Test + public void preprocessorPreprocessWithoutDefaultBlueIsExplicit() { + Node raw = YAML_MAPPER.readValue("x: 1", Node.class); + + Node result = new Preprocessor(BootstrapProvider.INSTANCE).preprocessWithoutDefaultBlue(raw); + + assertNull(result.getProperties().get("x").getType()); + assertEquals(BigInteger.ONE, result.getProperties().get("x").getValue()); + } + @Test public void testTypeConsistencyAfterMultiplePreprocessing() throws Exception { String doc = "a:\n" + @@ -148,6 +175,128 @@ public void testNodeProcessingAndDeserialization() throws Exception { assertNodesEqual(expectedRaw, rawNode); } + @Test + public void blueImportsReplaceTypeAliasesAndAreRemoved() { + String personBlueId = BlueIdCalculator.calculateBlueId(new Node().value("PersonType")); + String keyBlueId = BlueIdCalculator.calculateBlueId(new Node().value("KeyType")); + String valueBlueId = BlueIdCalculator.calculateBlueId(new Node().value("ValueType")); + String doc = "blue:\n" + + " imports:\n" + + " Person:\n" + + " blueId: " + personBlueId + "\n" + + " Key:\n" + + " blueId: " + keyBlueId + "\n" + + " Value:\n" + + " blueId: " + valueBlueId + "\n" + + "person:\n" + + " type: Person\n" + + "people:\n" + + " type: List\n" + + " itemType: Person\n" + + "dict:\n" + + " type: Dictionary\n" + + " keyType: Key\n" + + " valueType: Value"; + + Node node = new Blue().yamlToNode(doc); + + assertNull(node.getBlue()); + assertEquals(personBlueId, node.getAsText("/person/type/blueId")); + assertEquals(personBlueId, node.getAsText("/people/itemType/blueId")); + assertEquals(keyBlueId, node.getAsText("/dict/keyType/blueId")); + assertEquals(valueBlueId, node.getAsText("/dict/valueType/blueId")); + } + + @Test + public void blueImportsRejectInvalidShapes() { + String personBlueId = BlueIdCalculator.calculateBlueId(new Node().value("PersonType")); + + assertThrows(RuntimeException.class, () -> new Blue().yamlToNode( + "blue:\n" + + " imports:\n" + + " Person:\n" + + " value: x\n" + + "x:\n" + + " type: Person")); + + assertThrows(RuntimeException.class, () -> new Blue().yamlToNode( + "blue:\n" + + " imports:\n" + + " Text:\n" + + " blueId: " + personBlueId + "\n" + + "x:\n" + + " type: Text")); + + assertThrows(RuntimeException.class, () -> new Blue().yamlToNode( + "blue:\n" + + " imports:\n" + + " Person:\n" + + " blueId: " + personBlueId + "\n" + + " Person:\n" + + " blueId: " + personBlueId + "\n" + + "x:\n" + + " type: Person")); + + assertThrows(RuntimeException.class, () -> new Blue().yamlToNode( + "blue:\n" + + " imports:\n" + + " Person:\n" + + " blueId: not-a-real-blueid\n" + + "x:\n" + + " type: Person")); + + assertThrows(RuntimeException.class, () -> new Blue().yamlToNode( + "blue:\n" + + " imports:\n" + + " Person:\n" + + " blueId: this#0\n" + + "x:\n" + + " type: Person")); + + assertThrows(RuntimeException.class, () -> new Blue().yamlToNode( + "blue:\n" + + " imports:\n" + + " Person:\n" + + " blueId: " + personBlueId + "#0\n" + + "x:\n" + + " type: Person")); + } + + @Test + public void blueImportsDoNotDropOtherBlueTransforms() { + String personBlueId = BlueIdCalculator.calculateBlueId(new Node().value("PersonType")); + String doc = "blue:\n" + + " imports:\n" + + " Person:\n" + + " blueId: " + personBlueId + "\n" + + " items:\n" + + " - name: MyTestTransformation\n" + + "x:\n" + + " type: Person\n" + + "y: ABC"; + Node node = YAML_MAPPER.readValue(doc, Node.class); + + TransformationProcessor changeABCtoXYZ = document -> NodeTransformer.transform(document, docNode -> { + Node result = docNode.clone(); + if ("ABC".equals(docNode.getValue())) { + result.value("XYZ"); + } + return result; + }); + TransformationProcessorProvider provider = transformation -> { + if ("MyTestTransformation".equals(transformation.getName())) { + return Optional.of(changeABCtoXYZ); + } + return Optional.empty(); + }; + + Node result = new Preprocessor(provider, BootstrapProvider.INSTANCE).preprocess(node); + + assertEquals(personBlueId, result.getAsText("/x/type/blueId")); + assertEquals("XYZ", result.getAsText("/y/value")); + assertNull(result.getBlue()); + } + private void assertNodesEqual(Node expected, Node actual) { assertEquals(expected.isInlineValue(), actual.isInlineValue()); assertEquals(expected.getValue(), actual.getValue()); diff --git a/src/test/java/blue/language/SchemaVerifierMinLengthTest.java b/src/test/java/blue/language/SchemaVerifierMinLengthTest.java index d94bc54..b53c22f 100644 --- a/src/test/java/blue/language/SchemaVerifierMinLengthTest.java +++ b/src/test/java/blue/language/SchemaVerifierMinLengthTest.java @@ -222,8 +222,7 @@ public void testMinLengthSubInheritanceNegative() throws Exception { " type:\n" + indent(b, 4) + "\n" + " schema:\n" + - " minLength:\n" + - " value: 2"; + " minLength: 2"; String y = "name: Y\n" + "type:\n" + diff --git a/src/test/java/blue/language/SchemaVerifierTest.java b/src/test/java/blue/language/SchemaVerifierTest.java index 06fff3b..8fa340c 100644 --- a/src/test/java/blue/language/SchemaVerifierTest.java +++ b/src/test/java/blue/language/SchemaVerifierTest.java @@ -10,9 +10,11 @@ import org.junit.jupiter.api.Test; import java.math.BigDecimal; +import java.math.BigInteger; import java.util.Arrays; import static blue.language.utils.BlueIdCalculator.calculateBlueId; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -56,16 +58,14 @@ public void testRequiredNegative() throws Exception { } @Test - public void testAllowMultiplePositive() throws Exception { - schema.allowMultiple(true); + public void testMultipleItemsAllowedWithoutMaxItems() throws Exception { node.items(Arrays.asList(new Node().name("item 1"), new Node().name("item 2"))); - merger.resolve(node); - // nothing should be thrown + assertDoesNotThrow(() -> merger.resolve(node)); } @Test - public void testAllowMultipleNegative() throws Exception { - schema.allowMultiple(false); + public void testMaxItemsControlsSingleItemCardinality() throws Exception { + schema.maxItems(1); node.items(new Node().name("item 1"), new Node().name("item 2")); assertThrows(IllegalArgumentException.class, () -> merger.resolve(node)); } @@ -192,7 +192,6 @@ public void testMultipleOfNegative() throws Exception { @Test public void testMinItemsPositive() throws Exception { schema.minItems(2); - schema.allowMultiple(true); node.items(Arrays.asList(new Node(), new Node())); merger.resolve(node); // nothing should be thrown @@ -208,7 +207,6 @@ public void testMinItemsNegative() throws Exception { @Test public void testMaxItemsPositive() throws Exception { schema.maxItems(3); - schema.allowMultiple(true); node.items(Arrays.asList(new Node(), new Node())); merger.resolve(node); // nothing should be thrown @@ -224,7 +222,6 @@ public void testMaxItemsNegative() throws Exception { @Test public void testUniqueItemsPositive() throws Exception { schema.uniqueItems(true); - schema.allowMultiple(true); node.items(Arrays.asList(new Node().name("Name 1"), new Node().name("Name 2"))); merger.resolve(node); // nothing should be thrown @@ -233,7 +230,6 @@ public void testUniqueItemsPositive() throws Exception { @Test public void testUniqueItemsNegative() throws Exception { schema.uniqueItems(true); - schema.allowMultiple(true); node.items(Arrays.asList(new Node().name("Name 1"), new Node().name("Name 1"))); assertThrows(IllegalArgumentException.class, () -> merger.resolve(node)); } @@ -318,6 +314,74 @@ public void testSchemaWellFormedness() throws Exception { .value(BigDecimal.ONE))); } + @Test + public void enumIntersectionPreservesEffectiveScalarType() { + Node source = new Node().schema(new Schema().enumValues(Arrays.asList( + new Node().value(BigInteger.ONE), + new Node().value(new BigDecimal("1.0")), + new Node().value("1")))); + Node target = new Node().schema(new Schema().enumValues(Arrays.asList( + new Node().value(new BigDecimal("1.0")), + new Node().value("1")))); + + new SchemaPropagator().process(target, source, blueId -> null, null); + + assertEquals(2, target.getSchema().getEnum().size()); + assertEquals(new BigDecimal("1.0"), target.getSchema().getEnum().get(0).getValue()); + assertEquals("1", target.getSchema().getEnum().get(1).getValue()); + } + + @Test + public void minimumAndExclusiveMinimumMergeToExclusive() { + Node source = new Node().schema(new Schema().minimum(new BigDecimal("5"))); + Node targetAtBound = new Node().schema(new Schema().exclusiveMinimum(new BigDecimal("5"))).value(new BigDecimal("5")); + Node targetAboveBound = new Node().schema(new Schema().exclusiveMinimum(new BigDecimal("5"))).value(new BigDecimal("6")); + + assertThrows(IllegalArgumentException.class, () -> propagateAndVerify(targetAtBound, source)); + propagateAndVerify(targetAboveBound, source); + assertEquals(0, new BigDecimal("5").compareTo(targetAboveBound.getSchema().getMinimumValue())); + assertEquals(0, new BigDecimal("5").compareTo(targetAboveBound.getSchema().getExclusiveMinimumValue())); + } + + @Test + public void maximumAndExclusiveMaximumMergeToExclusive() { + Node source = new Node().schema(new Schema().maximum(new BigDecimal("5"))); + Node targetAtBound = new Node().schema(new Schema().exclusiveMaximum(new BigDecimal("5"))).value(new BigDecimal("5")); + Node targetBelowBound = new Node().schema(new Schema().exclusiveMaximum(new BigDecimal("5"))).value(new BigDecimal("4")); + + assertThrows(IllegalArgumentException.class, () -> propagateAndVerify(targetAtBound, source)); + propagateAndVerify(targetBelowBound, source); + assertEquals(0, new BigDecimal("5").compareTo(targetBelowBound.getSchema().getMaximumValue())); + assertEquals(0, new BigDecimal("5").compareTo(targetBelowBound.getSchema().getExclusiveMaximumValue())); + } + + @Test + public void minMaxItemsConflictFails() { + Node source = new Node().schema(new Schema().minItems(3)); + Node target = new Node() + .schema(new Schema().maxItems(2)) + .items(new Node().value("A"), new Node().value("B")); + + assertThrows(IllegalArgumentException.class, () -> propagateAndVerify(target, source)); + } + + @Test + public void integerMultipleOfMergeUsesLcmOrEquivalentAllConstraints() { + Node source = new Node().schema(new Schema().multipleOf(new BigDecimal("4"))); + Node target = new Node().schema(new Schema().multipleOf(new BigDecimal("6"))).value(new BigDecimal("24")); + Node failingTarget = new Node().schema(new Schema().multipleOf(new BigDecimal("6"))).value(new BigDecimal("18")); + + propagateAndVerify(target, source); + + assertEquals(0, new BigDecimal("12").compareTo(target.getSchema().getMultipleOfValue())); + assertThrows(IllegalArgumentException.class, () -> propagateAndVerify(failingTarget, source)); + } + + private void propagateAndVerify(Node target, Node source) { + new SchemaPropagator().process(target, source, blueId -> null, null); + new SchemaVerifier().postProcess(target, source, blueId -> null, null); + } + // // @Test // public void testSchemaAndBlueIdSimpler() throws Exception { diff --git a/src/test/java/blue/language/SelfReferenceTest.java b/src/test/java/blue/language/SelfReferenceTest.java index 88c6128..b7fb338 100644 --- a/src/test/java/blue/language/SelfReferenceTest.java +++ b/src/test/java/blue/language/SelfReferenceTest.java @@ -5,6 +5,7 @@ import blue.language.provider.BasicNodeProvider; import blue.language.provider.NodeContentHandler; import blue.language.utils.BlueIdCalculator; +import blue.language.utils.CircularBlueIdCalculator; import blue.language.utils.NodeExtender; import blue.language.utils.limits.PathLimits; import com.fasterxml.jackson.databind.JsonNode; @@ -39,12 +40,9 @@ public void testSingleDoc() throws Exception { Node aNode = nodeProvider.findNodeByName("A").orElseThrow(() -> new IllegalArgumentException("No A node found")); String aNodeBlueId = nodeProvider.getBlueIdByName("A"); Node extended = aNode.clone(); - new NodeExtender(nodeProvider).extend(extended, PathLimits.withSinglePath("/x/x/x/x")); - - assertEquals(aNodeBlueId, extended.getAsNode("/x/type").getBlueId()); - assertEquals("A", extended.getAsText("/x/type/name")); - assertEquals(aNodeBlueId, extended.getAsNode("/x/type/x/type").getBlueId()); - assertEquals("A", extended.getAsText("/x/type/x/type/name")); + assertThrows(IllegalArgumentException.class, + () -> new NodeExtender(nodeProvider).extend(extended, PathLimits.withSinglePath("/x/x/x/x"))); + assertEquals(aNodeBlueId, aNode.getAsNode("/x/type").getBlueId()); } @@ -64,7 +62,7 @@ public void testSingleDocSelfReferenceBlueIdUsesZeroPlaceholder() throws Excepti .preprocessWithDefaultBlue(YAML_MAPPER.readValue(withPlaceholder, Node.class)); assertEquals( - BlueIdCalculator.calculateBlueId(preprocessedPlaceholder), + BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders(preprocessedPlaceholder), nodeProvider.getBlueIdByName("A")); } @@ -204,8 +202,8 @@ public void testCyclicMultiDocumentSuffixesFollowPreliminaryPlaceholderSort() { "bVal: B"; BasicNodeProvider nodeProvider = new BasicNodeProvider(YAML_MAPPER.readValue(docs, Node.class)); - String expectedFirstName = BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue(aWithPlaceholder, Node.class)) - .compareTo(BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue(bWithPlaceholder, Node.class))) <= 0 + String expectedFirstName = BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders(YAML_MAPPER.readValue(aWithPlaceholder, Node.class)) + .compareTo(BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders(YAML_MAPPER.readValue(bWithPlaceholder, Node.class))) <= 0 ? "A" : "B"; String masterBlueId = baseBlueId(nodeProvider.getBlueIdByName("A")); List fetched = nodeProvider.fetchByBlueId(masterBlueId); @@ -285,7 +283,116 @@ public void testThreeDocumentCycleIsStableAcrossPermutationsAndFetchesByFinalSuf } @Test - public void testThisReferencesAreRewrittenInTypeMetadataAndSchemaEnum() { + public void circularSetCalculatorReturnsFinalMemberIdsInOriginalOrder() { + String docs = "- name: A\n" + + " x:\n" + + " type:\n" + + " blueId: this#1\n" + + " aVal: A\n" + + "- name: B\n" + + " y:\n" + + " type:\n" + + " blueId: this#0\n" + + " bVal: B"; + List nodes = YAML_MAPPER.readValue(docs, Node.class).getItems(); + BasicNodeProvider provider = new BasicNodeProvider(YAML_MAPPER.readValue(docs, Node.class)); + + List ids = CircularBlueIdCalculator.calculateCircularSetBlueIds(nodes); + + assertEquals(provider.getBlueIdByName("A"), ids.get(0)); + assertEquals(provider.getBlueIdByName("B"), ids.get(1)); + assertEquals(baseBlueId(ids.get(0)), baseBlueId(ids.get(1))); + } + + @Test + public void circularSetCalculatorIsStableAcrossPermutations() { + String abc = "- name: A\n" + + " next:\n" + + " type:\n" + + " blueId: this#1\n" + + "- name: B\n" + + " next:\n" + + " type:\n" + + " blueId: this#2\n" + + "- name: C\n" + + " next:\n" + + " type:\n" + + " blueId: this#0"; + String cab = "- name: C\n" + + " next:\n" + + " type:\n" + + " blueId: this#1\n" + + "- name: A\n" + + " next:\n" + + " type:\n" + + " blueId: this#2\n" + + "- name: B\n" + + " next:\n" + + " type:\n" + + " blueId: this#0"; + + Map abcIds = idsByName(YAML_MAPPER.readValue(abc, Node.class).getItems()); + Map cabIds = idsByName(YAML_MAPPER.readValue(cab, Node.class).getItems()); + + assertEquals(abcIds.get("A"), cabIds.get("A")); + assertEquals(abcIds.get("B"), cabIds.get("B")); + assertEquals(abcIds.get("C"), cabIds.get("C")); + } + + @Test + public void zeroPlaceholderIsRejectedInFinalBlueIdInput() { + assertThrows(RuntimeException.class, + () -> BlueIdCalculator.calculateBlueId(new Node().blueId(NodeContentHandler.ZERO_BLUE_ID))); + } + + @Test + public void circularSetWithoutInternalThisReferencesRejected() { + List nodes = Arrays.asList(new Node().value("same"), new Node().value("same")); + + assertThrows(IllegalArgumentException.class, () -> CircularBlueIdCalculator.calculateCircularSetBlueIds(nodes)); + } + + @Test + public void singleDocumentCycleUsesThisHashZero() { + Node node = YAML_MAPPER.readValue("next:\n blueId: this#0", Node.class); + + List ids = CircularBlueIdCalculator.calculateCircularSetBlueIds(Arrays.asList(node)); + + assertEquals(1, ids.size()); + assertTrue(ids.get(0).endsWith("#0")); + } + + @Test + public void bareThisRejectedInCircularApi() { + Node node = YAML_MAPPER.readValue("next:\n blueId: this", Node.class); + + assertThrows(IllegalArgumentException.class, + () -> CircularBlueIdCalculator.calculateCircularSetBlueIds(Arrays.asList(node))); + } + + @Test + public void bareThisRejectedOutsideCircularApi() { + assertThrows(RuntimeException.class, () -> BlueIdCalculator.calculateBlueId(new Node().blueId("this"))); + assertThrows(RuntimeException.class, () -> new Blue().parseBlueIdInputYaml("blueId: this")); + } + + @Test + public void duplicatePreliminaryIdsWithActualCycleUseOriginalIndexTieBreak() { + List nodes = YAML_MAPPER.readValue( + "- next:\n" + + " blueId: this#1\n" + + "- next:\n" + + " blueId: this#0", Node.class).getItems(); + + List ids = CircularBlueIdCalculator.calculateCircularSetBlueIds(nodes); + + assertEquals(baseBlueId(ids.get(0)), baseBlueId(ids.get(1))); + assertTrue(ids.get(0).endsWith("#0")); + assertTrue(ids.get(1).endsWith("#1")); + } + + @Test + public void testThisReferencesAreRewrittenInTypeMetadata() { String docs = "- name: A\n" + " type:\n" + " blueId: this#1\n" + @@ -295,11 +402,6 @@ public void testThisReferencesAreRewrittenInTypeMetadataAndSchemaEnum() { " blueId: this#1\n" + " valueType:\n" + " blueId: this#2\n" + - " choice:\n" + - " schema:\n" + - " enum:\n" + - " - blueId: this#1\n" + - " - blueId: this#2\n" + "- name: B\n" + " peer:\n" + " type:\n" + @@ -316,8 +418,6 @@ public void testThisReferencesAreRewrittenInTypeMetadataAndSchemaEnum() { assertEquals(nodeProvider.getBlueIdByName("C"), a.getItemType().getBlueId()); assertEquals(nodeProvider.getBlueIdByName("B"), a.getKeyType().getBlueId()); assertEquals(nodeProvider.getBlueIdByName("C"), a.getValueType().getBlueId()); - assertEquals(nodeProvider.getBlueIdByName("B"), a.getAsNode("/choice").getSchema().getEnum().get(0).getBlueId()); - assertEquals(nodeProvider.getBlueIdByName("C"), a.getAsNode("/choice").getSchema().getEnum().get(1).getBlueId()); } @Test @@ -333,7 +433,7 @@ public void testParsedCyclicSetStoresSortedDocumentsWithThisReferencesBeforeFetc NodeContentHandler.ParsedContent parsed = NodeContentHandler.parseAndCalculateBlueId(docs, node -> node); List stored = Arrays.asList(JSON_MAPPER.treeToValue(parsed.content, Node[].class)); - assertEquals(BlueIdCalculator.calculateBlueId(stored), parsed.blueId); + assertEquals(BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders(stored), parsed.blueId); Map nameToStoredIndex = IntStream.range(0, stored.size()) .boxed() @@ -385,4 +485,11 @@ private String baseBlueId(String blueId) { return blueId.split("#")[0]; } + private Map idsByName(List nodes) { + List ids = CircularBlueIdCalculator.calculateCircularSetBlueIds(nodes); + return IntStream.range(0, nodes.size()) + .boxed() + .collect(Collectors.toMap(i -> nodes.get(i).getName(), ids::get)); + } + } diff --git a/src/test/java/blue/language/SemanticCanonicalizationTest.java b/src/test/java/blue/language/SemanticCanonicalizationTest.java index 53f70b8..ee52571 100644 --- a/src/test/java/blue/language/SemanticCanonicalizationTest.java +++ b/src/test/java/blue/language/SemanticCanonicalizationTest.java @@ -5,12 +5,56 @@ import blue.language.utils.BlueIdCalculator; import org.junit.jupiter.api.Test; +import java.math.BigInteger; +import java.util.Collections; + +import static blue.language.utils.Properties.INTEGER_TYPE_BLUE_ID; +import static blue.language.utils.Properties.TEXT_TYPE_BLUE_ID; import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; class SemanticCanonicalizationTest { + @Test + void sourceTypeIntegerValueOneSemanticBlueIdWorks() { + Blue blue = new Blue(); + Node source = YAML_MAPPER.readValue("type: Integer\nvalue: 1", Node.class); + + assertEquals( + blue.calculateSemanticBlueId(YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + INTEGER_TYPE_BLUE_ID + "\n" + + "value: 1", Node.class)), + blue.calculateSemanticBlueId(source)); + } + + @Test + void sourceTypeIntegerCanonicalizesToIntegerBlueId() { + Node canonical = new Blue().canonicalize(YAML_MAPPER.readValue("type: Integer\nvalue: 1", Node.class)); + + assertEquals(INTEGER_TYPE_BLUE_ID, canonical.getType().getBlueId()); + assertEquals(BigInteger.ONE, canonical.getValue()); + } + + @Test + void directBlueIdTypeIntegerRejected() { + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue("type: Integer\nvalue: 1", Node.class))); + } + + @Test + void directBlueIdCanonicalIntegerBlueIdAccepted() { + Node canonical = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + INTEGER_TYPE_BLUE_ID + "\n" + + "value: 1", Node.class); + + assertEquals(BlueIdCalculator.calculateBlueId(canonical), new Blue().calculateBlueId(canonical)); + } + @Test void canonicalizeRemovesRedundantInheritedOverridesBeforeHashing() { BasicNodeProvider nodeProvider = new BasicNodeProvider(); @@ -42,4 +86,129 @@ void canonicalizeRemovesRedundantInheritedOverridesBeforeHashing() { assertEquals(blue.calculateSemanticBlueId(minimal), blue.calculateSemanticBlueId(noisy)); assertEquals(BlueIdCalculator.calculateBlueId(canonical), blue.calculateSemanticBlueId(noisy)); } + + @Test + void calculateSemanticBlueIdResolvesTypes() { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + nodeProvider.addSingleDocs( + "name: Product Type\n" + + "inherited: value"); + String productTypeBlueId = nodeProvider.getBlueIdByName("Product Type"); + + Blue blue = new Blue(nodeProvider); + Node source = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + productTypeBlueId + "\n" + + "inherited: value\n" + + "own: value", Node.class); + + Node canonical = blue.canonicalize(source); + + assertFalse(canonical.getProperties().containsKey("inherited")); + assertEquals(BlueIdCalculator.calculateBlueId(canonical), blue.calculateSemanticBlueId(source)); + } + + @Test + void calculateSemanticBlueIdPreprocessesRootBlue() { + Blue blue = new Blue(); + Node aliased = YAML_MAPPER.readValue( + "blue:\n" + + " imports:\n" + + " Person:\n" + + " blueId: " + TEXT_TYPE_BLUE_ID + "\n" + + "type: Person\n" + + "value: hello", Node.class); + Node direct = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + TEXT_TYPE_BLUE_ID + "\n" + + "value: hello", Node.class); + + Node canonical = blue.canonicalize(aliased); + + assertNull(canonical.getBlue()); + assertEquals(blue.calculateSemanticBlueId(direct), blue.calculateSemanticBlueId(aliased)); + } + + @Test + void calculateSemanticBlueIdRejectsInvalidProviderContent() { + String requestedBlueId = BlueIdCalculator.calculateBlueId(new Node().value("expected")); + Blue blue = new Blue(blueId -> Collections.singletonList(new Node().value("actual"))); + Node source = new Node().type(new Node().blueId(requestedBlueId)).value("x"); + + assertThrows(IllegalArgumentException.class, () -> blue.calculateSemanticBlueId(source)); + } + + @Test + void calculateSemanticBlueIdRejectsUnresolvableProviderReferences() { + String missingBlueId = BlueIdCalculator.calculateBlueId(new Node().value("missing")); + Blue blue = new Blue(blueId -> null); + Node source = new Node().type(new Node().blueId(missingBlueId)).value("x"); + + assertThrows(IllegalArgumentException.class, () -> blue.calculateSemanticBlueId(source)); + } + + @Test + void calculateSemanticBlueIdCanonicalOverlayContainsNoPreviousOrPos() { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + nodeProvider.addSingleDocs( + "name: Append Type\n" + + "type: List\n" + + "mergePolicy: append-only\n" + + "items:\n" + + " - value: A"); + String typeBlueId = nodeProvider.getBlueIdByName("Append Type"); + String previousBlueId = BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue( + "items:\n" + + " - value: A", Node.class).getItems()); + Blue blue = new Blue(nodeProvider); + Node source = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + typeBlueId + "\n" + + "items:\n" + + " - $previous:\n" + + " blueId: " + previousBlueId + "\n" + + " - value: B", Node.class); + + Node canonical = blue.canonicalize(source); + + assertNoPreviousOrPos(canonical); + assertEquals(BlueIdCalculator.calculateBlueId(canonical), blue.calculateSemanticBlueId(source)); + } + + @Test + void contractsCanonicalizesAsReservedField() { + Blue blue = new Blue(); + Node source = YAML_MAPPER.readValue( + "value: x\n" + + "contracts:\n" + + " audit:\n" + + " enabled: true", Node.class); + + Node canonical = blue.canonicalize(source); + + assertEquals("x", canonical.getValue()); + assertEquals(Boolean.TRUE, canonical.get("/contracts/audit/enabled/value")); + assertFalse(canonical.getProperties() != null && canonical.getProperties().containsKey("contracts")); + assertEquals(BlueIdCalculator.calculateBlueId(canonical), blue.calculateSemanticBlueId(source)); + } + + private void assertNoPreviousOrPos(Node node) { + if (node == null) { + return; + } + assertNull(node.getPreviousBlueId()); + assertNull(node.getPosition()); + assertNull(node.getBlue()); + assertNoPreviousOrPos(node.getType()); + assertNoPreviousOrPos(node.getItemType()); + assertNoPreviousOrPos(node.getKeyType()); + assertNoPreviousOrPos(node.getValueType()); + assertNoPreviousOrPos(node.getContracts()); + if (node.getItems() != null) { + node.getItems().forEach(this::assertNoPreviousOrPos); + } + if (node.getProperties() != null) { + node.getProperties().values().forEach(this::assertNoPreviousOrPos); + } + } } diff --git a/src/test/java/blue/language/TestUtils.java b/src/test/java/blue/language/TestUtils.java index ae106f0..116d819 100644 --- a/src/test/java/blue/language/TestUtils.java +++ b/src/test/java/blue/language/TestUtils.java @@ -3,6 +3,7 @@ import blue.language.merge.MergingProcessor; import blue.language.model.Node; import blue.language.provider.DirectoryBasedNodeProvider; +import blue.language.utils.NodeProviderWrapper; import java.io.IOException; import java.util.*; @@ -15,7 +16,7 @@ public static DirectoryBasedNodeProvider samplesDirectoryNodeProvider() throws I } public static NodeProvider fakeNameBasedNodeProvider(Collection nodes) { - return new NodeProvider() { + return NodeProviderWrapper.unverified(new NodeProvider() { private final Map nodeMap = nodes.stream() .collect(Collectors.toMap( node -> "blueId-" + node.getName(), @@ -27,16 +28,16 @@ public List fetchByBlueId(String blueId) { Node node = nodeMap.get(blueId); return node != null ? Collections.singletonList(node) : new ArrayList<>(); } - }; + }); } public static NodeProvider useNodeNameAsBlueIdProvider(List nodes) { - return (blueId) -> nodes.stream() + return NodeProviderWrapper.unverified((blueId) -> nodes.stream() .filter(e -> blueId.equals(e.getName())) .findAny() .map(Node::clone) .map(Collections::singletonList) - .orElse(null); + .orElse(null)); } public static MergingProcessor numbersMustIncreasePayloadMerger() { diff --git a/src/test/java/blue/language/conformance/BlueLanguageConformanceFixtureTest.java b/src/test/java/blue/language/conformance/BlueLanguageConformanceFixtureTest.java new file mode 100644 index 0000000..454335b --- /dev/null +++ b/src/test/java/blue/language/conformance/BlueLanguageConformanceFixtureTest.java @@ -0,0 +1,207 @@ +package blue.language.conformance; + +import blue.language.Blue; +import blue.language.BlueConformanceFailure; +import blue.language.BlueConformanceReport; +import blue.language.BlueConformanceSuiteRunner; +import blue.language.BlueFixtureCategory; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +public class BlueLanguageConformanceFixtureTest { + + private static final String FIXTURE_PATH = "blue-language-1.0/fixtures"; + + @TestFactory + Stream blueLanguage10Fixtures() { + BlueConformanceReport report = new Blue().runConformanceSuite(); + Map failuresById = report.getFailures().stream() + .collect(Collectors.toMap(BlueConformanceFailure::getFixtureId, Function.identity())); + + return report.getFixtureIds().stream() + .map(id -> DynamicTest.dynamicTest(id, () -> { + BlueConformanceFailure failure = failuresById.get(id); + if (failure != null) { + fail(failureMessage(failure)); + } + assertTrue(report.getPassedFixtureIds().contains(id), "Fixture did not run: " + id); + })); + } + + @Test + void fixtureWithoutExpectedOutputFailsMetadataValidation() { + JsonNode spec = YAML_MAPPER.readTree( + "id: B_missing_expected\n" + + "category: BlueId\n" + + "operation: calculateBlueId\n" + + "input: 1\n"); + + assertThrows(IllegalArgumentException.class, + () -> BlueConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void fixtureOperationCalculateBlueIdAllowingCyclicPlaceholdersIsRejected() { + JsonNode spec = YAML_MAPPER.readTree( + "id: C_placeholder_helper\n" + + "category: Circular\n" + + "operation: calculateBlueIdAllowingCyclicPlaceholders\n" + + "input:\n" + + " blueId: this#0\n" + + "expectedNodeBlueId: placeholder\n"); + + assertThrows(IllegalArgumentException.class, + () -> BlueConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void fixtureTopLevelProfileFieldFails() { + JsonNode spec = YAML_MAPPER.readTree( + "id: B_profile_metadata\n" + + "profile: BlueId\n" + + "category: BlueId\n" + + "operation: calculateBlueId\n" + + "input: 1\n" + + "expectedNodeBlueId: placeholder\n"); + + assertThrows(IllegalArgumentException.class, + () -> BlueConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void fixtureInputMayContainOrdinaryProfileField() { + JsonNode spec = YAML_MAPPER.readTree( + "id: B_profile_data\n" + + "category: BlueId\n" + + "operation: calculateBlueId\n" + + "input:\n" + + " profile: user\n" + + "expectedNodeBlueId: placeholder\n"); + + assertDoesNotThrow(() -> BlueConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void fixtureExpectedOutputMayContainOrdinaryProfileField() { + JsonNode spec = YAML_MAPPER.readTree( + "id: R_profile_expected\n" + + "category: Resolution\n" + + "operation: preprocess\n" + + "source:\n" + + " profile: user\n" + + "expectedPreprocessed:\n" + + " profile: user\n"); + + assertDoesNotThrow(() -> BlueConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void fixtureProviderNodeMayContainOrdinaryProfileField() { + JsonNode spec = YAML_MAPPER.readTree( + "id: F_profile_provider\n" + + "category: Provider\n" + + "operation: calculateBlueId\n" + + "provider:\n" + + " - requestedBlueId: placeholder\n" + + " node:\n" + + " profile: user\n" + + "input: 1\n" + + "expectedNodeBlueId: placeholder\n"); + + assertDoesNotThrow(() -> BlueConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void conformanceManifestIsAuthoritative() throws Exception { + URL resource = getClass().getClassLoader().getResource(FIXTURE_PATH); + assertTrue(resource != null); + Path fixtureRoot = Paths.get(resource.toURI()); + JsonNode manifest = YAML_MAPPER.readTree(new String(Files.readAllBytes(fixtureRoot.resolve("manifest.yaml")))); + JsonNode manifestFixtures = manifest.get("fixtures"); + assertTrue(manifestFixtures != null && manifestFixtures.isArray()); + + Set fixtureIds = new LinkedHashSet<>(); + Set listedPaths = new HashSet<>(); + for (JsonNode entry : manifestFixtures) { + assertTrue(entry.hasNonNull("id")); + assertTrue(entry.hasNonNull("category")); + assertTrue(entry.hasNonNull("path")); + String id = entry.get("id").asText(); + assertTrue(fixtureIds.add(id), "Duplicate fixture id in manifest: " + id); + BlueFixtureCategory manifestCategory = BlueFixtureCategory.fromLabel(entry.get("category").asText()); + Path fixturePath = fixtureRoot.resolve(entry.get("path").asText()).normalize(); + assertTrue(Files.isRegularFile(fixturePath), "Missing fixture file: " + fixturePath); + listedPaths.add(fixturePath.toAbsolutePath().normalize()); + + JsonNode fixture = YAML_MAPPER.readTree(new String(Files.readAllBytes(fixturePath))); + assertFalse(fixture.has("profile"), "Fixture metadata must use category, not profile: " + fixturePath); + assertEquals(id, requireNonNull(fixture, "id").asText(), "Fixture id mismatch: " + fixturePath); + assertEquals(manifestCategory, + BlueFixtureCategory.fromLabel(requireNonNull(fixture, "category").asText()), + "Fixture category mismatch: " + fixturePath); + assertTrue(BlueConformanceSuiteRunner.knownOperations().contains(requireNonNull(fixture, "operation").asText()), + "Unknown fixture operation in " + fixturePath); + BlueConformanceSuiteRunner.validateFixtureMetadataForTest(fixture); + } + + assertEquals(BlueConformanceReport.requiredFixtureIdsForBlueLanguage10(), fixtureIds); + assertEquals(listedPaths, fixtureYamlFiles(fixtureRoot)); + } + + private Set fixtureYamlFiles(Path fixtureRoot) throws Exception { + try (Stream paths = Files.walk(fixtureRoot)) { + return paths + .filter(Files::isRegularFile) + .filter(path -> { + String name = path.getFileName().toString(); + return (name.endsWith(".yaml") || name.endsWith(".yml")) + && !"manifest.yaml".equals(name) + && !"manifest.yml".equals(name); + }) + .sorted(Comparator.comparing(Path::toString)) + .map(path -> path.toAbsolutePath().normalize()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + } + + private 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 String failureMessage(BlueConformanceFailure failure) { + return "Fixture " + failure.getFixtureId() + + " (" + failure.getCategory() + + ", operation=" + failure.getOperation() + + ") failed with " + failure.getExceptionClass() + + ": " + failure.getMessage(); + } +} diff --git a/src/test/java/blue/language/mapping/JsonPropertyMappingTest.java b/src/test/java/blue/language/mapping/JsonPropertyMappingTest.java index a793d0a..9b5a0d3 100644 --- a/src/test/java/blue/language/mapping/JsonPropertyMappingTest.java +++ b/src/test/java/blue/language/mapping/JsonPropertyMappingTest.java @@ -101,7 +101,7 @@ void blueIdAnnotationCalculatesHashFromJsonPropertyBackedField() { JsonPropertyBlueIdMetadata converted = blue.nodeToObject(node, JsonPropertyBlueIdMetadata.class); - assertEquals(BlueIdCalculator.calculateBlueId(target), converted.packageBlueId); + assertEquals(BlueIdCalculator.calculateUncheckedBlueId(target), converted.packageBlueId); } @Test diff --git a/src/test/java/blue/language/mapping/NodeToObjectConverterNullHandlingTest.java b/src/test/java/blue/language/mapping/NodeToObjectConverterNullHandlingTest.java index 9fc20af..f16a4e1 100644 --- a/src/test/java/blue/language/mapping/NodeToObjectConverterNullHandlingTest.java +++ b/src/test/java/blue/language/mapping/NodeToObjectConverterNullHandlingTest.java @@ -40,28 +40,16 @@ public void testNullHandling() throws Exception { "x1SetField: null\n" + "x2MapField: null\n" + "xArrayField: null\n" + - "wildcardXListField: null\n" + - "name: null\n" + - "description: null"; + "wildcardXListField: null"; Node node = blue.yamlToNode(yaml); Y y = converter.convert(node, Y.class); assertNotNull(y); - // Check X field - assertNotNull(y.xField); - assertEquals(0, y.xField.intField); - assertNull(y.xField.stringField); - - assertNotNull(y.x1Field); - assertNull(y.x1Field.intArrayField); - assertNull(y.x1Field.stringListField); - assertNull(y.x1Field.integerSetField); - - // Check X2 field - assertNotNull(y.x2Field); - assertNull(y.x2Field.stringIntMapField); + assertNull(y.xField); + assertNull(y.x1Field); + assertNull(y.x2Field); // Check other fields assertNull(y.xListField); @@ -71,7 +59,6 @@ public void testNullHandling() throws Exception { assertNull(y.xArrayField); assertNull(y.wildcardXListField); - // Check name and description assertNull(node.getName()); assertNull(node.getDescription()); } @@ -91,8 +78,7 @@ public void testPartialNullHandling() throws Exception { " stringIntMapField:\n" + " key1: 100\n" + " key2: null\n" + - "name: \"Test Y\"\n" + - "description: null"; + "name: \"Test Y\""; Node node = blue.yamlToNode(yaml); Y y = converter.convert(node, Y.class); @@ -119,7 +105,6 @@ public void testPartialNullHandling() throws Exception { assertEquals(100, y.x2Field.stringIntMapField.get("key1")); assertNull(y.x2Field.stringIntMapField.get("key2")); - // Check name and description assertEquals("Test Y", node.getName()); assertNull(node.getDescription()); } @@ -159,4 +144,4 @@ public void testEmptyCollectionsAndMaps() throws Exception { assertTrue(y.x1SetField.isEmpty()); assertNull(y.x2MapField); } -} \ No newline at end of file +} diff --git a/src/test/java/blue/language/mapping/NodeToObjectConverterTest.java b/src/test/java/blue/language/mapping/NodeToObjectConverterTest.java index 378e2e2..5bdb980 100644 --- a/src/test/java/blue/language/mapping/NodeToObjectConverterTest.java +++ b/src/test/java/blue/language/mapping/NodeToObjectConverterTest.java @@ -38,8 +38,14 @@ public void testXConversion() throws Exception { "shortObjectField: -32768\n" + "intField: 2147483647\n" + "integerField: -2147483648\n" + - "longField: 9223372036854775807\n" + - "longObjectField: -9223372036854775808\n" + + "longField:\n" + + " type:\n" + + " blueId: " + INTEGER_TYPE_BLUE_ID + "\n" + + " value: \"9223372036854775807\"\n" + + "longObjectField:\n" + + " type:\n" + + " blueId: " + INTEGER_TYPE_BLUE_ID + "\n" + + " value: \"-9223372036854775808\"\n" + "floatField: 3.14\n" + "floatObjectField: -3.14\n" + "doubleField: 3.141592653589793\n" + @@ -448,7 +454,7 @@ public void testObjectVariants() throws Exception { assertNotNull(data); assertNotNull(data.alice1); - assertTrue(data.alice1.matches(BlueIdCalculator.calculateBlueId(data.alice2))); + assertTrue(data.alice1.matches(BlueIdCalculator.calculateUncheckedBlueId(data.alice2))); assertNotNull(data.alice2); assertEquals("Alice", data.alice2.getName()); diff --git a/src/test/java/blue/language/processor/ContractMappingIntegrationTest.java b/src/test/java/blue/language/processor/ContractMappingIntegrationTest.java index 8a1368b..870b818 100644 --- a/src/test/java/blue/language/processor/ContractMappingIntegrationTest.java +++ b/src/test/java/blue/language/processor/ContractMappingIntegrationTest.java @@ -39,7 +39,7 @@ void loadsAllContractsFromBlueYaml() throws Exception { Blue blue = new Blue(); Node document = blue.yamlToNode(yaml); assertNotNull(document); - Node contractsNode = document.getProperties().get("contracts"); + Node contractsNode = document.getContracts(); assertNotNull(contractsNode, "contracts node should be present"); Map contractEntries = contractsNode.getProperties(); @@ -104,7 +104,7 @@ void contractLoaderLoadsBundleFromResolvedSnapshotWithoutScopeNodeTraversal() th Blue blue = new Blue(); Node document = blue.yamlToNode(yaml); - FrozenNode canonicalRoot = FrozenNode.fromNode(document); + FrozenNode canonicalRoot = FrozenNode.fromUncheckedCanonicalNode(document); ResolvedSnapshot snapshot = new ResolvedSnapshot(canonicalRoot, FrozenNode.fromResolvedNode(document), canonicalRoot.blueId()); @@ -133,4 +133,31 @@ void contractLoaderLoadsBundleFromResolvedSnapshotWithoutScopeNodeTraversal() th assertEquals(7, setProperty.getPropertyValue()); assertEquals("/custom/path/", setProperty.getPath()); } + + @Test + void processorContractLoaderStillFindsContracts() { + Node document = new Blue().yamlToNode( + "contracts:\n" + + " lifecycleChannel:\n" + + " type:\n" + + " blueId: LifecycleChannel\n" + + " setProperty:\n" + + " channel: lifecycleChannel\n" + + " type:\n" + + " blueId: SetProperty\n" + + " propertyKey: /x\n" + + " propertyValue: 7\n"); + ContractProcessorRegistry registry = ContractProcessorRegistryBuilder.create() + .register(new SetPropertyContractProcessor()) + .build(); + TypeClassResolver resolver = new TypeClassResolver("blue.language.processor.model"); + ContractLoader loader = new ContractLoader(registry, + new NodeToObjectConverter(resolver), + resolver); + + ContractBundle bundle = loader.load(FrozenNode.fromResolvedNode(document), "/"); + + assertNotNull(bundle.contractNode("setProperty")); + assertTrue(bundle.contractNodes().containsKey("setProperty")); + } } diff --git a/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java b/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java index f7fbd2c..1977101 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java @@ -46,8 +46,7 @@ void deniesPatchingOutsideScope() { assertTrue(execution.runtime().isScopeTerminated("/foo")); Node foo = resultDoc.getAsNode("/foo"); Map fooProps = foo.getProperties(); - assertNotNull(fooProps); - assertFalse(fooProps.containsKey("bar")); + assertFalse(fooProps != null && fooProps.containsKey("bar")); } @Test @@ -72,8 +71,7 @@ void parentCannotModifyEmbeddedChildInterior() { assertTrue(execution.runtime().isScopeTerminated("/foo")); Node foo = resultDoc.getAsNode("/foo"); Map fooProps = foo.getProperties(); - assertNotNull(fooProps); - assertFalse(fooProps.containsKey("child")); + assertFalse(fooProps != null && fooProps.containsKey("child")); } @Test @@ -200,7 +198,7 @@ void reservedContractsWithinScopeAreWriteProtected() { assertTrue(execution.runtime().isScopeTerminated("/foo")); Node fooNode = resultDoc.getProperties().get("foo"); assertNotNull(fooNode); - assertTrue(fooNode.getProperties().containsKey("contracts")); + assertTrue(fooNode.getContracts() != null); } private Node getProperty(Node node, String key) { diff --git a/src/test/java/blue/language/processor/DocumentProcessorCapabilityTest.java b/src/test/java/blue/language/processor/DocumentProcessorCapabilityTest.java index 3e4cd34..a535315 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorCapabilityTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorCapabilityTest.java @@ -62,14 +62,7 @@ void initializeDocumentFailsWithCapabilityFailureWhenContractsIsNotObjectMap() { " - bad\n"; Blue blue = new Blue(); - Node document = blue.yamlToNode(yaml); - - DocumentProcessingResult result = blue.initializeDocument(document); - - assertTrue(result.capabilityFailure()); - assertEquals(0L, result.totalGas()); - assertTrue(result.triggeredEvents().isEmpty()); - assertTrue(result.failureReason().contains("Contracts must be an object map")); + assertThrows(RuntimeException.class, () -> blue.yamlToNode(yaml)); } @Test @@ -90,7 +83,7 @@ void processDocumentFailsWithCapabilityFailureWhenNewUnsupportedContractAppears( " propertyValue: 1\n"; Node initialized = blue.initializeDocument(blue.yamlToNode(baseYaml)).document().clone(); - Node contracts = initialized.getProperties().get("contracts"); + Node contracts = initialized.getContracts(); assertNotNull(contracts); TerminateScope scope = new TerminateScope(); @@ -108,7 +101,7 @@ void processDocumentFailsWithCapabilityFailureWhenNewUnsupportedContractAppears( assertTrue(result.triggeredEvents().isEmpty()); Node resultDoc = result.document(); assertNotNull(resultDoc); - Node resultContracts = resultDoc.getProperties().get("contracts"); + Node resultContracts = resultDoc.getContracts(); assertNotNull(resultContracts); assertNotNull(resultContracts.getProperties().get("unsupportedHandler")); assertNotNull(result.failureReason()); @@ -132,7 +125,7 @@ void processDocumentFailsWithCapabilityFailureWhenNewTypelessContractAppears() { " propertyValue: 1\n"; Node initialized = blue.initializeDocument(blue.yamlToNode(baseYaml)).document().clone(); - Node contracts = initialized.getProperties().get("contracts"); + Node contracts = initialized.getContracts(); assertNotNull(contracts); contracts.properties("unclear", new Node().properties("property", new Node().value("value"))); diff --git a/src/test/java/blue/language/processor/DocumentProcessorGasTest.java b/src/test/java/blue/language/processor/DocumentProcessorGasTest.java index 9495da3..158f09f 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorGasTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorGasTest.java @@ -367,7 +367,7 @@ void processDocumentResultExposesCanonicalSnapshotBlueIdAndResolvedView() { assertProcessedAccount(result, types); assertNotNull(result.snapshot()); assertEquals(result.snapshot().blueId(), result.blueId()); - assertEquals(BlueIdCalculator.calculateBlueId(result.canonicalDocument()), result.blueId()); + assertEquals(BlueIdCalculator.calculateUncheckedBlueId(result.canonicalDocument()), result.blueId()); assertEquals(1, result.canonicalDocument().getAsInteger("/balance/cents")); assertEquals(1, result.resolvedDocument().getAsInteger("/balance/cents")); assertNullNode(result.canonicalDocument(), "/balance/currency"); @@ -387,7 +387,7 @@ void initializeDocumentResultExposesCanonicalSnapshotBlueIdAndResolvedView() { assertInitializedAccount(result, types); assertNotNull(result.snapshot()); assertEquals(result.snapshot().blueId(), result.blueId()); - assertEquals(BlueIdCalculator.calculateBlueId(result.canonicalDocument()), result.blueId()); + assertEquals(BlueIdCalculator.calculateUncheckedBlueId(result.canonicalDocument()), result.blueId()); assertEquals(0, result.canonicalDocument().getAsInteger("/balance/cents")); assertEquals(0, result.resolvedDocument().getAsInteger("/balance/cents")); assertNullNode(result.canonicalDocument(), "/balance/currency"); @@ -418,9 +418,7 @@ void capabilityFailureResultDoesNotBuildSnapshotOrSpendGasOnResolution() { } private Node extractInitializedMarker(Node document) { - Map contracts = document.getProperties(); - assertNotNull(contracts); - Node contractsNode = contracts.get("contracts"); + Node contractsNode = document.getContracts(); assertNotNull(contractsNode); return contractsNode.getProperties().get("initialized"); } @@ -432,7 +430,7 @@ private Node extractProperty(Node document, String key) { } private Node extractEmitterEventTemplate(Node document) { - Node contracts = document.getProperties().get("contracts"); + Node contracts = document.getContracts(); assertNotNull(contracts); Node emitter = contracts.getProperties().get("emitter"); assertNotNull(emitter); diff --git a/src/test/java/blue/language/processor/DocumentProcessorGeneralizationTest.java b/src/test/java/blue/language/processor/DocumentProcessorGeneralizationTest.java index 9942413..a59db90 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorGeneralizationTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorGeneralizationTest.java @@ -7,6 +7,7 @@ import blue.language.processor.model.JsonPatch; import blue.language.provider.BasicNodeProvider; import blue.language.utils.BlueIdCalculator; +import blue.language.utils.NodeToBlueIdInput; import blue.language.utils.Properties; import org.junit.jupiter.api.Test; @@ -539,11 +540,15 @@ private List applySequential(Node } private void assertEquivalentDocuments(Node expected, Node actual, String label) { - assertEquals(BlueIdCalculator.calculateBlueId(expected), - BlueIdCalculator.calculateBlueId(actual), + assertEquals(runtimeDocumentBlueId(expected), + runtimeDocumentBlueId(actual), label); } + private String runtimeDocumentBlueId(Node node) { + return BlueIdCalculator.INSTANCE.calculate(NodeToBlueIdInput.getWithResolvedBlueIdMetadata(node)); + } + private List updatePaths(List updates) { List paths = new ArrayList<>(); for (DocumentProcessingRuntime.DocumentUpdateData update : updates) { diff --git a/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java b/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java index f478a0d..7b4df9e 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java @@ -4,6 +4,7 @@ import blue.language.model.Node; import blue.language.processor.contracts.RemovePropertyContractProcessor; import blue.language.processor.contracts.SetPropertyContractProcessor; +import blue.language.utils.BlueIdCalculator; import org.junit.jupiter.api.Test; import java.math.BigInteger; @@ -43,7 +44,7 @@ void initializesDocumentAndExecutesHandlersInOrder() { Blue blue = new Blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node original = blue.yamlToNode(yaml); - String expectedDocumentId = blue.calculateBlueId(original.clone()); + String expectedDocumentId = BlueIdCalculator.calculateUncheckedBlueId(original.clone()); assertFalse(blue.isInitialized(original)); @@ -70,7 +71,7 @@ void initializesDocumentAndExecutesHandlersInOrder() { assertNotNull(xNode, "x should be present after initialization"); assertEquals(new BigInteger("10"), xNode.getValue()); - Node contractsNode = initializedProps.get("contracts"); + Node contractsNode = initialized.getContracts(); assertNotNull(contractsNode); Node initializedNode = contractsNode.getProperties().get("initialized"); assertNotNull(initializedNode, "Initialization marker should be present"); @@ -92,7 +93,7 @@ void initializesDocumentAndExecutesHandlersInOrder() { assertTrue(processResult.triggeredEvents().isEmpty()); - assertNull(original.getProperties().get("x")); + assertNull(original.getProperties() != null ? original.getProperties().get("x") : null); } @Test @@ -263,7 +264,7 @@ void removePatchDeletesPropertyDuringInitialization() { DocumentProcessingResult result = blue.initializeDocument(original); Node processed = result.document(); - assertFalse(processed.getProperties().containsKey("x")); + assertFalse(processed.getProperties() != null && processed.getProperties().containsKey("x")); assertTrue(result.triggeredEvents().stream() .anyMatch(node -> { Map props = node.getProperties(); diff --git a/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java b/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java index 255eabe..671622f 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java @@ -376,7 +376,7 @@ void snapshotNativeProcessingDoesNotBuildInitialSnapshotFromDocument() { " blueId: SetProperty\n" + " propertyKey: /x\n" + " propertyValue: 9\n", Node.class); - FrozenNode canonical = FrozenNode.fromNode(initialized); + FrozenNode canonical = FrozenNode.fromUncheckedCanonicalNode(initialized); ResolvedSnapshot snapshot = new ResolvedSnapshot(canonical, FrozenNode.fromResolvedNode(initialized), canonical.blueId()); @@ -410,7 +410,7 @@ void blueSnapshotNativeProcessingMatchesNodeBasedGasAndResult() { DocumentProcessor snapshotProcessor = new DocumentProcessor(null, new CountingSnapshotManager()) .registerContractProcessor(new TestEventChannelProcessor()) .registerContractProcessor(new SetPropertyContractProcessor()); - FrozenNode canonical = FrozenNode.fromNode(document); + FrozenNode canonical = FrozenNode.fromUncheckedCanonicalNode(document); ResolvedSnapshot inputSnapshot = new ResolvedSnapshot(canonical, FrozenNode.fromResolvedNode(document), canonical.blueId()); @@ -512,7 +512,7 @@ void processorPatchToInheritedValueKeepsCanonicalOverrideWhileBatchMinimizationI @Test void contractLoadingUsesSnapshotResolvedViewForInheritedContracts() { BasicNodeProvider provider = new BasicNodeProvider(); - provider.addSingleDocs( + provider.addSingleDocsUnchecked( "name: Event Driven Type\n" + "contracts:\n" + " testChannel:\n" + @@ -547,7 +547,7 @@ private static void assertMissing(Node node, String path) { } private static void assertSnapshotConsistent(ResolvedSnapshot snapshot) { - assertEquals(BlueIdCalculator.calculateBlueId(snapshot.canonicalRoot()), snapshot.blueId()); + assertEquals(BlueIdCalculator.calculateUncheckedBlueId(snapshot.canonicalRoot()), snapshot.blueId()); } private static final class CountingSnapshotManager implements ProcessingSnapshotManager { @@ -591,7 +591,7 @@ public ResolvedSnapshot fromDocument(Node document) { } Node canonicalSource = canonical != null ? canonical.clone() : document.clone(); Node resolvedSource = resolved != null ? resolved.clone() : document.clone(); - FrozenNode canonicalRoot = FrozenNode.fromNode(canonicalSource); + FrozenNode canonicalRoot = FrozenNode.fromUncheckedCanonicalNode(canonicalSource); return new ResolvedSnapshot(canonicalRoot, FrozenNode.fromResolvedNode(resolvedSource), canonicalRoot.blueId()); diff --git a/src/test/java/blue/language/processor/DocumentProcessorTerminationTest.java b/src/test/java/blue/language/processor/DocumentProcessorTerminationTest.java index 87ac5e5..bcdf4f7 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorTerminationTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorTerminationTest.java @@ -47,12 +47,13 @@ void rootGracefulTerminationStopsFurtherWork() { DocumentProcessingResult result = blue.processDocument(initialized, event); Node processed = result.document(); - Node contracts = processed.getProperties().get("contracts"); + Node contracts = processed.getContracts(); assertNotNull(contracts); Node terminated = contracts.getProperties().get("terminated"); assertNotNull(terminated); assertEquals("graceful", terminated.getProperties().get("cause").getValue()); - assertNull(processed.getProperties().get("afterTermination"), "patch after termination must be ignored"); + assertNull(processed.getProperties() != null ? processed.getProperties().get("afterTermination") : null, + "patch after termination must be ignored"); List triggeredEvents = result.triggeredEvents(); assertEquals(1, triggeredEvents.size(), "Only the terminated lifecycle event should be present"); @@ -128,8 +129,7 @@ void childTerminationBridgesToParent() { assertNotNull(fromChild, "Parent should capture bridged termination event"); assertEquals(new BigInteger("7"), fromChild.getValue()); - Node childContracts = processed.getProperties().get("child").getProperties() - .get("contracts"); + Node childContracts = processed.getProperties().get("child").getContracts(); assertNotNull(childContracts); Node childTerminated = childContracts.getProperties().get("terminated"); assertNotNull(childTerminated); diff --git a/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java b/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java index 7a65ad2..06ab6eb 100644 --- a/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java +++ b/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java @@ -215,7 +215,7 @@ void cascadedUpdatesPropagateThroughEmbeddedScopes() { assertNull(originalX.getProperties().get("a")); Node originalY = originalX.getProperties().get("y"); assertNotNull(originalY); - assertNull(originalY.getProperties().get("a")); + assertNull(originalY.getProperties() != null ? originalY.getProperties().get("a") : null); } @Test diff --git a/src/test/java/blue/language/processor/ProcessEmbeddedTest.java b/src/test/java/blue/language/processor/ProcessEmbeddedTest.java index 8a37009..817be72 100644 --- a/src/test/java/blue/language/processor/ProcessEmbeddedTest.java +++ b/src/test/java/blue/language/processor/ProcessEmbeddedTest.java @@ -10,6 +10,7 @@ import blue.language.processor.contracts.SetPropertyOnEventContractProcessor; import blue.language.processor.contracts.TestEventChannelProcessor; import blue.language.processor.model.TestEvent; +import blue.language.utils.BlueIdCalculator; import org.junit.jupiter.api.Test; import java.math.BigInteger; @@ -52,16 +53,16 @@ void initializesEmbeddedChildDocument() { Blue blue = new Blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node original = blue.yamlToNode(yaml); - String rootId = blue.calculateBlueId(original.clone()); + String rootId = BlueIdCalculator.calculateUncheckedBlueId(original.clone()); Node originalChildNode = original.getProperties().get("x"); - String childId = blue.calculateBlueId(originalChildNode.clone()); + String childId = BlueIdCalculator.calculateUncheckedBlueId(originalChildNode.clone()); DocumentProcessingResult result = blue.initializeDocument(original); Node initialized = result.document(); Node child = initialized.getProperties().get("x"); assertNotNull(child, "Embedded child should remain present"); - Node childContracts = child.getProperties().get("contracts"); + Node childContracts = child.getContracts(); assertNotNull(childContracts, "Child contracts map should exist"); assertTrue(childContracts.getProperties().containsKey("initialized"), "Child scope must record Initialization Marker"); @@ -72,7 +73,7 @@ void initializesEmbeddedChildDocument() { assertEquals(new BigInteger("1"), child.getProperties().get("a").getValue(), "Child property /x/a should be set by embedded handler"); - Node rootContracts = initialized.getProperties().get("contracts"); + Node rootContracts = initialized.getContracts(); assertNotNull(rootContracts, "Root contracts map should exist"); assertTrue(rootContracts.getProperties().containsKey("initialized"), "Root scope must record Initialization Marker"); @@ -262,19 +263,19 @@ void nestedEmbeddedScopesEnforceBoundaries() { Node xNode = initialized.getProperties().get("x"); assertNotNull(xNode); - Node xContracts = xNode.getProperties().get("contracts"); + Node xContracts = xNode.getContracts(); assertNotNull(xContracts); assertTrue(xContracts.getProperties().containsKey("initialized")); Node yNode = xNode.getProperties().get("y"); assertNotNull(yNode); - Node yContracts = yNode.getProperties().get("contracts"); + Node yContracts = yNode.getContracts(); assertNotNull(yContracts); assertTrue(yContracts.getProperties().containsKey("initialized")); assertEquals(new BigInteger("1"), yNode.getProperties().get("a").getValue()); Node originalY = nested.getProperties().get("x").getProperties().get("y"); - assertNull(originalY.getProperties().get("a")); + assertNull(originalY.getProperties() != null ? originalY.getProperties().get("a") : null); Node rootViolation = blue.yamlToNode(rootViolationYaml); DocumentProcessingResult rootResult = blue.initializeDocument(rootViolation); @@ -470,7 +471,7 @@ void embeddedListUpdatesProcessNewChildDuringExternalEvent() { DocumentProcessingResult initResult = blue.initializeDocument(original); Node initialized = initResult.document(); - Node initialContracts = initialized.getProperties().get("contracts"); + Node initialContracts = initialized.getContracts(); Node initialEmbedded = initialContracts.getProperties().get("embedded"); Node initialPaths = initialEmbedded.getProperties().get("paths"); assertNotNull(initialPaths); @@ -557,9 +558,11 @@ void removingEmbeddedChildCutsOffFurtherWorkWithinRun() { DocumentProcessingResult result = blue.processDocument(initialized, event); Node processed = result.document(); - assertNull(processed.getProperties().get("child"), "Child scope should remain removed after cut-off"); + assertNull(processed.getProperties() != null ? processed.getProperties().get("child") : null, + "Child scope should remain removed after cut-off"); - assertNull(processed.getProperties().get("postSeen"), "No post-cut-off emission should be bridged"); + assertNull(processed.getProperties() != null ? processed.getProperties().get("postSeen") : null, + "No post-cut-off emission should be bridged"); boolean postEmissionRecorded = result.triggeredEvents().stream() .map(Node::getProperties) diff --git a/src/test/java/blue/language/processor/TestEventChannelTest.java b/src/test/java/blue/language/processor/TestEventChannelTest.java index 1a7010c..bd34853 100644 --- a/src/test/java/blue/language/processor/TestEventChannelTest.java +++ b/src/test/java/blue/language/processor/TestEventChannelTest.java @@ -40,12 +40,12 @@ void testEventChannelMatchesOnlyTestEvents() { DocumentProcessingResult initResult = blue.initializeDocument(document); Node initialized = initResult.document(); - assertNull(initialized.getProperties().get("x")); + assertNull(initialized.getProperties() != null ? initialized.getProperties().get("x") : null); Node randomEvent = blue.yamlToNode("type:\n blueId: RandomEvent\n"); DocumentProcessingResult randomResult = blue.processDocument(initialized, randomEvent); Node afterRandom = randomResult.document(); - assertNull(afterRandom.getProperties().get("x")); + assertNull(afterRandom.getProperties() != null ? afterRandom.getProperties().get("x") : null); Node testEvent = blue.objectToNode(new TestEvent().x(5).y(10)); DocumentProcessingResult testResult = blue.processDocument(afterRandom, testEvent); @@ -180,7 +180,7 @@ void checkpointSkipsStaleEvents() { } private String checkpointValue(Node document) { - Node contracts = document.getProperties().get("contracts"); + Node contracts = document.getContracts(); Node checkpoint = contracts.getProperties().get("checkpoint"); if (checkpoint == null) { return null; @@ -247,7 +247,7 @@ void checkpointStoresFullEventAndComparesPayload() { } private Node checkpointStoredEvent(Node document) { - Node contracts = document.getProperties().get("contracts"); + Node contracts = document.getContracts(); Node checkpoint = contracts.getProperties().get("checkpoint"); if (checkpoint == null) { return null; diff --git a/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java b/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java index a6ee4b9..1325fe8 100644 --- a/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java +++ b/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java @@ -91,7 +91,7 @@ void unknownExternalContractTypeProducesCapabilityFailureWithoutMutation() { assertTrue(result.capabilityFailure()); assertTrue(result.failureReason().contains(UNKNOWN_BLUE_ID)); - assertFalse(result.document().getProperties().get("contracts").getProperties().containsKey("initialized")); + assertFalse(result.document().getContracts().getProperties().containsKey("initialized")); assertEquals(new BigInteger("0"), result.document().get("/counter")); } diff --git a/src/test/java/blue/language/provider/BootstrapProviderVerificationTest.java b/src/test/java/blue/language/provider/BootstrapProviderVerificationTest.java new file mode 100644 index 0000000..3377692 --- /dev/null +++ b/src/test/java/blue/language/provider/BootstrapProviderVerificationTest.java @@ -0,0 +1,105 @@ +package blue.language.provider; + +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.preprocess.Preprocessor; +import blue.language.utils.BlueIdCalculator; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.util.List; + +import static blue.language.utils.Properties.BOOLEAN_TYPE_BLUE_ID; +import static blue.language.utils.Properties.CORE_TYPE_BLUE_ID_TO_NAME_MAP; +import static blue.language.utils.Properties.CORE_TYPE_NAME_TO_BLUE_ID_MAP; +import static blue.language.utils.Properties.DICTIONARY_TYPE_BLUE_ID; +import static blue.language.utils.Properties.DOUBLE_TYPE_BLUE_ID; +import static blue.language.utils.Properties.INTEGER_TYPE_BLUE_ID; +import static blue.language.utils.Properties.LIST_TYPE_BLUE_ID; +import static blue.language.utils.Properties.TEXT_TYPE_BLUE_ID; +import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class BootstrapProviderVerificationTest { + + @Test + void coreAliasMapMatchesRegistryBlueIds() { + assertEquals(TEXT_TYPE_BLUE_ID, CORE_TYPE_NAME_TO_BLUE_ID_MAP.get("Text")); + assertEquals(DOUBLE_TYPE_BLUE_ID, CORE_TYPE_NAME_TO_BLUE_ID_MAP.get("Double")); + assertEquals(INTEGER_TYPE_BLUE_ID, CORE_TYPE_NAME_TO_BLUE_ID_MAP.get("Integer")); + assertEquals(BOOLEAN_TYPE_BLUE_ID, CORE_TYPE_NAME_TO_BLUE_ID_MAP.get("Boolean")); + assertEquals(LIST_TYPE_BLUE_ID, CORE_TYPE_NAME_TO_BLUE_ID_MAP.get("List")); + assertEquals(DICTIONARY_TYPE_BLUE_ID, CORE_TYPE_NAME_TO_BLUE_ID_MAP.get("Dictionary")); + + CORE_TYPE_NAME_TO_BLUE_ID_MAP.forEach((name, blueId) -> + assertEquals(name, CORE_TYPE_BLUE_ID_TO_NAME_MAP.get(blueId))); + assertEquals(CORE_TYPE_NAME_TO_BLUE_ID_MAP, new Blue().conformanceReport().getCoreRegistryBlueIds()); + } + + @Test + void defaultBlueTransformBlueIdsMatchResources() throws Exception { + Node defaultBlue = readResource("transformation/DefaultBlue.blue"); + Node transformation = readResource("transformation/Transformation.blue"); + Node replaceInlineTypes = readResource("transformation/ReplaceInlineTypesWithBlueIds.blue"); + Node inferBasicTypes = readResource("transformation/InferBasicTypesForUntypedValues.blue"); + + String transformationBlueId = BlueIdCalculator.calculateBlueId(transformation); + String replaceInlineTypesBlueId = BlueIdCalculator.calculateBlueId(replaceInlineTypes); + String inferBasicTypesBlueId = BlueIdCalculator.calculateBlueId(inferBasicTypes); + + assertEquals(transformationBlueId, replaceInlineTypes.getType().getBlueId()); + assertEquals(transformationBlueId, inferBasicTypes.getType().getBlueId()); + assertEquals(replaceInlineTypesBlueId, defaultBlue.getItems().get(0).getType().getBlueId()); + assertEquals(inferBasicTypesBlueId, defaultBlue.getItems().get(1).getType().getBlueId()); + assertEquals(BlueIdCalculator.calculateBlueId(defaultBlue.getItems()), Preprocessor.DEFAULT_BLUE_BLUE_ID); + } + + @Test + void bootstrapProviderContentHashesToAdvertisedBlueIds() throws Exception { + for (String resource : new String[]{ + "transformation/Transformation.blue", + "transformation/ReplaceInlineTypesWithBlueIds.blue", + "transformation/InferBasicTypesForUntypedValues.blue"}) { + Node advertised = readResource(resource); + String blueId = BlueIdCalculator.calculateBlueId(advertised); + List fetched = BootstrapProvider.INSTANCE.fetchByBlueId(blueId); + + assertNotNull(fetched, "Bootstrap provider returned null for " + resource); + assertFalse(fetched.isEmpty(), "Bootstrap provider returned no content for " + resource); + assertEquals(blueId, BlueIdCalculator.calculateBlueId(withoutRootIdentity(fetched.get(0))), resource); + } + } + + @Test + void allDefaultBlueTransformsAreFetchableAndVerifiedByBlueId() throws Exception { + Node defaultBlue = readResource("transformation/DefaultBlue.blue"); + + for (Node transformationReference : defaultBlue.getItems()) { + String blueId = transformationReference.getType().getBlueId(); + List fetched = BootstrapProvider.INSTANCE.fetchByBlueId(blueId); + + assertNotNull(fetched, "Bootstrap provider returned null for DefaultBlue transform " + blueId); + assertFalse(fetched.isEmpty(), "Bootstrap provider returned no transform content for " + blueId); + assertEquals(blueId, BlueIdCalculator.calculateBlueId(withoutRootIdentity(fetched.get(0)))); + } + } + + private Node withoutRootIdentity(Node node) { + Node canonical = node.clone(); + if (canonical.getBlueId() != null && !canonical.isReferenceOnly()) { + canonical.blueId(null); + } + return canonical; + } + + private Node readResource(String resource) throws Exception { + try (InputStream stream = getClass().getClassLoader().getResourceAsStream(resource)) { + if (stream == null) { + throw new IllegalArgumentException("Missing resource: " + resource); + } + return YAML_MAPPER.readValue(stream, Node.class); + } + } +} diff --git a/src/test/java/blue/language/provider/ProviderCanonicalIngestionTest.java b/src/test/java/blue/language/provider/ProviderCanonicalIngestionTest.java index e801639..41379ab 100644 --- a/src/test/java/blue/language/provider/ProviderCanonicalIngestionTest.java +++ b/src/test/java/blue/language/provider/ProviderCanonicalIngestionTest.java @@ -1,10 +1,13 @@ package blue.language.provider; +import blue.language.Blue; import blue.language.model.Node; import blue.language.utils.BlueIdCalculator; import org.junit.jupiter.api.Test; +import java.util.Collections; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; import static org.junit.jupiter.api.Assertions.*; @@ -12,23 +15,132 @@ class ProviderCanonicalIngestionTest { @Test - void storesMigratedCanonicalContentUnderCorrectedHash() { - String legacyDoc = "name: Legacy\n" + + void rejectsInvalidConstraintsKey() { + String invalidConstraintsDoc = "name: Invalid Constraints\n" + "constraints:\n" + " minLength: 2"; - Node canonicalNode = YAML_MAPPER.readValue(legacyDoc, Node.class); - String canonicalHash = BlueIdCalculator.calculateBlueId(canonicalNode); - BasicNodeProvider provider = new BasicNodeProvider(); - provider.addSingleDocs(legacyDoc); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue(invalidConstraintsDoc, blue.language.model.Node.class)); + assertThrows(RuntimeException.class, () -> provider.addSingleDocs(invalidConstraintsDoc)); + } + + @Test + void providerContentWithWrongBlueIdFailsTypeResolution() { + String requestedBlueId = BlueIdCalculator.calculateBlueId(new Node().value("expected")); + Blue blue = new Blue(blueId -> Collections.singletonList(new Node().value("actual"))); + + assertThrows(IllegalArgumentException.class, + () -> blue.resolve(new Node().type(new Node().blueId(requestedBlueId)))); + } + + @Test + void providerMissingContentFailsDeterministically() { + String requestedBlueId = BlueIdCalculator.calculateBlueId(new Node().value("missing")); + Blue blue = new Blue(blueId -> Collections.emptyList()); + + RuntimeException error = assertThrows(RuntimeException.class, + () -> blue.resolve(new Node().type(new Node().blueId(requestedBlueId)))); + assertNotNull(error.getMessage()); + } + + @Test + void providerRejectsInvalidBlueIdBeforeFetch() { + AtomicBoolean fetched = new AtomicBoolean(false); + VerifyingNodeProvider provider = new VerifyingNodeProvider(blueId -> { + fetched.set(true); + return Collections.singletonList(new Node().value("x")); + }); + + assertThrows(IllegalArgumentException.class, () -> provider.fetchByBlueId("not-a-real-blueid")); + assertFalse(fetched.get()); + } + + @Test + void providerDoesNotSkipVerificationWhenContentReferencesRequestedBlueId() { + String requestedBlueId = BlueIdCalculator.calculateBlueId(new Node().value("expected")); + VerifyingNodeProvider provider = new VerifyingNodeProvider(blueId -> Collections.singletonList( + new Node().properties( + "self", new Node().blueId(requestedBlueId), + "actual", new Node().value("actual")))); + + assertThrows(IllegalArgumentException.class, () -> provider.fetchByBlueId(requestedBlueId)); + } + + @Test + void providerPlainIdDoesNotUseCyclicRewriteFallback() { + String requestedBlueId = BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders( + new Node().properties("self", new Node().blueId(NodeContentHandler.ZERO_BLUE_ID))); + VerifyingNodeProvider provider = new VerifyingNodeProvider(blueId -> Collections.singletonList( + new Node().properties("self", new Node().blueId(requestedBlueId)))); + + assertThrows(IllegalArgumentException.class, () -> provider.fetchByBlueId(requestedBlueId)); + } + + @Test + void providerDoesNotBypassPlainVerificationForCyclicAwareDelegate() { + String requestedBlueId = BlueIdCalculator.calculateBlueId(new Node().value("expected")); + VerifyingNodeProvider provider = new VerifyingNodeProvider(new CyclicAwareWrongContentProvider(requestedBlueId)); + + assertThrows(IllegalArgumentException.class, () -> provider.fetchByBlueId(requestedBlueId)); + } + + @Test + void providerCyclicMemberFetchRequiresCyclicAwareVerificationOrFailsExplicitly() { + String baseBlueId = BlueIdCalculator.calculateBlueId(new Node().value("base")); + VerifyingNodeProvider provider = new VerifyingNodeProvider(blueId -> { + if ((baseBlueId + "#0").equals(blueId)) { + return Collections.singletonList(new Node().value("member")); + } + return null; + }); + + assertThrows(UnsupportedOperationException.class, () -> provider.fetchByBlueId(baseBlueId + "#0")); + } + + @Test + void providerCyclicMemberDoesNotUsePartialBaseSetVerification() { + BasicNodeProvider baseProvider = new BasicNodeProvider(YAML_MAPPER.readValue( + "- name: A\n" + + " next:\n" + + " type:\n" + + " blueId: this#1\n" + + "- name: B\n" + + " next:\n" + + " type:\n" + + " blueId: this#0", Node.class)); + String aBlueId = baseProvider.getBlueIdByName("A"); + String baseBlueId = aBlueId.substring(0, aBlueId.indexOf('#')); + List baseNodes = baseProvider.fetchByBlueId(baseBlueId); + + VerifyingNodeProvider provider = new VerifyingNodeProvider(blueId -> { + if (baseBlueId.equals(blueId)) { + return baseNodes; + } + if ((baseBlueId + "#0").equals(blueId)) { + return Collections.singletonList(baseNodes.get(1)); + } + return null; + }); + + assertThrows(UnsupportedOperationException.class, () -> provider.fetchByBlueId(baseBlueId + "#0")); + } + + private static final class CyclicAwareWrongContentProvider implements blue.language.NodeProvider, CyclicAwareNodeProvider { + private final String claimedBlueId; + + private CyclicAwareWrongContentProvider(String claimedBlueId) { + this.claimedBlueId = claimedBlueId; + } - assertEquals(canonicalHash, provider.getBlueIdByName("Legacy")); + @Override + public List fetchByBlueId(String blueId) { + return Collections.singletonList(new Node().value("actual")); + } - List fetched = provider.fetchByBlueId(canonicalHash); - assertEquals(1, fetched.size()); - assertNotNull(fetched.get(0).getSchema()); - assertEquals(2, fetched.get(0).getSchema().getMinLengthValue()); - assertNull(fetched.get(0).getProperties()); + @Override + public boolean hasVerifiedContentForBlueId(String blueId) { + return claimedBlueId.equals(blueId); + } } } diff --git a/src/test/java/blue/language/snapshot/FrozenNodeTest.java b/src/test/java/blue/language/snapshot/FrozenNodeTest.java index 8f609f8..1acbf13 100644 --- a/src/test/java/blue/language/snapshot/FrozenNodeTest.java +++ b/src/test/java/blue/language/snapshot/FrozenNodeTest.java @@ -2,8 +2,13 @@ import blue.language.model.Node; import blue.language.utils.BlueIdCalculator; +import blue.language.Blue; +import blue.language.utils.NodeToBlueIdInput; +import blue.language.utils.Nodes; +import com.fasterxml.jackson.databind.JsonNode; import org.junit.jupiter.api.Test; +import java.io.InputStream; import java.util.Arrays; import java.util.Collections; @@ -11,24 +16,137 @@ import static blue.language.utils.Properties.DOUBLE_TYPE_BLUE_ID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; class FrozenNodeTest { @Test void blueIdMatchesMutableCalculatorForObjectsScalarsAndPureReferences() { + String referenceBlueId = BlueIdCalculator.calculateBlueId(new Node().value("reference")); Node node = YAML_MAPPER.readValue( "name: Product\n" + "count: 1\n" + "nested:\n" + " label: abc\n" + "ref:\n" + - " blueId: SomeReference", Node.class); + " blueId: " + referenceBlueId, Node.class); FrozenNode frozen = FrozenNode.fromNode(node); assertEquals(BlueIdCalculator.calculateBlueId(node), frozen.blueId()); - assertEquals("SomeReference", FrozenNode.fromNode(new Node().blueId("SomeReference")).blueId()); + assertEquals(referenceBlueId, FrozenNode.fromNode(new Node().blueId(referenceBlueId)).blueId()); + } + + @Test + void frozenNodeBlueIdMatchesBlueIdCalculatorForEveryBlueIdFixture() throws Exception { + JsonNode manifest = readFixtureResource("manifest.yaml"); + for (JsonNode entry : manifest.get("fixtures")) { + JsonNode fixture = readFixtureResource(entry.get("path").asText()); + if (fixture.path("expectError").asBoolean(false) + || !"calculateBlueId".equals(fixture.path("operation").asText())) { + continue; + } + Node input = YAML_MAPPER.treeToValue(fixture.get("input"), Node.class); + + assertEquals( + BlueIdCalculator.calculateBlueId(input), + FrozenNode.fromNode(input).blueId(), + "Frozen BlueId mismatch for fixture " + fixture.get("id").asText()); + } + } + + @Test + void frozenNodeToBlueIdInputMatchesNodeToBlueIdInputForCanonicalShapes() { + String previousBlueId = BlueIdCalculator.calculateBlueId(new Node().items()); + String referenceBlueId = BlueIdCalculator.calculateBlueId(new Node().value("reference")); + Node withSchema = new Node() + .schema(new blue.language.model.Schema().minimum(new Node().type(new Node().blueId( + blue.language.utils.Properties.INTEGER_TYPE_BLUE_ID)).value("9007199254740992"))); + + for (Node node : Arrays.asList( + new Node().value("text"), + new Node().items(new Node().value("A"), Nodes.emptyPlaceholder(), new Node().value("B")), + new Node().items(new Node().previousBlueId(previousBlueId), new Node().value("A")), + new Node().blueId(referenceBlueId), + new Node().value("abc").contracts(new Node().properties("audit", new Node().value(true))), + withSchema)) { + assertEquals( + NodeToBlueIdInput.get(node), + FrozenNodeToBlueIdInput.get(FrozenNode.fromNode(node)), + "Frozen canonical input mismatch for " + node); + } + } + + @Test + void frozenNodeToBlueIdInputHashesLikeNodeToBlueIdInputForEveryValidBlueIdFixture() throws Exception { + JsonNode manifest = readFixtureResource("manifest.yaml"); + for (JsonNode entry : manifest.get("fixtures")) { + if (!"BlueId".equals(entry.get("category").asText())) { + continue; + } + JsonNode fixture = readFixtureResource(entry.get("path").asText()); + if (fixture.path("expectError").asBoolean(false) + || !"calculateBlueId".equals(fixture.path("operation").asText())) { + continue; + } + Node input = YAML_MAPPER.treeToValue(fixture.get("input"), Node.class); + + assertEquals( + BlueIdCalculator.INSTANCE.calculate(NodeToBlueIdInput.get(input)), + BlueIdCalculator.INSTANCE.calculate(FrozenNodeToBlueIdInput.get(FrozenNode.fromNode(input))), + "Frozen canonical input mismatch for fixture " + fixture.get("id").asText()); + } + } + + @Test + void frozenNodeRejectsEveryInvalidBlueIdFixtureThatParsesAsNode() throws Exception { + JsonNode manifest = readFixtureResource("manifest.yaml"); + for (JsonNode entry : manifest.get("fixtures")) { + if (!"BlueId".equals(entry.get("category").asText())) { + continue; + } + JsonNode fixture = readFixtureResource(entry.get("path").asText()); + if (!fixture.path("expectError").asBoolean(false) + || !"calculateBlueId".equals(fixture.path("operation").asText()) + || !fixture.has("input")) { + continue; + } + Node input; + try { + input = YAML_MAPPER.treeToValue(fixture.get("input"), Node.class); + } catch (RuntimeException parserRejected) { + continue; + } + + assertThrows( + RuntimeException.class, + () -> BlueIdCalculator.calculateBlueId(input), + "Mutable calculator accepted invalid fixture " + fixture.get("id").asText()); + assertThrows( + RuntimeException.class, + () -> FrozenNode.fromNode(input).blueId(), + "Frozen calculator accepted invalid fixture " + fixture.get("id").asText()); + } + } + + @Test + void repeatedFrozenBlueIdIsCached() { + FrozenNode frozen = FrozenNode.fromNode(new Node().properties("a", new Node().value("b"))); + + assertSame(frozen.blueId(), frozen.blueId()); + } + + @Test + void repeatedFrozenBlueIdDoesNotRecompute() { + FrozenNode frozen = FrozenNode.fromNode(new Node() + .properties("a", new Node().value("b")) + .properties("nested", new Node().properties("c", new Node().value("d")))); + String first = frozen.blueId(); + + for (int i = 0; i < 10; i++) { + assertSame(first, frozen.blueId()); + } } @Test @@ -60,18 +178,76 @@ void blueIdMatchesMutableCalculatorForEmptySingletonAndNestedLists() { } @Test - void blueIdMatchesMutableCalculatorForListControlForms() { + void directEmptyObjectInsideListIsRejected() { + Node withEmptyObject = YAML_MAPPER.readValue( + "items:\n" + + " - {}", Node.class); + + assertThrows(IllegalArgumentException.class, () -> FrozenNode.fromNode(withEmptyObject)); + } + + @Test + void sourceEmptyObjectInsideListNormalizesBeforeFreezing() { + Node normalized = new Blue().yamlToNode( + "items:\n" + + " - {}"); + + assertEquals(BlueIdCalculator.calculateBlueId(normalized), FrozenNode.fromNode(normalized).blueId()); + } + + @Test + void positionedListsAreRejectedByDirectFrozenBlueIdInput() { + String previousBlueId = BlueIdCalculator.calculateBlueId(new Node().items()); + Node positioned = YAML_MAPPER.readValue( + "items:\n" + + " - $pos: 0\n" + + " value: A", Node.class); + Node previous = YAML_MAPPER.readValue( + "items:\n" + + " - $previous:\n" + + " blueId: " + previousBlueId + "\n" + + " - $empty: true\n" + + " - value: A", Node.class); + + assertThrows(IllegalArgumentException.class, () -> FrozenNode.fromNode(positioned)); + assertEquals(BlueIdCalculator.calculateBlueId(previous), FrozenNode.fromNode(previous).blueId()); + } + + @Test + void directFrozenBlueIdRejectsPositionControls() { + String previousBlueId = BlueIdCalculator.calculateBlueId(new Node().items()); Node node = YAML_MAPPER.readValue( "items:\n" + " - $previous:\n" + - " blueId: PrevListHash\n" + - " - $pos: 2\n" + - " value: C\n" + - " - $pos: 0\n" + - " value: A\n" + + " blueId: " + previousBlueId + "\n" + " - value: D", Node.class); + Node positioned = YAML_MAPPER.readValue( + "items:\n" + + " - $pos: 0\n" + + " value: A", Node.class); assertEquals(BlueIdCalculator.calculateBlueId(node), FrozenNode.fromNode(node).blueId()); + assertThrows(IllegalArgumentException.class, () -> FrozenNode.fromNode(positioned)); + } + + @Test + void frozenStrictRejectsRootPreviousOnlyNode() { + String previousBlueId = BlueIdCalculator.calculateBlueId(new Node().items()); + + assertThrows(IllegalArgumentException.class, + () -> FrozenNode.fromNode(new Node().previousBlueId(previousBlueId))); + } + + @Test + void frozenStrictAllowsPreviousOnlyOnlyAsFirstListElement() { + String previousBlueId = BlueIdCalculator.calculateBlueId(new Node().items()); + Node anchored = YAML_MAPPER.readValue( + "items:\n" + + " - $previous:\n" + + " blueId: " + previousBlueId + "\n" + + " - value: A", Node.class); + + assertEquals(BlueIdCalculator.calculateBlueId(anchored), FrozenNode.fromNode(anchored).blueId()); } @Test @@ -84,6 +260,34 @@ void blueIdMatchesMutableCalculatorForTypedDoubleCanonicalization() { assertEquals(BlueIdCalculator.calculateBlueId(node), FrozenNode.fromNode(node).blueId()); } + @Test + void strictCanonicalAllowsContractsAlongsideScalarAndListPayloads() { + Node scalar = YAML_MAPPER.readValue( + "value: abc\n" + + "contracts:\n" + + " audit:\n" + + " value: enabled", Node.class); + Node list = YAML_MAPPER.readValue( + "items:\n" + + " - abc\n" + + "contracts:\n" + + " audit:\n" + + " value: enabled", Node.class); + + assertEquals(BlueIdCalculator.calculateBlueId(scalar), FrozenNode.fromNode(scalar).blueId()); + assertEquals(BlueIdCalculator.calculateBlueId(list), FrozenNode.fromNode(list).blueId()); + assertThrows(IllegalArgumentException.class, + () -> FrozenNode.fromNode(new Node().value("abc").properties( + "contracts", new Node().properties("audit", new Node().value("enabled")), + "child", new Node().value("not allowed")))); + } + + @Test + void strictCanonicalRejectsInvalidReferenceBlueIds() { + assertThrows(IllegalArgumentException.class, () -> FrozenNode.fromNode(new Node().blueId("invalid"))); + assertThrows(IllegalArgumentException.class, () -> FrozenNode.fromNode(new Node().previousBlueId("invalid"))); + } + @Test void immutableViewsCannotBeMutatedAndToNodeReturnsFreshMutableCopies() { FrozenNode frozen = FrozenNode.fromNode(YAML_MAPPER.readValue( @@ -160,6 +364,16 @@ void rejectsInvalidCanonicalPayloadShapes() { () -> FrozenNode.fromNode(new Node().position(1))); } + @Test + void strictCanonicalModeRejectsBlueDirective() { + Node node = YAML_MAPPER.readValue( + "blue:\n" + + " items: []\n" + + "value: hello", Node.class); + + assertThrows(IllegalArgumentException.class, () -> FrozenNode.fromNode(node)); + } + @Test void rejectsInvalidListControlFormsDuringHashing() { Node duplicatePosition = YAML_MAPPER.readValue( @@ -186,7 +400,18 @@ void resolvedModeAllowsExpandedBlueIdMetadataButCanonicalModeRejectsIt() { FrozenNode resolved = FrozenNode.fromResolvedNode(resolvedLike); - assertEquals(BlueIdCalculator.calculateBlueId(resolved.toNode()), resolved.blueId()); + assertEquals(BlueIdCalculator.INSTANCE.calculate(Collections.singletonMap("name", "Expanded node")), resolved.blueId()); + assertThrows(IllegalArgumentException.class, () -> BlueIdCalculator.calculateBlueId(resolved.toNode())); assertThrows(IllegalArgumentException.class, () -> FrozenNode.fromNode(resolvedLike)); } + + private JsonNode readFixtureResource(String path) throws Exception { + try (InputStream stream = getClass().getClassLoader() + .getResourceAsStream("blue-language-1.0/fixtures/" + path)) { + if (stream == null) { + throw new IllegalArgumentException("Missing fixture resource: " + path); + } + return YAML_MAPPER.readTree(stream); + } + } } diff --git a/src/test/java/blue/language/snapshot/ResolvedSnapshotTest.java b/src/test/java/blue/language/snapshot/ResolvedSnapshotTest.java index 5f73eb0..eeb4c21 100644 --- a/src/test/java/blue/language/snapshot/ResolvedSnapshotTest.java +++ b/src/test/java/blue/language/snapshot/ResolvedSnapshotTest.java @@ -117,6 +117,47 @@ void exposesCanonicalAndResolvedPathIndexes() { assertTrue(snapshot.resolvedIndex().containsKey("/")); } + @Test + void resolvedSnapshotResolvedAtUsesIndex() { + ResolvedSnapshot snapshot = new Blue().loadSnapshot(YAML_MAPPER.readValue( + "deep:\n" + + " nested:\n" + + " value: ok", Node.class)); + + assertSame(snapshot.resolvedIndex().get("/deep/nested"), snapshot.resolvedAt("/deep/nested")); + } + + @Test + void resolvedSnapshotCanonicalAtUsesIndex() { + ResolvedSnapshot snapshot = new Blue().loadSnapshot(YAML_MAPPER.readValue( + "deep:\n" + + " nested:\n" + + " value: ok", Node.class)); + + assertSame(snapshot.canonicalIndex().get("/deep/nested"), snapshot.canonicalAt("/deep/nested")); + } + + @Test + void resolvedSnapshotBlueIdEqualsCanonicalRootBlueId() { + ResolvedSnapshot snapshot = new Blue().loadSnapshot(YAML_MAPPER.readValue("value: ok", Node.class)); + + assertEquals(snapshot.frozenCanonicalRoot().blueId(), snapshot.blueId()); + } + + @Test + void resolvedRootHashNotUsedAsContentBlueId() { + BasicNodeProvider nodeProvider = productProvider(); + Blue blue = new Blue(nodeProvider); + Node canonical = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Product"), Node.class); + + ResolvedSnapshot snapshot = blue.loadSnapshot(canonical); + + assertEquals(snapshot.frozenCanonicalRoot().blueId(), snapshot.blueId()); + assertFalse(snapshot.frozenResolvedRoot().blueId().equals(snapshot.blueId())); + } + @Test void blueCanApplyCanonicalPatchAndReturnNextResolvedSnapshot() { BasicNodeProvider nodeProvider = new BasicNodeProvider(); @@ -323,6 +364,20 @@ void differentSnapshotsReuseSameResolvedTypeFrozenNodeAndAvoidRefetchingTypeGrap assertTrue(blue.resolvedReferenceCacheSize() >= 2); } + @Test + void providerFetchCountDoesNotIncreaseForCachedResolvedTypes() { + BasicNodeProvider delegate = inheritedProductProvider(); + CountingNodeProvider countingProvider = new CountingNodeProvider(delegate); + Blue blue = new Blue(countingProvider); + + blue.loadSnapshot(productInstance(delegate, "first")); + int fetchesAfterFirst = countingProvider.fetchCount(); + blue.loadSnapshot(productInstance(delegate, "second")); + + assertTrue(fetchesAfterFirst > 0); + assertEquals(fetchesAfterFirst, countingProvider.fetchCount()); + } + @Test void preloadedResolvedTypeSnapshotIsUsedToResolveInstancesWithoutProviderFetches() { BasicNodeProvider delegate = inheritedProductProvider(); diff --git a/src/test/java/blue/language/utils/BlueIdCalculatorTest.java b/src/test/java/blue/language/utils/BlueIdCalculatorTest.java index 33ec527..d1918f3 100644 --- a/src/test/java/blue/language/utils/BlueIdCalculatorTest.java +++ b/src/test/java/blue/language/utils/BlueIdCalculatorTest.java @@ -1,5 +1,6 @@ package blue.language.utils; +import blue.language.Blue; import blue.language.model.Node; import org.junit.jupiter.api.Test; @@ -11,9 +12,11 @@ import static blue.language.utils.Properties.*; import static blue.language.utils.UncheckedObjectMapper.JSON_MAPPER; import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; public class BlueIdCalculatorTest { @@ -144,38 +147,24 @@ public void testPreviousListAnchorWithoutAppendsReturnsPreviousBlueId() { } @Test - public void testPositionControlsAreConsumedBeforeHashing() { + public void directBlueIdRejectsPosOverlay() { String withPosition = "abc:\n" + " - $pos: 0\n" + " value: A\n" + " - value: B"; - String normalized = "abc:\n" + - " - value: A\n" + - " - value: B"; - - String positionedResult = new BlueIdCalculator(fakeHashValueProvider()).calculate(YAML_MAPPER.readValue(withPosition, Map.class)); - String normalizedResult = new BlueIdCalculator(fakeHashValueProvider()).calculate(YAML_MAPPER.readValue(normalized, Map.class)); - assertEquals(normalizedResult, positionedResult); + assertThrows(IllegalArgumentException.class, + () -> new BlueIdCalculator(fakeHashValueProvider()).calculate(YAML_MAPPER.readValue(withPosition, Map.class))); } @Test - public void testPositionControlsAreOrderIndependentBeforeHashing() { - String outOfOrder = "abc:\n" + - " - $pos: 1\n" + - " value: B\n" + - " - $pos: 0\n" + - " value: A\n" + - " - value: C"; - String normalized = "abc:\n" + - " - value: A\n" + - " - value: B\n" + - " - value: C"; - - String outOfOrderResult = new BlueIdCalculator(fakeHashValueProvider()).calculate(YAML_MAPPER.readValue(outOfOrder, Map.class)); - String normalizedResult = new BlueIdCalculator(fakeHashValueProvider()).calculate(YAML_MAPPER.readValue(normalized, Map.class)); + public void directBlueIdRejectsReplaceOverlay() { + String withReplace = "abc:\n" + + " - $replace: true\n" + + " value: A"; - assertEquals(normalizedResult, outOfOrderResult); + assertThrows(IllegalArgumentException.class, + () -> new BlueIdCalculator(fakeHashValueProvider()).calculate(YAML_MAPPER.readValue(withReplace, Map.class))); } @Test @@ -363,15 +352,7 @@ public void testDoubleOneThirdCanonicalizesAcrossComputedAndAuthoredForms() { public void testBigIntegerV1() { String yaml = "num: 36928735469874359687345908673940586739458679548679034857690345876905238476903485769"; - Node node = YAML_MAPPER.readValue(yaml, Node.class); - String blueId = BlueIdCalculator.calculateBlueId(node); - - String json = "{\"num\":{\"type\":{\"blueId\":\"" + INTEGER_TYPE_BLUE_ID - + "\"},\"value\":\"36928735469874359687345908673940586739458679548679034857690345876905238476903485769\"}}"; - Node node2 = JSON_MAPPER.readValue(json, Node.class); - String blueId2 = BlueIdCalculator.calculateBlueId(node2); - - assertEquals(blueId2, blueId); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue(yaml, Node.class)); } @Test @@ -489,6 +470,200 @@ public void testNullAndEmptyRemoval() { assertNotEquals(result1, result4); } + @Test + public void directBlueIdRejectsBlueDirective() { + Node node = YAML_MAPPER.readValue( + "blue:\n" + + " items: []\n" + + "value: hello", Node.class); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(node)); + + assertTrue(exception.getMessage().contains("\"blue\" is a preprocessing directive")); + } + + @Test + public void blueFacadeDirectBlueIdRejectsBlueDirective() { + Node node = YAML_MAPPER.readValue( + "blue:\n" + + " items: []\n" + + "value: hello", Node.class); + + assertThrows(IllegalArgumentException.class, () -> new Blue().calculateBlueId(node)); + } + + @Test + public void explicitBlueIdInputParsingRequiresCanonicalBlueIds() { + Blue blue = new Blue(); + String validBlueId = BlueIdCalculator.calculateBlueId(new Node().value("x")); + + assertDoesNotThrow(() -> blue.parseBlueIdInputYaml("blueId: " + validBlueId)); + assertDoesNotThrow(() -> blue.parseBlueIdInputYaml("blueId: " + validBlueId + "#0")); + + assertThrows(RuntimeException.class, () -> blue.parseBlueIdInputYaml("blueId: abc")); + assertThrows(RuntimeException.class, () -> blue.parseBlueIdInputYaml("blueId: " + validBlueId + "#01")); + assertThrows(RuntimeException.class, () -> blue.parseBlueIdInputYaml("blueId: this#0")); + assertThrows(RuntimeException.class, () -> blue.parseBlueIdInputYaml( + "items:\n" + + " - $previous:\n" + + " blueId: prevHash\n" + + " - value: x")); + } + + @Test + public void staticCalculatorRejectsInvalidReferenceBlueIds() { + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(new Node().blueId("not-a-real-blueid"))); + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(new Node().blueId("this#0"))); + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue( + "items:\n" + + " - $previous:\n" + + " blueId: not-a-real-blueid\n" + + " - value: x", Node.class))); + } + + @Test + public void directBlueIdRejectsUnresolvedTypeAliases() { + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue("type: Integer\nvalue: 1", Node.class))); + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue("itemType: Text\nitems: []", Node.class))); + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue("keyType: Text\nvalueType: Integer", Node.class))); + assertThrows(RuntimeException.class, + () -> new Blue().parseBlueIdInputYaml("type: Integer\nvalue: 1")); + } + + @Test + public void directBlueIdRejectsTypeAlias() { + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue("type: Integer\nvalue: 1", Node.class))); + } + + @Test + public void directBlueIdRejectsItemTypeAlias() { + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue("itemType: Text\nitems: []", Node.class))); + } + + @Test + public void directBlueIdRejectsKeyTypeAlias() { + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue("keyType: Text\n", Node.class))); + } + + @Test + public void directBlueIdRejectsValueTypeAlias() { + assertThrows(IllegalArgumentException.class, + () -> BlueIdCalculator.calculateBlueId(YAML_MAPPER.readValue("valueType: Integer\n", Node.class))); + } + + @Test + public void parseBlueIdInputRejectsTypeAlias() { + assertThrows(RuntimeException.class, + () -> new Blue().parseBlueIdInputYaml("type: Integer\nvalue: 1")); + } + + @Test + public void semanticBlueIdAcceptsAuthoredBlueDirective() { + Node node = YAML_MAPPER.readValue( + "blue:\n" + + " items: []\n" + + "value: hello", Node.class); + + assertDoesNotThrow(() -> new Blue().calculateSemanticBlueId(node)); + } + + @Test + public void semanticBlueIdAcceptsSourceAliasesAndCanonicalOverlayRemovesThem() { + Blue blue = new Blue(); + Node source = YAML_MAPPER.readValue("type: Integer\nvalue: 1", Node.class); + + assertDoesNotThrow(() -> blue.calculateSemanticBlueId(source)); + Node canonical = blue.canonicalize(source); + + assertEquals(INTEGER_TYPE_BLUE_ID, canonical.getType().getBlueId()); + assertDoesNotThrow(() -> BlueIdCalculator.calculateBlueId(canonical)); + } + + @Test + public void directBlueIdUsesPreviousAsListSeed() { + String previousBlueId = BlueIdCalculator.calculateBlueId(new Node().items()); + Node node = YAML_MAPPER.readValue( + "items:\n" + + " - $previous:\n" + + " blueId: " + previousBlueId + "\n" + + " - value: C", Node.class); + + assertDoesNotThrow(() -> BlueIdCalculator.calculateBlueId(node)); + } + + @Test + public void sourceListNullNormalizesToEmptyPlaceholder() { + Blue blue = new Blue(); + Node withNull = blue.yamlToNode( + "items:\n" + + " - A\n" + + " - null\n" + + " - B"); + Node withPlaceholder = blue.yamlToNode( + "items:\n" + + " - A\n" + + " - $empty: true\n" + + " - B"); + Node compact = blue.yamlToNode( + "items:\n" + + " - A\n" + + " - B"); + + assertEquals(BlueIdCalculator.calculateBlueId(withPlaceholder), BlueIdCalculator.calculateBlueId(withNull)); + assertNotEquals(BlueIdCalculator.calculateBlueId(compact), BlueIdCalculator.calculateBlueId(withNull)); + } + + @Test + public void sourceListEmptyObjectNormalizesToEmptyPlaceholder() { + Blue blue = new Blue(); + Node withEmptyObject = blue.yamlToNode( + "items:\n" + + " - A\n" + + " - {}\n" + + " - B"); + Node withPlaceholder = blue.yamlToNode( + "items:\n" + + " - A\n" + + " - $empty: true\n" + + " - B"); + Node compact = blue.yamlToNode( + "items:\n" + + " - A\n" + + " - B"); + + assertEquals(BlueIdCalculator.calculateBlueId(withPlaceholder), BlueIdCalculator.calculateBlueId(withEmptyObject)); + assertNotEquals(BlueIdCalculator.calculateBlueId(compact), BlueIdCalculator.calculateBlueId(withEmptyObject)); + } + + @Test + public void directBlueIdRejectsEmptyObjectListElement() { + Node withEmptyObject = YAML_MAPPER.readValue( + "items:\n" + + " - {}", Node.class); + + assertThrows(IllegalArgumentException.class, () -> BlueIdCalculator.calculateBlueId(withEmptyObject)); + } + + @Test + public void directBlueIdRejectsNullListElement() { + Node withNull = YAML_MAPPER.readValue( + "items:\n" + + " - null", Node.class); + + assertThrows(IllegalArgumentException.class, () -> BlueIdCalculator.calculateBlueId(withNull)); + } + private static Function fakeHashValueProvider() { return obj -> "hash(" + obj + ")"; } diff --git a/src/test/java/blue/language/utils/BlueIdsTest.java b/src/test/java/blue/language/utils/BlueIdsTest.java index 64f9577..edf8d9c 100644 --- a/src/test/java/blue/language/utils/BlueIdsTest.java +++ b/src/test/java/blue/language/utils/BlueIdsTest.java @@ -17,6 +17,7 @@ void testIsPotentialBlueId() { assertFalse(isPotentialBlueId("4Yj5XZbpuS1quJHsLbxsAnNHTV1XbhgQar2zQBDzr")); assertFalse(isPotentialBlueId("4Yj5XZbpuS1quJHsLbxsAnNHTV1XbhgQar2zQBDzrat7A")); assertFalse(isPotentialBlueId("4Yj5XZbpuS1quJHsLbxsAnNHTV1XbhgQar2zQBDzrat7#")); + assertFalse(isPotentialBlueId("4Yj5XZbpuS1quJHsLbxsAnNHTV1XbhgQar2zQBDzrat7#01")); assertFalse(isPotentialBlueId("4Yj5XZbpuS1quJHsLbxsAnNHTV1XbhgQar2zQBDzrat7#-1")); assertFalse(isPotentialBlueId("4Yj5XZbpuS1quJHsLbxsAnNHTV1XbhgQar2zQBDzrat7#abc")); assertFalse(isPotentialBlueId("4Yj5XZbpuS1quJHsLbxsAnNHTV1XbhgQar2zQBDzrat7#12#34")); @@ -25,4 +26,4 @@ void testIsPotentialBlueId() { assertFalse(isPotentialBlueId("4Yj5XZbpuS1quJHsLbxsAnNHTV1XbhgQar2zQBDzrat7I")); assertFalse(isPotentialBlueId("4Yj5XZbpuS1quJHsLbxsAnNHTV1XbhgQar2zQBDzrat7l")); } -} \ No newline at end of file +} diff --git a/src/test/java/blue/language/utils/NodePathAccessorTest.java b/src/test/java/blue/language/utils/NodePathAccessorTest.java index 113b884..1f036c0 100644 --- a/src/test/java/blue/language/utils/NodePathAccessorTest.java +++ b/src/test/java/blue/language/utils/NodePathAccessorTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.Test; import java.math.BigInteger; +import java.util.Arrays; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -102,4 +104,52 @@ void testJsonPointerEscaping() throws Exception { assertEquals("tilde", node.get("/a~0b/value")); assertEquals("value", node.get("/nested/x~1y/value")); } + + @Test + void nodePathAccessorReadsContracts() throws Exception { + Node node = YAML_MAPPER.readValue( + "contracts:\n" + + " audit:\n" + + " enabled: true", Node.class); + + assertEquals(Boolean.TRUE, node.get("/contracts/audit/enabled/value")); + assertSame(node.getContracts(), NodePathAccessor.getNode(node, "/contracts")); + } + + @Test + void nodePathEditorWritesContracts() { + Node node = new Node(); + + NodePathEditor.put(node, "/contracts/audit/enabled", new Node().value(true)); + + assertNotNull(node.getContracts()); + assertEquals(Boolean.TRUE, node.get("/contracts/audit/enabled/value")); + assertFalse(node.getProperties() != null && node.getProperties().containsKey("contracts")); + } + + @Test + void nodePathSelectorFindsContracts() throws Exception { + Node node = YAML_MAPPER.readValue( + "contracts:\n" + + " audit:\n" + + " enabled: true\n" + + "other:\n" + + " enabled: true", Node.class); + + List selected = NodePathSelector.select(node, + Arrays.asList("/contracts/*/enabled"), + candidate -> Boolean.TRUE.equals(candidate.getValue())); + + assertEquals(Arrays.asList("/contracts/audit/enabled"), selected); + } + + @Test + void jsonPointerContractsRoundTrip() throws Exception { + Node node = YAML_MAPPER.readValue( + "contracts:\n" + + " \"a/b\":\n" + + " \"c~d\": value", Node.class); + + assertEquals("value", node.get("/contracts/a~1b/c~0d/value")); + } } diff --git a/src/test/java/blue/language/utils/NodeTypeMatcherTest.java b/src/test/java/blue/language/utils/NodeTypeMatcherTest.java index e67335f..5c2a596 100644 --- a/src/test/java/blue/language/utils/NodeTypeMatcherTest.java +++ b/src/test/java/blue/language/utils/NodeTypeMatcherTest.java @@ -117,7 +117,7 @@ void targetLabelsDoNotConstrainPresenceOrMatching() { } @Test - void providerBackedTypeCompatibilityIgnoresNameDescriptionAndSchemaLabels() { + void providerBackedTypeCompatibilityIgnoresNameDescriptionOnTypes() { BasicNodeProvider nodeProvider = new BasicNodeProvider(); nodeProvider.addSingleDocs( "name: Provider Request\n" + @@ -125,10 +125,7 @@ void providerBackedTypeCompatibilityIgnoresNameDescriptionAndSchemaLabels() { "payload:\n" + " type: Integer\n" + " schema:\n" + - " minimum:\n" + - " name: Provider minimum label\n" + - " description: Provider minimum description\n" + - " value: 1"); + " minimum: 1"); Blue blue = new Blue(nodeProvider); Node providerTypedNode = blue.yamlToNode( @@ -142,10 +139,7 @@ void providerBackedTypeCompatibilityIgnoresNameDescriptionAndSchemaLabels() { " payload:\n" + " type: Integer\n" + " schema:\n" + - " minimum:\n" + - " name: Inline minimum label\n" + - " description: Inline minimum description\n" + - " value: 1\n" + + " minimum: 1\n" + "payload:\n" + " type: Integer"); Node inlineTypedNode = blue.yamlToNode( @@ -155,10 +149,7 @@ void providerBackedTypeCompatibilityIgnoresNameDescriptionAndSchemaLabels() { " payload:\n" + " type: Integer\n" + " schema:\n" + - " minimum:\n" + - " name: Inline minimum label\n" + - " description: Inline minimum description\n" + - " value: 1\n" + + " minimum: 1\n" + "payload: 5"); Node providerReferenceTarget = blue.yamlToNode( "type:\n" + @@ -291,7 +282,6 @@ void verifiesSchemaKeywordsOnFrozenNodes() { "values:\n" + " type: List\n" + " schema:\n" + - " allowMultiple: true\n" + " minItems: 2\n" + " maxItems: 3\n" + " uniqueItems: true\n" + @@ -520,7 +510,6 @@ void listSchemaCardinalityMergesItemsWithoutExpandingItemReferences() { "values:\n" + " type: List\n" + " schema:\n" + - " allowMultiple: true\n" + " minItems: 2\n" + " maxItems: 2"))); assertEquals(1, provider.fetchesFor(delegate.getBlueIdByName("List Candidate"))); @@ -1279,7 +1268,8 @@ void directFrozenReferenceMatchingCachesResolvedReferenceLookups() { void directFrozenReferenceMatchingCachesUnresolvedReferenceMisses() { CountingNodeProvider provider = new CountingNodeProvider(new BasicNodeProvider()); Blue blue = new Blue(provider); - FrozenNode missingReference = FrozenNode.fromResolvedNode(new Node().blueId("missing-reference")); + String missingBlueId = BlueIdCalculator.calculateBlueId(new Node().value("missing")); + FrozenNode missingReference = FrozenNode.fromResolvedNode(new Node().blueId(missingBlueId)); FrozenNode target = FrozenNode.fromResolvedNode(blue.yamlToNode("payload: 1")); NodeTypeMatcher matcher = new NodeTypeMatcher(blue); @@ -1287,7 +1277,7 @@ void directFrozenReferenceMatchingCachesUnresolvedReferenceMisses() { assertFalse(matcher.matchesResolvedType(missingReference, target)); } - assertEquals(2, provider.fetchesFor("missing-reference")); + assertEquals(2, provider.fetchesFor(missingBlueId)); } @Test @@ -1407,7 +1397,6 @@ private Node complexOrderPattern(Blue blue, String activeStatusBlueId) { " lineItems:\n" + " type: List\n" + " schema:\n" + - " allowMultiple: true\n" + " minItems: 2\n" + " maxItems: 2\n" + " metadata:\n" + diff --git a/src/test/java/blue/language/utils/limits/NodeToPathLimitsConverterTest.java b/src/test/java/blue/language/utils/limits/NodeToPathLimitsConverterTest.java index 883112d..b606163 100644 --- a/src/test/java/blue/language/utils/limits/NodeToPathLimitsConverterTest.java +++ b/src/test/java/blue/language/utils/limits/NodeToPathLimitsConverterTest.java @@ -74,6 +74,16 @@ void testEscapedPropertyNames() { assertRejects(node, "/a/b"); } + @Test + void testContractsReservedField() { + Node node = new Node().contracts(new Node().properties("audit", new Node().properties("enabled", new Node()))); + + assertAllows(node, "/contracts"); + assertAllows(node, "/contracts/audit"); + assertAllows(node, "/contracts/audit/enabled"); + assertRejects(node, "/audit"); + } + @Test void testNullNode() { assertRejects(null, "/"); diff --git a/src/test/java/blue/language/utils/limits/PathLimitsTest.java b/src/test/java/blue/language/utils/limits/PathLimitsTest.java index c9f38bb..7be9e0e 100644 --- a/src/test/java/blue/language/utils/limits/PathLimitsTest.java +++ b/src/test/java/blue/language/utils/limits/PathLimitsTest.java @@ -192,12 +192,13 @@ public void testSchemaAndBlueId() throws Exception { " maxLength: 4"; Node aNode = blue.yamlToNode(a); nodeProvider.addSingleNodes(aNode); + String referencedBlueId = calculateBlueId(new Node().value("some-blue-id")); String b = "name: B\n" + "type:\n" + " blueId: " + calculateBlueId(aNode) + "\n" + "x:\n" + - " blueId: some-blue-id\n" + + " blueId: " + referencedBlueId + "\n" + "y: abcd"; Node bNode = blue.yamlToNode(b); nodeProvider.addSingleNodes(bNode); @@ -206,7 +207,7 @@ public void testSchemaAndBlueId() throws Exception { "type:\n" + " blueId: " + calculateBlueId(bNode) + "\n" + "x:\n" + - " blueId: some-blue-id\n" + + " blueId: " + referencedBlueId + "\n" + "y: abcd"; Node bInstNode = blue.yamlToNode(bInst); nodeProvider.addSingleNodes(bInstNode); diff --git a/src/test/resources/blue-language-1.0/fixtures/.gitkeep b/src/test/resources/blue-language-1.0/fixtures/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_1e0.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_1e0.yaml new file mode 100644 index 0000000..79d2c2e --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_1e0.yaml @@ -0,0 +1,6 @@ +id: B_double_1e0 +category: BlueId +operation: calculateBlueId +description: exponent notation 1e0 is inferred as Double +input: 1e0 +expectedNodeBlueId: "3SeoqNFjgk6TJV2DzMBGv1wkzcgUzTL9s8BVKhmnXUqL" diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_list.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_list.yaml new file mode 100644 index 0000000..a6a182b --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_list.yaml @@ -0,0 +1,6 @@ +id: B_empty_list +category: BlueId +operation: calculateBlueId +description: empty list remains hashable as list content +input: [] +expectedNodeBlueId: "8m5xsnaKVx5FSBtpcf3tNWKQcE795gf51XbNUTrjxmDK" diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_object_list_element_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_object_list_element_rejected.yaml new file mode 100644 index 0000000..f9168f6 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_object_list_element_rejected.yaml @@ -0,0 +1,10 @@ +id: B_empty_object_list_element_rejected +category: BlueId +operation: calculateBlueId +description: direct BlueId Input rejects empty-object list elements +expectError: true +input: + items: + - A + - {} + - B diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_placeholder.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_placeholder.yaml new file mode 100644 index 0000000..6394fdc --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_placeholder.yaml @@ -0,0 +1,10 @@ +id: B_empty_placeholder +category: BlueId +operation: calculateBlueId +description: exact empty placeholder hashes as positional list content +input: + items: + - value: A + - $empty: true + - value: B +expectedNodeBlueId: "Eeg3z8vs8hxseHrd3vssoYsXCvPSYq2cWjqp1ZaqtYTX" diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_integer_1_vs_double_1_0.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_integer_1_vs_double_1_0.yaml new file mode 100644 index 0000000..ca4a6be --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_integer_1_vs_double_1_0.yaml @@ -0,0 +1,7 @@ +id: B_integer_1_vs_double_1_0 +category: BlueId +operation: calculateBlueId +description: integer 1 and double 1.0 have distinct BlueIds +input: 1.0 +expectedNodeBlueId: "3SeoqNFjgk6TJV2DzMBGv1wkzcgUzTL9s8BVKhmnXUqL" +alsoDifferentFrom: 1 diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_invalid_this_placeholder_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_invalid_this_placeholder_rejected.yaml new file mode 100644 index 0000000..9b6aeb4 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_invalid_this_placeholder_rejected.yaml @@ -0,0 +1,7 @@ +id: B_invalid_this_placeholder_rejected +category: BlueId +operation: calculateBlueId +description: this#k placeholders are rejected outside cyclic calculation APIs +expectError: true +input: + blueId: this#0 diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_large_integer_quoted_explicit_integer.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_large_integer_quoted_explicit_integer.yaml new file mode 100644 index 0000000..50f8b65 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_large_integer_quoted_explicit_integer.yaml @@ -0,0 +1,9 @@ +id: B_large_integer_quoted_explicit_integer +category: BlueId +operation: calculateBlueId +description: large integers use quoted canonical decimal strings with explicit Integer type +input: + type: + blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 + value: "9007199254740992" +expectedNodeBlueId: "ERvwYbBgPotMM2EMpdJ5sieCht9TGYaCmsFzeEH1hU1H" diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_list_sugar_equivalence.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_list_sugar_equivalence.yaml new file mode 100644 index 0000000..13accec --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_list_sugar_equivalence.yaml @@ -0,0 +1,12 @@ +id: B_list_sugar_equivalence +category: BlueId +operation: calculateBlueId +description: surface list and wrapped items list are equivalent +input: + - A + - B +expectedNodeBlueId: "HGLd6gvx6YHX2cizkCMnrTPhTngpTeRnSzApLfrGYDWf" +alsoEquivalentTo: + items: + - A + - B diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_malformed_empty_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_malformed_empty_rejected.yaml new file mode 100644 index 0000000..168a39c --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_malformed_empty_rejected.yaml @@ -0,0 +1,8 @@ +id: B_malformed_empty_rejected +category: BlueId +operation: calculateBlueId +description: malformed empty placeholders are rejected +expectError: true +input: + items: + - $empty: false diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_null_list_element_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_null_list_element_rejected.yaml new file mode 100644 index 0000000..37239af --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_null_list_element_rejected.yaml @@ -0,0 +1,10 @@ +id: B_null_list_element_rejected +category: BlueId +operation: calculateBlueId +description: direct BlueId Input rejects null list elements +expectError: true +input: + items: + - A + - null + - B diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_object_field_null_removal.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_object_field_null_removal.yaml new file mode 100644 index 0000000..c34aef5 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_object_field_null_removal.yaml @@ -0,0 +1,8 @@ +id: B_object_field_null_removal +category: BlueId +operation: calculateBlueId +description: object field null is removed during direct BlueId cleaning +input: + x: null +expectedNodeBlueId: "5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK" +alsoEquivalentTo: {} diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_plain_blueid_validation.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_plain_blueid_validation.yaml new file mode 100644 index 0000000..9147c1a --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_plain_blueid_validation.yaml @@ -0,0 +1,7 @@ +id: B_plain_blueid_validation +category: BlueId +operation: calculateBlueId +description: invalid plain BlueId reference is rejected +expectError: true +input: + blueId: not-a-real-blueid diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_pos_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_pos_rejected.yaml new file mode 100644 index 0000000..fd66d9a --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_pos_rejected.yaml @@ -0,0 +1,9 @@ +id: B_pos_rejected +category: BlueId +operation: calculateBlueId +description: direct BlueId Input rejects $pos overlays +expectError: true +input: + items: + - $pos: 0 + value: A diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_previous_invalid_blueid_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_previous_invalid_blueid_rejected.yaml new file mode 100644 index 0000000..42010e4 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_previous_invalid_blueid_rejected.yaml @@ -0,0 +1,10 @@ +id: B_previous_invalid_blueid_rejected +category: BlueId +operation: calculateBlueId +description: previous anchors must use plain valid BlueIds +expectError: true +input: + items: + - $previous: + blueId: not-a-real-blueid + - A diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_replace_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_replace_rejected.yaml new file mode 100644 index 0000000..7ee3852 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_replace_rejected.yaml @@ -0,0 +1,10 @@ +id: B_replace_rejected +category: BlueId +operation: calculateBlueId +description: direct BlueId Input rejects $replace overlays +expectError: true +input: + items: + - $pos: 0 + $replace: + value: A diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_empty_object.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_empty_object.yaml new file mode 100644 index 0000000..b5c49eb --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_empty_object.yaml @@ -0,0 +1,6 @@ +id: B_root_empty_object +category: BlueId +operation: calculateBlueId +description: root empty object remains hashable +input: {} +expectedNodeBlueId: "5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK" diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_list.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_list.yaml new file mode 100644 index 0000000..f86eee7 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_list.yaml @@ -0,0 +1,8 @@ +id: B_root_list +category: BlueId +operation: calculateBlueId +description: root list is valid BlueId Input +input: + - A + - B +expectedNodeBlueId: "HGLd6gvx6YHX2cizkCMnrTPhTngpTeRnSzApLfrGYDWf" diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_null_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_null_rejected.yaml new file mode 100644 index 0000000..ee5f3aa --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_null_rejected.yaml @@ -0,0 +1,6 @@ +id: B_root_null_rejected +category: BlueId +operation: calculateBlueId +description: root null is not valid BlueId Input +expectError: true +input: null diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_pure_reference.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_pure_reference.yaml new file mode 100644 index 0000000..a40d222 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_pure_reference.yaml @@ -0,0 +1,7 @@ +id: B_root_pure_reference +category: BlueId +operation: calculateBlueId +description: root pure reference hashes to its plain BlueId +input: + blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K +expectedNodeBlueId: "DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K" diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_scalar.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_scalar.yaml new file mode 100644 index 0000000..d002460 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_scalar.yaml @@ -0,0 +1,6 @@ +id: B_root_scalar +category: BlueId +operation: calculateBlueId +description: root scalar is valid BlueId Input +input: hello +expectedNodeBlueId: "HE4QGLZWbsarW3S32hi6eMrBNaQB2cpuS4WuFHZGiDA6" diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_scalar_sugar_equivalence.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_scalar_sugar_equivalence.yaml new file mode 100644 index 0000000..7e8ce1b --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_scalar_sugar_equivalence.yaml @@ -0,0 +1,8 @@ +id: B_scalar_sugar_equivalence +category: BlueId +operation: calculateBlueId +description: scalar sugar and wrapped scalar are equivalent +input: 1 +expectedNodeBlueId: "GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF" +alsoEquivalentTo: + value: 1 diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_type_alias_rejected_in_direct_blueid_input.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_type_alias_rejected_in_direct_blueid_input.yaml new file mode 100644 index 0000000..9fa2c8a --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_type_alias_rejected_in_direct_blueid_input.yaml @@ -0,0 +1,8 @@ +id: B_type_alias_rejected_in_direct_blueid_input +category: BlueId +operation: calculateBlueId +description: direct BlueId input rejects unresolved aliases in type-position fields +expectError: true +input: + type: Integer + value: 1 diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_unquoted_large_integer_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_unquoted_large_integer_rejected.yaml new file mode 100644 index 0000000..06c2901 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_unquoted_large_integer_rejected.yaml @@ -0,0 +1,7 @@ +id: B_unquoted_large_integer_rejected +category: BlueId +operation: calculateBlueId +description: unquoted integers outside the safe interoperable range are rejected +expectError: true +input: + value: 9007199254740992 diff --git a/src/test/resources/blue-language-1.0/fixtures/circular/C_circular_reference_set_ids.yaml b/src/test/resources/blue-language-1.0/fixtures/circular/C_circular_reference_set_ids.yaml new file mode 100644 index 0000000..42076e0 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/circular/C_circular_reference_set_ids.yaml @@ -0,0 +1,14 @@ +id: C_circular_reference_set_ids +category: Circular +operation: calculateCircularSetBlueIds +description: circular sets produce final MASTER member BlueIds +documents: + - name: A + next: + blueId: this#1 + - name: B + next: + blueId: this#0 +expectedBlueIds: + - "C18ETfS2A7MNmBGo67MYaQrRL9TrUSGwvvEu6KoMqC2R#0" + - "C18ETfS2A7MNmBGo67MYaQrRL9TrUSGwvvEu6KoMqC2R#1" diff --git a/src/test/resources/blue-language-1.0/fixtures/circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml new file mode 100644 index 0000000..6e5e41c --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml @@ -0,0 +1,12 @@ +id: C_duplicate_preliminary_ids_deterministic_or_rejected +category: Circular +operation: calculateCircularSetBlueIds +description: duplicate preliminary IDs use original index as a deterministic tie-break +documents: + - next: + blueId: this#1 + - next: + blueId: this#0 +expectedBlueIds: + - "APkJUKnY7fY7dcCpuq16jHSoQQ6hkgUmRJWHXu6GK6Da#0" + - "APkJUKnY7fY7dcCpuq16jHSoQQ6hkgUmRJWHXu6GK6Da#1" diff --git a/src/test/resources/blue-language-1.0/fixtures/circular/C_this_placeholder_rejected_outside_cyclic_api.yaml b/src/test/resources/blue-language-1.0/fixtures/circular/C_this_placeholder_rejected_outside_cyclic_api.yaml new file mode 100644 index 0000000..6608b81 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/circular/C_this_placeholder_rejected_outside_cyclic_api.yaml @@ -0,0 +1,7 @@ +id: C_this_placeholder_rejected_outside_cyclic_api +category: Circular +operation: calculateBlueId +description: this#k placeholders are rejected by ordinary Node BlueId input +expectError: true +input: + blueId: this#0 diff --git a/src/test/resources/blue-language-1.0/fixtures/circular/C_three_document_cycle_stable_order.yaml b/src/test/resources/blue-language-1.0/fixtures/circular/C_three_document_cycle_stable_order.yaml new file mode 100644 index 0000000..b06e48b --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/circular/C_three_document_cycle_stable_order.yaml @@ -0,0 +1,18 @@ +id: C_three_document_cycle_stable_order +category: Circular +operation: calculateCircularSetBlueIds +description: three-document circular set returns final IDs in original input order +documents: + - name: A + next: + blueId: this#1 + - name: B + next: + blueId: this#2 + - name: C + next: + blueId: this#0 +expectedBlueIds: + - "8vSb4nj6vqUPCySQMaVQKbaHKANXpPhucCrEA6hCxHCf#1" + - "8vSb4nj6vqUPCySQMaVQKbaHKANXpPhucCrEA6hCxHCf#2" + - "8vSb4nj6vqUPCySQMaVQKbaHKANXpPhucCrEA6hCxHCf#0" diff --git a/src/test/resources/blue-language-1.0/fixtures/circular/C_zero_blueid_rejected_in_final_input.yaml b/src/test/resources/blue-language-1.0/fixtures/circular/C_zero_blueid_rejected_in_final_input.yaml new file mode 100644 index 0000000..05d05a8 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/circular/C_zero_blueid_rejected_in_final_input.yaml @@ -0,0 +1,7 @@ +id: C_zero_blueid_rejected_in_final_input +category: Circular +operation: calculateBlueId +description: ZERO_BLUEID placeholders are rejected by finalized Node BlueId input +expectError: true +input: + blueId: "00000000000000000000000000000000000000000000" diff --git a/src/test/resources/blue-language-1.0/fixtures/manifest.yaml b/src/test/resources/blue-language-1.0/fixtures/manifest.yaml new file mode 100644 index 0000000..9f81e9a --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/manifest.yaml @@ -0,0 +1,177 @@ +specVersion: "1.0" +fixturePackageIdentity: "sha256:85e8690260c1b861431b42e2bad77d2959fe7253e12d9950f387fb0a9c0e9399" +fixtures: + - id: B_scalar_sugar_equivalence + category: BlueId + path: blueid/B_scalar_sugar_equivalence.yaml + - id: B_list_sugar_equivalence + category: BlueId + path: blueid/B_list_sugar_equivalence.yaml + - id: B_root_scalar + category: BlueId + path: blueid/B_root_scalar.yaml + - id: B_root_list + category: BlueId + path: blueid/B_root_list.yaml + - id: B_root_empty_object + category: BlueId + path: blueid/B_root_empty_object.yaml + - id: B_root_pure_reference + category: BlueId + path: blueid/B_root_pure_reference.yaml + - id: B_root_null_rejected + category: BlueId + path: blueid/B_root_null_rejected.yaml + - id: B_plain_blueid_validation + category: BlueId + path: blueid/B_plain_blueid_validation.yaml + - id: B_empty_list + category: BlueId + path: blueid/B_empty_list.yaml + - id: B_object_field_null_removal + category: BlueId + path: blueid/B_object_field_null_removal.yaml + - id: B_empty_placeholder + category: BlueId + path: blueid/B_empty_placeholder.yaml + - id: B_null_list_element_rejected + category: BlueId + path: blueid/B_null_list_element_rejected.yaml + - id: B_empty_object_list_element_rejected + category: BlueId + path: blueid/B_empty_object_list_element_rejected.yaml + - id: B_malformed_empty_rejected + category: BlueId + path: blueid/B_malformed_empty_rejected.yaml + - id: B_large_integer_quoted_explicit_integer + category: BlueId + path: blueid/B_large_integer_quoted_explicit_integer.yaml + - id: B_unquoted_large_integer_rejected + category: BlueId + path: blueid/B_unquoted_large_integer_rejected.yaml + - id: B_integer_1_vs_double_1_0 + category: BlueId + path: blueid/B_integer_1_vs_double_1_0.yaml + - id: B_double_1e0 + category: BlueId + path: blueid/B_double_1e0.yaml + - id: B_invalid_this_placeholder_rejected + category: BlueId + path: blueid/B_invalid_this_placeholder_rejected.yaml + - id: B_type_alias_rejected_in_direct_blueid_input + category: BlueId + path: blueid/B_type_alias_rejected_in_direct_blueid_input.yaml + - id: B_previous_invalid_blueid_rejected + category: BlueId + path: blueid/B_previous_invalid_blueid_rejected.yaml + - id: B_pos_rejected + category: BlueId + path: blueid/B_pos_rejected.yaml + - id: B_replace_rejected + category: BlueId + path: blueid/B_replace_rejected.yaml + - id: R_blue_imports_type_itemType_keyType_valueType + category: Resolution + path: resolver/R_blue_imports_type_itemType_keyType_valueType.yaml + - id: R_source_null_list_to_empty + category: Resolution + path: resolver/R_source_null_list_to_empty.yaml + - id: R_source_empty_object_list_to_empty + category: Resolution + path: resolver/R_source_empty_object_list_to_empty.yaml + - id: R_blue_imports + category: Resolution + path: resolver/R_blue_imports.yaml + - id: R_schema_value_shapes + category: Schema + path: resolver/R_schema_value_shapes.yaml + - id: R_schema_large_integer_minimum_with_type_alias + category: Schema + path: resolver/R_schema_large_integer_minimum_with_type_alias.yaml + - id: R_enum_integer_vs_double + category: Schema + path: resolver/R_enum_integer_vs_double.yaml + - id: R_canonical_overlay_no_previous_no_pos + category: Canonicalization + path: resolver/R_canonical_overlay_no_previous_no_pos.yaml + - id: R_inherited_append_only_policy + category: Resolution + path: resolver/R_inherited_append_only_policy.yaml + - id: R_inherited_item_type + category: Resolution + path: resolver/R_inherited_item_type.yaml + - id: R_inherited_keyType_valueType + category: Resolution + path: resolver/R_inherited_keyType_valueType.yaml + - id: R_provider_reference_canonicalizes_back + category: Canonicalization + path: resolver/R_provider_reference_canonicalizes_back.yaml + - id: R_type_aliases_removed_from_canonical_overlay + category: Canonicalization + path: resolver/R_type_aliases_removed_from_canonical_overlay.yaml + - id: R_contracts_merge_as_content + category: Resolution + path: resolver/R_contracts_merge_as_content.yaml + - id: R_top_level_type_name_description_not_inherited + category: Resolution + path: resolver/R_top_level_type_name_description_not_inherited.yaml + - id: R_type_derived_field_removed + category: Canonicalization + path: resolver/R_type_derived_field_removed.yaml + - id: R_instance_field_kept + category: Canonicalization + path: resolver/R_instance_field_kept.yaml + - id: R_provider_reference_with_overlay_keeps_overlay + category: Canonicalization + path: resolver/R_provider_reference_with_overlay_keeps_overlay.yaml + - id: R_contracts_canonicalization_deterministic + category: Canonicalization + path: resolver/R_contracts_canonicalization_deterministic.yaml + - id: R_child_field_labels_materialize_until_overridden + category: Resolution + path: resolver/R_child_field_labels_materialize_until_overridden.yaml + - id: R_canonicalization_deterministic_for_same_resolved_view + category: Canonicalization + path: resolver/R_canonicalization_deterministic_for_same_resolved_view.yaml + - id: F_provider_wrong_blueid_rejected + category: Provider + path: provider/F_provider_wrong_blueid_rejected.yaml + - id: F_provider_missing_content_fails + category: Provider + path: provider/F_provider_missing_content_fails.yaml + - id: F_expand_preserves_node_blueid + category: Provider + path: provider/F_expand_preserves_node_blueid.yaml + - id: F_expand_nested_reference_preserves_node_blueid + category: Provider + path: provider/F_expand_nested_reference_preserves_node_blueid.yaml + - id: F_expand_wrong_nested_provider_content_fails + category: Provider + path: provider/F_expand_wrong_nested_provider_content_fails.yaml + - id: F_expand_missing_nested_content_fails + category: Provider + path: provider/F_expand_missing_nested_content_fails.yaml + - id: F_collapse_preserves_node_blueid + category: Provider + path: provider/F_collapse_preserves_node_blueid.yaml + - id: F_collapse_nested_subtree_preserves_node_blueid + category: Provider + path: provider/F_collapse_nested_subtree_preserves_node_blueid.yaml + - id: F_collapse_does_not_produce_mixed_blueid + category: Provider + path: provider/F_collapse_does_not_produce_mixed_blueid.yaml + - id: C_circular_reference_set_ids + category: Circular + path: circular/C_circular_reference_set_ids.yaml + - id: C_this_placeholder_rejected_outside_cyclic_api + category: Circular + path: circular/C_this_placeholder_rejected_outside_cyclic_api.yaml + - id: C_zero_blueid_rejected_in_final_input + category: Circular + path: circular/C_zero_blueid_rejected_in_final_input.yaml + - id: C_three_document_cycle_stable_order + category: Circular + path: circular/C_three_document_cycle_stable_order.yaml + - id: C_duplicate_preliminary_ids_deterministic_or_rejected + category: Circular + path: circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_does_not_produce_mixed_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_does_not_produce_mixed_blueid.yaml new file mode 100644 index 0000000..5f947bc --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_does_not_produce_mixed_blueid.yaml @@ -0,0 +1,10 @@ +id: F_collapse_does_not_produce_mixed_blueid +category: Provider +operation: collapse +description: collapse emits a pure blueId reference without sibling materialized content +source: + child: 1 + label: ok +expectedCollapsed: + blueId: 4uy5Y7Kisu3mMtuFEeM1WPZAyrUYyGcJ5XiriEoEvvyt +expectedNodeBlueId: "4uy5Y7Kisu3mMtuFEeM1WPZAyrUYyGcJ5XiriEoEvvyt" diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_nested_subtree_preserves_node_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_nested_subtree_preserves_node_blueid.yaml new file mode 100644 index 0000000..0050ade --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_nested_subtree_preserves_node_blueid.yaml @@ -0,0 +1,9 @@ +id: F_collapse_nested_subtree_preserves_node_blueid +category: Provider +operation: collapse +description: collapse returns a pure reference for nested materialized content while preserving root Node BlueId +source: + child: 1 +expectedCollapsed: + blueId: 7YbCaeXGknitZpoejQMKbfVEBiVtxNGSnSEHvo9ubN6o +expectedNodeBlueId: "7YbCaeXGknitZpoejQMKbfVEBiVtxNGSnSEHvo9ubN6o" diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_preserves_node_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_preserves_node_blueid.yaml new file mode 100644 index 0000000..9ead719 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_preserves_node_blueid.yaml @@ -0,0 +1,8 @@ +id: F_collapse_preserves_node_blueid +category: Provider +operation: collapse +description: collapse returns a pure-reference form preserving known content Node BlueId +source: 1 +expectedCollapsed: + blueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF +expectedNodeBlueId: "GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF" diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_missing_nested_content_fails.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_missing_nested_content_fails.yaml new file mode 100644 index 0000000..6a06279 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_missing_nested_content_fails.yaml @@ -0,0 +1,9 @@ +id: F_expand_missing_nested_content_fails +category: Provider +operation: expand +description: nested expansion fails deterministically when provider content is missing +expectError: true +source: + child: + blueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF +provider: [] diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_nested_reference_preserves_node_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_nested_reference_preserves_node_blueid.yaml new file mode 100644 index 0000000..ddba180 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_nested_reference_preserves_node_blueid.yaml @@ -0,0 +1,13 @@ +id: F_expand_nested_reference_preserves_node_blueid +category: Provider +operation: expand +description: provider-backed expansion materializes nested pure references and preserves Node BlueId +source: + child: + blueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF +provider: + - requestedBlueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF + node: 1 +expectedExpanded: + child: 1 +expectedNodeBlueId: "7YbCaeXGknitZpoejQMKbfVEBiVtxNGSnSEHvo9ubN6o" diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_preserves_node_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_preserves_node_blueid.yaml new file mode 100644 index 0000000..94d520d --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_preserves_node_blueid.yaml @@ -0,0 +1,11 @@ +id: F_expand_preserves_node_blueid +category: Provider +operation: expand +description: provider-backed expansion preserves Node BlueId +source: + blueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF +provider: + - requestedBlueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF + node: 1 +expectedExpanded: 1 +expectedNodeBlueId: "GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF" diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_wrong_nested_provider_content_fails.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_wrong_nested_provider_content_fails.yaml new file mode 100644 index 0000000..aac969f --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_wrong_nested_provider_content_fails.yaml @@ -0,0 +1,11 @@ +id: F_expand_wrong_nested_provider_content_fails +category: Provider +operation: expand +description: nested expansion rejects provider content whose BlueId does not match the requested reference +expectError: true +source: + child: + blueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF +provider: + - requestedBlueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF + returnedNode: 2 diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_missing_content_fails.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_missing_content_fails.yaml new file mode 100644 index 0000000..aedf5c0 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_missing_content_fails.yaml @@ -0,0 +1,9 @@ +id: F_provider_missing_content_fails +category: Provider +operation: resolve +description: missing provider content fails resolution +expectError: true +source: + type: + blueId: HE4QGLZWbsarW3S32hi6eMrBNaQB2cpuS4WuFHZGiDA6 +provider: [] diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_wrong_blueid_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_wrong_blueid_rejected.yaml new file mode 100644 index 0000000..202d6fb --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_wrong_blueid_rejected.yaml @@ -0,0 +1,12 @@ +id: F_provider_wrong_blueid_rejected +category: Provider +operation: resolve +description: provider content with the wrong computed BlueId is rejected +expectError: true +source: + type: + blueId: HE4QGLZWbsarW3S32hi6eMrBNaQB2cpuS4WuFHZGiDA6 +provider: + - requestedBlueId: HE4QGLZWbsarW3S32hi6eMrBNaQB2cpuS4WuFHZGiDA6 + returnedNode: + value: actual diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_blue_imports.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_blue_imports.yaml new file mode 100644 index 0000000..1504e28 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_blue_imports.yaml @@ -0,0 +1,15 @@ +id: R_blue_imports +category: Resolution +operation: preprocess +description: portable blue.imports aliases type positions and is removed +source: + blue: + imports: + Person: + blueId: F92yoH8U1LPKtsiJ7qjS4nK4N8tuU88opM5ZkqB9WEPy + type: Person + value: hello +expectedPreprocessed: + type: + blueId: F92yoH8U1LPKtsiJ7qjS4nK4N8tuU88opM5ZkqB9WEPy + value: hello diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_blue_imports_type_itemType_keyType_valueType.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_blue_imports_type_itemType_keyType_valueType.yaml new file mode 100644 index 0000000..312c690 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_blue_imports_type_itemType_keyType_valueType.yaml @@ -0,0 +1,24 @@ +id: R_blue_imports_type_itemType_keyType_valueType +category: Resolution +operation: preprocess +description: portable blue.imports aliases all type-position fields and is removed +source: + blue: + imports: + Person: + blueId: F92yoH8U1LPKtsiJ7qjS4nK4N8tuU88opM5ZkqB9WEPy + type: Person + itemType: Person + keyType: Person + valueType: Person + items: [] +expectedPreprocessed: + type: + blueId: F92yoH8U1LPKtsiJ7qjS4nK4N8tuU88opM5ZkqB9WEPy + itemType: + blueId: F92yoH8U1LPKtsiJ7qjS4nK4N8tuU88opM5ZkqB9WEPy + keyType: + blueId: F92yoH8U1LPKtsiJ7qjS4nK4N8tuU88opM5ZkqB9WEPy + valueType: + blueId: F92yoH8U1LPKtsiJ7qjS4nK4N8tuU88opM5ZkqB9WEPy + items: [] diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_canonical_overlay_no_previous_no_pos.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_canonical_overlay_no_previous_no_pos.yaml new file mode 100644 index 0000000..56759bb --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_canonical_overlay_no_previous_no_pos.yaml @@ -0,0 +1,17 @@ +id: R_canonical_overlay_no_previous_no_pos +category: Canonicalization +operation: canonicalize +description: canonical overlay serializes final list content without $previous or $pos controls +source: + type: + blueId: 6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY + items: + - $pos: 0 + value: A + - value: B +expectedCanonicalOverlay: + type: + blueId: 6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY + items: + - value: A + - value: B diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_canonicalization_deterministic_for_same_resolved_view.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_canonicalization_deterministic_for_same_resolved_view.yaml new file mode 100644 index 0000000..0b9e8d8 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_canonicalization_deterministic_for_same_resolved_view.yaml @@ -0,0 +1,11 @@ +id: R_canonicalization_deterministic_for_same_resolved_view +category: Canonicalization +operation: canonicalize +description: canonicalization is deterministic for a stable source and provider state +source: + type: + inherited: stable + local: stable +expectedCanonicalOverlay: + type: {} + local: stable diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_child_field_labels_materialize_until_overridden.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_child_field_labels_materialize_until_overridden.yaml new file mode 100644 index 0000000..139f8cc --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_child_field_labels_materialize_until_overridden.yaml @@ -0,0 +1,26 @@ +id: R_child_field_labels_materialize_until_overridden +category: Resolution +operation: resolve +description: child labels inherited from a type materialize in resolution +source: + type: + child: + name: Type child + description: Type child description + value: inherited + child: + value: inherited +expectedResolved: + type: + child: + name: Type child + description: Type child description + type: + blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + value: inherited + child: + name: Type child + description: Type child description + type: + blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + value: inherited diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_canonicalization_deterministic.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_canonicalization_deterministic.yaml new file mode 100644 index 0000000..60703b6 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_canonicalization_deterministic.yaml @@ -0,0 +1,17 @@ +id: R_contracts_canonicalization_deterministic +category: Canonicalization +operation: canonicalize +description: inherited and instance contracts canonicalize deterministically as reserved content +source: + type: + contracts: + audit: + enabled: true + contracts: + audit: + retentionDays: 30 +expectedCanonicalOverlay: + type: {} + contracts: + audit: + retentionDays: 30 diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_merge_as_content.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_merge_as_content.yaml new file mode 100644 index 0000000..1b0caa5 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_merge_as_content.yaml @@ -0,0 +1,21 @@ +id: R_contracts_merge_as_content +category: Resolution +operation: resolve +description: contracts merge field-wise as language content +source: + type: + contracts: + audit: + enabled: true + contracts: + audit: + retentionDays: 30 +expectedResolved: + type: + contracts: + audit: + enabled: true + contracts: + audit: + enabled: true + retentionDays: 30 diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_enum_integer_vs_double.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_enum_integer_vs_double.yaml new file mode 100644 index 0000000..a0c32d7 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_enum_integer_vs_double.yaml @@ -0,0 +1,14 @@ +id: R_enum_integer_vs_double +category: Schema +operation: parseSource +description: enum accepts scalar integer and double entries as distinct scalar forms +source: + schema: + enum: + - 1 + - 1.0 +expectedParsed: + schema: + enum: + - 1 + - 1.0 diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_append_only_policy.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_append_only_policy.yaml new file mode 100644 index 0000000..2d91e81 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_append_only_policy.yaml @@ -0,0 +1,15 @@ +id: R_inherited_append_only_policy +category: Resolution +operation: resolve +description: inherited append-only merge policy rejects positional overlays when child omits mergePolicy +expectError: true +source: + type: + type: + blueId: 6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY + mergePolicy: append-only + items: + - value: A + items: + - $pos: 0 + value: B diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_item_type.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_item_type.yaml new file mode 100644 index 0000000..38a2da7 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_item_type.yaml @@ -0,0 +1,13 @@ +id: R_inherited_item_type +category: Resolution +operation: resolve +description: inherited itemType remains effective when child omits itemType +expectError: true +source: + type: + type: + blueId: 6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY + itemType: + blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 + items: + - value: not an integer diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_keyType_valueType.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_keyType_valueType.yaml new file mode 100644 index 0000000..e03845f --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_keyType_valueType.yaml @@ -0,0 +1,15 @@ +id: R_inherited_keyType_valueType +category: Resolution +operation: resolve +description: inherited dictionary keyType and valueType remain effective when child omits them +expectError: true +source: + type: + type: + blueId: G7fBT9PSod1RfHLHkpafAGBDVAJMrMhAMY51ERcyXNrj + keyType: + blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 + valueType: + blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 + notAnIntegerKey: + value: not an integer diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_instance_field_kept.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_instance_field_kept.yaml new file mode 100644 index 0000000..3cfbc9e --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_instance_field_kept.yaml @@ -0,0 +1,11 @@ +id: R_instance_field_kept +category: Canonicalization +operation: canonicalize +description: canonical overlay keeps fields supplied by the instance +source: + type: + inherited: from-type + instanceField: supplied +expectedCanonicalOverlay: + type: {} + instanceField: supplied diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_canonicalizes_back.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_canonicalizes_back.yaml new file mode 100644 index 0000000..dd9dd38 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_canonicalizes_back.yaml @@ -0,0 +1,12 @@ +id: R_provider_reference_canonicalizes_back +category: Canonicalization +operation: canonicalize +description: materialized type reference canonicalizes back to the pure reference used by the source +source: + type: + blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + value: materialized +expectedCanonicalOverlay: + type: + blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + value: materialized diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_with_overlay_keeps_overlay.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_with_overlay_keeps_overlay.yaml new file mode 100644 index 0000000..ec41dc6 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_with_overlay_keeps_overlay.yaml @@ -0,0 +1,15 @@ +id: R_provider_reference_with_overlay_keeps_overlay +category: Canonicalization +operation: canonicalize +description: provider materialization canonicalizes back to a pure reference plus instance overlay +source: + type: + blueId: 5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK + local: overlay +provider: + - requestedBlueId: 5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK + node: {} +expectedCanonicalOverlay: + type: + blueId: 5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK + local: overlay diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_large_integer_minimum_with_type_alias.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_large_integer_minimum_with_type_alias.yaml new file mode 100644 index 0000000..12791fe --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_large_integer_minimum_with_type_alias.yaml @@ -0,0 +1,15 @@ +id: R_schema_large_integer_minimum_with_type_alias +category: Schema +operation: preprocess +description: schema numeric explicit Integer node may use a core alias before preprocessing +source: + schema: + minimum: + type: Integer + value: "9007199254740992" +expectedPreprocessed: + schema: + minimum: + type: + blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 + value: "9007199254740992" diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_value_shapes.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_value_shapes.yaml new file mode 100644 index 0000000..2ba0652 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_value_shapes.yaml @@ -0,0 +1,9 @@ +id: R_schema_value_shapes +category: Schema +operation: parseSource +description: schema keyword value shapes are strict +expectError: true +source: + schema: + required: + value: true diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_empty_object_list_to_empty.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_empty_object_list_to_empty.yaml new file mode 100644 index 0000000..f504d7d --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_empty_object_list_to_empty.yaml @@ -0,0 +1,14 @@ +id: R_source_empty_object_list_to_empty +category: Resolution +operation: preprocess +description: source list empty object normalizes to exact empty placeholder +source: + items: + - A + - {} + - B +expectedPreprocessed: + items: + - value: A + - $empty: true + - value: B diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_null_list_to_empty.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_null_list_to_empty.yaml new file mode 100644 index 0000000..61b6ca7 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_null_list_to_empty.yaml @@ -0,0 +1,14 @@ +id: R_source_null_list_to_empty +category: Resolution +operation: preprocess +description: source list null normalizes to exact empty placeholder +source: + items: + - A + - null + - B +expectedPreprocessed: + items: + - value: A + - $empty: true + - value: B diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_top_level_type_name_description_not_inherited.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_top_level_type_name_description_not_inherited.yaml new file mode 100644 index 0000000..80e427d --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_top_level_type_name_description_not_inherited.yaml @@ -0,0 +1,20 @@ +id: R_top_level_type_name_description_not_inherited +category: Resolution +operation: resolve +description: root name and description from an inline type are not inherited onto the instance root +source: + type: + name: Base Type + description: Base description + inherited: yes + own: child +expectedResolved: + type: + name: Base Type + description: Base description + inherited: + value: yes + inherited: + value: yes + own: + value: child diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_type_aliases_removed_from_canonical_overlay.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_type_aliases_removed_from_canonical_overlay.yaml new file mode 100644 index 0000000..8df0e00 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_type_aliases_removed_from_canonical_overlay.yaml @@ -0,0 +1,15 @@ +id: R_type_aliases_removed_from_canonical_overlay +category: Canonicalization +operation: canonicalize +description: type aliases are removed from canonical overlay after preprocessing +source: + blue: + imports: + Label: + blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + type: Label + value: hello +expectedCanonicalOverlay: + type: + blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + value: hello diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_type_derived_field_removed.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_type_derived_field_removed.yaml new file mode 100644 index 0000000..0692aee --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_type_derived_field_removed.yaml @@ -0,0 +1,11 @@ +id: R_type_derived_field_removed +category: Canonicalization +operation: canonicalize +description: canonical overlay omits content inherited from the type root +source: + type: + inherited: from-type + local: from-instance +expectedCanonicalOverlay: + type: {} + local: from-instance From 56170c0c4d7906b82e4e9656dfe48cc0817e2bdd Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Sun, 24 May 2026 18:51:19 +0200 Subject: [PATCH 2/6] docs: update documentation for stricter schema vocabulary and remove outdated references Removed "Specification Implementation Gaps" section and references to legacy `constraints` migration. Clarified canonical field ingestion rules, rejected content handling, and updated BlueId validation details. Removed outdated `allowMultiple` and refined schema contract examples for consistency. --- README.md | 1 - docs/canonical-language-core.md | 38 +++++++++++++++++++-------------- docs/frozen-type-matching.md | 6 ++---- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 4e06682..fe0a7a9 100644 --- a/README.md +++ b/README.md @@ -874,7 +874,6 @@ For deeper design notes, see: - [Frozen Type Matching](docs/frozen-type-matching.md) - [Processor Contract Matching](docs/processor-contract-matching.md) - [Snapshots, Patching, And Generalization](docs/snapshots-patching-and-generalization.md) -- [Specification Implementation Gaps](docs/specification-implementation-gaps.md) ## Build And Test diff --git a/docs/canonical-language-core.md b/docs/canonical-language-core.md index ce2cba5..1bb33df 100644 --- a/docs/canonical-language-core.md +++ b/docs/canonical-language-core.md @@ -84,24 +84,16 @@ schema: minimum: 0 ``` -Legacy input with `constraints` is migrated: +Input with `constraints` is rejected: ```yaml -name: Legacy +name: Invalid Constraints constraints: minLength: 2 ``` -After parsing, this is represented as: - -```yaml -name: Legacy -schema: - minLength: 2 -``` - -If both `schema` and `constraints` are present, parsing fails because the two -sources of truth would be ambiguous. +Use `schema` directly. This keeps canonical ingestion strict and avoids a +second schema vocabulary in source documents. ## Deterministic Numbers @@ -188,7 +180,13 @@ String structural = blue.calculateBlueId(node); String semantic = blue.calculateSemanticBlueId(node); ``` -`calculateBlueId(node)` hashes the node as provided. +`blue` is a preprocessing directive, not semantic content. It is not valid +BlueId input. + +`calculateBlueId(node)` hashes a node that is already valid BlueId input. It +rejects nodes containing `blue` because silently dropping the directive would +hash unprocessed authored content. It also rejects `blueId` with sibling +content; resolved runtime metadata must be minimized before canonical hashing. `calculateSemanticBlueId(node)` runs: @@ -200,11 +198,19 @@ Use semantic BlueId when authoring noise should not matter. Use structural BlueId when the node is already known to be canonical and you want direct Merkle hashing. +The BlueId algorithm removes nulls and empty maps at any depth. Empty lists are +preserved. If a list element normalizes to an empty map, that element is removed. +Use `$empty: true` when a placeholder must remain as content. + +A leading `$previous` list-control item is a list accumulator seed in the pure +BlueId algorithm. The hash algorithm itself does not verify the seed against an +inherited prefix. Semantic resolution validates that the inherited list prefix +hashes to `$previous.blueId`; if it does not, resolution fails. + ## Provider Ingestion -Provider ingestion now parses and migrates legacy canonical fields before -hashing. For example, `constraints` is migrated to `schema`, and the provider -stores/fetches the migrated content under the corrected hash. +Provider ingestion parses canonical fields strictly before hashing. `constraints` +input is rejected; provider content must use `schema` directly. Provider ingestion does not yet resolve and semantically minimize arbitrary authoring input by default. If that becomes the intended language rule, provider diff --git a/docs/frozen-type-matching.md b/docs/frozen-type-matching.md index 8ff69bd..716ed7b 100644 --- a/docs/frozen-type-matching.md +++ b/docs/frozen-type-matching.md @@ -327,7 +327,6 @@ The frozen matcher verifies the schema keywords currently supported by the Java schema verifier: - `required` -- `allowMultiple` - `minLength` - `maxLength` - `minimum` @@ -343,8 +342,8 @@ schema verifier: - `enum` String length is counted by Unicode code points. Regex pattern validation is -intentionally not part of core schema; contract libraries can perform regex -validation as runtime behavior when they define exact execution semantics. +outside the Blue Language 1.0 schema vocabulary; contract libraries can perform +regex validation as runtime behavior when they define exact execution semantics. ### Presence Semantics @@ -442,7 +441,6 @@ order: lineItems: type: List schema: - allowMultiple: true minItems: 2 maxItems: 2 ``` From a4bb742fc3c4a0d7601e4270259966a98c59b78a Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 26 May 2026 17:25:47 +0200 Subject: [PATCH 3/6] first version passing all the fixtures --- .cz.toml | 2 +- README.md | 80 +- docs/canonical-language-core.md | 4 +- docs/list-controls-and-circular-references.md | 4 +- src/main/java/blue/language/Blue.java | 153 +- .../blue/language/BlueConformanceFailure.java | 15 + .../blue/language/BlueConformanceReport.java | 23 +- .../language/BlueConformanceSuiteRunner.java | 207 +- .../BlueContractsConformanceFailure.java | 42 + .../BlueContractsConformanceReport.java | 229 ++ .../BlueContractsConformanceSuiteRunner.java | 1331 ++++++ .../BlueContractsFixtureCategory.java | 36 + .../blue/language/BlueFixtureCategory.java | 4 +- .../language/BlueLanguageErrorCategory.java | 21 + .../language/BlueLanguageErrorClassifier.java | 132 + src/main/java/blue/language/BlueViewPath.java | 119 + .../conformance/ConformanceEngine.java | 26 + .../conformance/FrozenConformancePlanner.java | 86 +- .../merge/processor/SchemaPropagator.java | 23 +- .../merge/processor/SchemaVerifier.java | 214 +- .../language/preprocess/Preprocessor.java | 42 +- .../processor/BatchPatchTransaction.java | 77 +- .../language/processor/ChannelRunner.java | 90 +- .../CheckpointIdentityCalculator.java | 35 + .../language/processor/CheckpointManager.java | 33 +- .../processor/ConformanceChangedPath.java | 25 + .../processor/ConformancePlannerOverride.java | 18 + .../processor/ContractEffectBuffer.java | 101 + .../language/processor/ContractLoader.java | 81 +- .../processor/ContractMatchingService.java | 6 + .../processor/DocumentProcessingResult.java | 108 +- .../processor/DocumentProcessingRuntime.java | 62 +- .../language/processor/DocumentProcessor.java | 29 + .../blue/language/processor/GasMeter.java | 31 +- .../processor/HandlerMatchContext.java | 14 + .../MustUnderstandFailureException.java | 14 + .../ProcessingDocumentValidator.java | 120 + .../language/processor/ProcessorEngine.java | 302 +- .../processor/ProcessorErrorCategory.java | 23 + .../processor/ProcessorExecutionContext.java | 102 +- .../processor/ProcessorFailureException.java | 27 + .../processor/ProcessorFatalException.java | 14 + .../language/processor/ProcessorStatus.java | 21 + .../language/processor/ScopeExecutor.java | 515 ++- .../processor/ScopeRuntimeContext.java | 29 + .../processor/TerminationService.java | 31 +- .../TypeGeneralizationPolicyResolver.java | 219 + .../conformance/MockExternalChannel.java | 31 + .../MockExternalChannelProcessor.java | 37 + .../processor/conformance/MockHandler.java | 94 + .../conformance/MockHandlerProcessor.java | 144 + .../conformance/MockTypeBlueIds.java | 12 + .../conformance/ScriptedContractsRuntime.java | 1065 +++++ .../model/ChannelEventCheckpoint.java | 35 +- .../processor/model/DocumentUpdate.java | 3 +- .../model/DocumentUpdateChannel.java | 3 +- .../processor/model/EmbeddedNodeChannel.java | 3 +- .../processor/model/InitializationMarker.java | 3 +- .../language/processor/model/JsonPatch.java | 3 +- .../processor/model/LifecycleChannel.java | 3 +- .../processor/model/ProcessEmbedded.java | 3 +- .../model/ProcessingTerminatedMarker.java | 5 +- .../model/TriggeredEventChannel.java | 3 +- .../model/TypeGeneralizationPolicy.java | 29 + .../model/TypeGeneralizationRule.java | 37 + .../registry/BlueRuntimeTypeRegistry.java | 461 +++ .../processor/registry/RuntimeBlueIds.java | 77 + .../processor/registry/RuntimeTypeKey.java | 24 + .../language/processor/util/PointerUtils.java | 61 + .../util/ProcessorPointerConstants.java | 5 - .../language/provider/BootstrapProvider.java | 7 +- .../registry/BlueCoreTypeRegistry.java | 168 + .../snapshot/CanonicalOverlayPatchEngine.java | 62 +- .../snapshot/FrozenNodeToBlueIdInput.java | 23 + .../java/blue/language/utils/BlueNumbers.java | 84 + .../utils/CircularBlueIdCalculator.java | 14 + .../language/utils/FrozenTypeMatcher.java | 96 +- .../language/utils/NodeToBlueIdInput.java | 23 + .../java/blue/language/utils/Properties.java | 12 +- src/main/java/blue/language/utils/Types.java | 45 +- .../registry/blue-contracts-1.0/Channel.blue | 14 + .../ChannelEventCheckpoint.blue | 24 + .../registry/blue-contracts-1.0/Contract.blue | 17 + .../ContractExecutionResult.blue | 37 + .../DocumentProcessingFatalError.blue | 11 + .../DocumentProcessingInitiated.blue | 15 + .../DocumentProcessingTerminated.blue | 18 + .../blue-contracts-1.0/DocumentUpdate.blue | 29 + .../DocumentUpdateChannel.blue | 20 + .../EmbeddedNodeChannel.blue | 18 + .../registry/blue-contracts-1.0/Handler.blue | 22 + .../blue-contracts-1.0/JsonPatchEntry.blue | 32 + .../LifecycleEventChannel.blue | 10 + .../registry/blue-contracts-1.0/Marker.blue | 9 + .../blue-contracts-1.0/ProcessEmbedded.blue | 22 + .../ProcessingInitializedMarker.blue | 16 + .../ProcessingTerminatedMarker.blue | 24 + .../TriggeredEventChannel.blue | 9 + .../TypeGeneralizationPolicy.blue | 25 + .../TypeGeneralizationRule.blue | 25 + .../registry/blue-contracts-1.0/manifest.yaml | 89 + .../registry/blue-language-1.0/Boolean.blue | 6 + .../blue-language-1.0/Dictionary.blue | 17 + .../registry/blue-language-1.0/Double.blue | 12 + .../registry/blue-language-1.0/Integer.blue | 11 + .../registry/blue-language-1.0/List.blue | 14 + .../registry/blue-language-1.0/Text.blue | 9 + .../registry/blue-language-1.0/manifest.yaml | 8 + .../resources/transformation/DefaultBlue.blue | 12 +- .../language/BlueConformanceReportTest.java | 10 +- .../java/blue/language/BlueViewPathTest.java | 53 + .../language/NodeToMapListOrValueTest.java | 2 +- .../blue/language/SchemaVerifierTest.java | 35 + .../java/blue/language/SelfReferenceTest.java | 9 +- .../BlueLanguageConformanceFixtureTest.java | 45 + .../mapping/NodeToObjectConverterTest.java | 6 +- .../language/processor/ChannelRunnerTest.java | 34 +- .../CheckpointIdentityCalculatorTest.java | 83 + .../processor/ContractBundleCacheTest.java | 20 +- .../ContractMappingIntegrationTest.java | 10 +- ...cumentProcessingRuntimeBatchPatchTest.java | 4 +- .../DocumentProcessorBatchPatchTest.java | 62 +- .../DocumentProcessorBoundaryTest.java | 11 +- .../DocumentProcessorCapabilityTest.java | 107 +- ...ocumentProcessorEventImmutabilityTest.java | 10 +- .../processor/DocumentProcessorGasTest.java | 77 +- .../DocumentProcessorGeneralizationTest.java | 341 +- .../DocumentProcessorHandlerFailureTest.java | 154 + .../DocumentProcessorInitializationTest.java | 129 +- ...umentProcessorSnapshotTransactionTest.java | 38 +- .../DocumentProcessorTerminationTest.java | 39 +- .../processor/DocumentUpdateChannelTest.java | 70 +- .../processor/ProcessEmbeddedTest.java | 239 +- .../ProcessorExecutionContextTest.java | 57 +- .../processor/ProcessorStaticSafetyTest.java | 181 + .../processor/ProcessorTestSupport.java | 91 + .../processor/TestEventChannelTest.java | 57 +- .../BlueContractsConformanceFixtureTest.java | 516 +++ .../BlueContractsConformanceReportTest.java | 56 + .../ApplyBatchPatchContractProcessor.java | 6 + .../contracts/TestEventChannelProcessor.java | 2 +- .../ExternalContractIntegrationTest.java | 66 +- .../processor/model/ApplyBatchPatch.java | 16 +- .../processor/model/AssertDocumentUpdate.java | 2 +- .../language/processor/model/CutOffProbe.java | 2 +- .../language/processor/model/EmitEvents.java | 2 +- .../processor/model/IncrementProperty.java | 2 +- .../processor/model/MutateEmbeddedPaths.java | 2 +- .../language/processor/model/MutateEvent.java | 2 +- .../model/ProcessingFailureMarker.java | 2 +- .../processor/model/RecordDocumentUpdate.java | 2 +- .../processor/model/RemoveIfPresent.java | 2 +- .../processor/model/RemoveProperty.java | 2 +- .../language/processor/model/SetProperty.java | 2 +- .../processor/model/SetPropertyOnEvent.java | 2 +- .../processor/model/TerminateScope.java | 2 +- .../language/processor/model/TestEvent.java | 4 +- .../processor/model/TestEventChannel.java | 2 +- .../registry/BlueRuntimeTypeRegistryTest.java | 82 + .../processor/util/PointerUtilsTest.java | 23 + .../language/utils/BlueIdCalculatorTest.java | 4 +- ...012_checkpoint_lazy_create_and_update.yaml | 33 + ...T013_stale_event_no_checkpoint_update.yaml | 43 + .../checkpointDefaultUsesContentBlueId.yaml | 56 + ...EventIdDoesNotOverrideDefaultIdentity.yaml | 55 + ...ointNodeBlueIdModeRequiresBlueIdInput.yaml | 33 + .../checkpointStoresPreprocessedSubject.yaml | 42 + ...ithSlashAndUsesEscapedPointerForWrite.yaml | 47 + .../contractKeyEmptyRejected.yaml | 15 + .../contractKeyReservedTypeRejected.yaml | 14 + .../contractKeyReservedValueRejected.yaml | 15 + ...KeySlashStoredRawEscapedOnlyInPointer.yaml | 45 + ...napshot_stable_after_handler_mutation.yaml | 37 + ...dler_does_not_affect_current_delivery.yaml | 34 + ...s_not_affect_current_delivery_content.yaml | 46 + ...ing_delivery_does_not_run_immediately.yaml | 31 + ...s_not_remove_current_phase3_candidate.yaml | 37 + ...apshots_handlers_before_first_handler.yaml | 47 + ...esNotAffectAlreadySnapshottedEmission.yaml | 37 + ...eDoesNotRemoveCurrentEmissionDelivery.yaml | 36 + ...ectCurrentEventButCanAffectLaterEvent.yaml | 54 + ...l_added_by_patch_receives_same_update.yaml | 34 + ...by_patch_does_not_receive_same_update.yaml | 44 + ...ateNullSentinelsAreRuntimePayloadOnly.yaml | 51 + ...nPatchStillAppliesPatchBeforeEmission.yaml | 52 + ...tchStillAppliesPatchBeforeTermination.yaml | 56 + ...sAfterBufferingPatchDiscardsOwnBuffer.yaml | 45 + ...10_embedded_bridge_before_parent_fifo.yaml | 72 + .../T011_embedded_path_slash_fatal.yaml | 21 + .../T029_duplicate_embedded_paths_fatal.yaml | 21 + .../T030_malformed_embedded_path_fatal.yaml | 19 + ...ded_path_skipped_and_marked_processed.yaml | 32 + .../T032_embedded_path_non_object_fatal.yaml | 21 + ...bedded_rereads_paths_after_each_child.yaml | 54 + ...o_resurrection_after_remove_and_readd.yaml | 58 + ..._uses_processed_paths_insertion_order.yaml | 74 + ...ly_when_delivered_to_matching_channel.yaml | 43 + ...t_scope_and_cannot_patch_inside_child.yaml | 46 + ...mittedEventsIncludeRuntimeTypeBlueIds.yaml | 53 + .../fixtures/fixture_update_summary.md | 31 + .../gas/T019_gas_boundary_per_patch.yaml | 32 + .../T069_boundary_gas_per_patch_exact.yaml | 32 + ...s_only_for_participating_scopes_exact.yaml | 39 + ...mpt_gas_for_rejected_candidates_exact.yaml | 31 + ...no_free_external_channel_prefiltering.yaml | 23 + .../T073_emit_gas_only_after_validation.yaml | 21 + .../gas/T074_consume_gas_negative_fatal.yaml | 26 + ...uses_embedded_depth_not_pointer_depth.yaml | 35 + ...zy_checkpoint_creation_costs_zero_gas.yaml | 19 + ...kpoint_update_costs_configured_amount.yaml | 29 + ...e_termination_costs_configured_amount.yaml | 22 + ...neralization_nearest_valid_child_type.yaml | 62 + ...eralization_propagates_to_parent_type.yaml | 74 + ...licy_floor_rejects_overgeneralization.yaml | 66 + ...alization_reject_mode_fatal_no_commit.yaml | 58 + ...ion_type_writes_emit_document_updates.yaml | 74 + ..._patch_cannot_generalize_parent_scope.yaml | 66 + ...nitialized_document_initializes_scope.yaml | 16 + ...ization_lifecycle_before_marker_write.yaml | 27 + ...marker_patch_triggers_document_update.yaml | 28 + ...ialization_does_not_create_checkpoint.yaml | 15 + ...triggered_event_drains_only_in_phase5.yaml | 34 + ...BlueIdComputedBeforeInitializedMarker.yaml | 25 + .../blue-contracts-1.0/fixtures/manifest.yaml | 433 ++ ...stand_initial_unsupported_no_mutation.yaml | 20 + ..._contract_in_terminated_scope_ignored.yaml | 34 + ...nsupported_contract_after_patch_fatal.yaml | 30 + ...tial_closure_includes_embedded_scopes.yaml | 24 + ...supported_in_terminated_scope_ignored.yaml | 43 + ...in_initial_closure_capability_failure.yaml | 24 + ...runtime_unsupported_after_patch_fatal.yaml | 31 + ...oleUnsupportedSubjectToMustUnderstand.yaml | 19 + ...BytesUseRuntimeInsertionNormalization.yaml | 46 + ...BytesUseRuntimeInsertionNormalization.yaml | 49 + ...NodeInsertionRejectsRootBlueDirective.yaml | 46 + ...paths_mutation_allowed_only_for_paths.yaml | 151 + ...mbedded_marker_type_patch_still_fatal.yaml | 56 + ...dded_marker_whole_replace_still_fatal.yaml | 58 + .../T006_patch_cascade_after_each_patch.yaml | 64 + .../T016_reserved_key_patch_fatal.yaml | 28 + .../T041_patch_root_path_rejected.yaml | 25 + ...ing_intermediate_objects_materializes.yaml | 25 + ...atch_does_not_auto_materialize_arrays.yaml | 26 + ...ch_remove_missing_object_member_fatal.yaml | 32 + ...5_patch_replace_object_member_upserts.yaml | 25 + ...tch_array_leading_zero_index_rejected.yaml | 28 + ...patch_array_dash_only_allowed_for_add.yaml | 28 + ..._ab_not_inside_a_for_patch_boundaries.yaml | 34 + ...reserved_initialized_path_patch_fatal.yaml | 26 + ...ved_checkpoint_descendant_patch_fatal.yaml | 25 + ..._preserving_reserved_subtrees_allowed.yaml | 46 + ...patch_changing_reserved_subtree_fatal.yaml | 34 + ...d_child_root_containing_reserved_keys.yaml | 37 + ...ch_inside_embedded_child_reserved_key.yaml | 37 + .../pointer/T017_pointer_ab_not_inside_a.yaml | 6 + .../T038_pointer_empty_string_rejected.yaml | 6 + .../T039_pointer_bad_tilde_rejected.yaml | 6 + .../T040_pointer_trailing_slash_rejected.yaml | 6 + .../T001_registry_runtime_type_blueids.yaml | 26 + ...ngRuntimeTypeDescriptionChangesBlueId.yaml | 11 + ...FromPublishedPreprocessingEnvironment.yaml | 11 + ...CheckpointNodeHashesToPublishedBlueId.yaml | 8 + ...tryChannelNodeHashesToPublishedBlueId.yaml | 8 + ...tionResultNodeHashesToPublishedBlueId.yaml | 8 + ...ryContractNodeHashesToPublishedBlueId.yaml | 8 + ...yDocumentIdFieldsUseTextBlueIdStrings.yaml | 15 + ...ateChannelNodeHashesToPublishedBlueId.yaml | 8 + ...pdateEventNodeHashesToPublishedBlueId.yaml | 8 + ...odeChannelNodeHashesToPublishedBlueId.yaml | 8 + ...ErrorEventNodeHashesToPublishedBlueId.yaml | 8 + ...tryHandlerNodeHashesToPublishedBlueId.yaml | 8 + ...PatchEntryNodeHashesToPublishedBlueId.yaml | 8 + ...entChannelNodeHashesToPublishedBlueId.yaml | 8 + ...stryMarkerNodeHashesToPublishedBlueId.yaml | 8 + ...ssEmbeddedNodeHashesToPublishedBlueId.yaml | 8 + ...izedMarkerNodeHashesToPublishedBlueId.yaml | 8 + ...iatedEventNodeHashesToPublishedBlueId.yaml | 8 + ...natedEventNodeHashesToPublishedBlueId.yaml | 8 + ...atedMarkerNodeHashesToPublishedBlueId.yaml | 8 + ...entChannelNodeHashesToPublishedBlueId.yaml | 8 + ...tionPolicyNodeHashesToPublishedBlueId.yaml | 8 + ...zationRuleNodeHashesToPublishedBlueId.yaml | 8 + .../T014_root_graceful_termination.yaml | 39 + ...15_root_fatal_termination_event_order.yaml | 44 + ..._marker_direct_write_does_not_cascade.yaml | 36 + ...56_graceful_root_termination_ends_run.yaml | 25 + ...l_appends_terminated_then_fatal_event.yaml | 23 + ...8_fatal_error_not_lifecycle_delivered.yaml | 40 + ...entrancy_no_duplicate_marker_or_event.yaml | 31 + ..._post_termination_emit_and_patch_noop.yaml | 35 + ...rmination_lifecycle_bridges_to_parent.yaml | 45 + ..._does_not_escalate_to_root_by_default.yaml | 45 + ...n_lifecycle_appends_exactly_one_fatal.yaml | 34 + ...edContractsFallbackOrTerminationError.yaml | 23 + ...gered_fifo_not_drained_during_cascade.yaml | 76 + ...0_emit_invalid_event_fatal_before_gas.yaml | 41 + .../fixtures/blueid/B_double_1e0.yaml | 2 +- .../blueid/B_double_negative_zero.yaml | 14 + .../blueid/B_double_overflow_rejected.yaml | 9 + .../fixtures/blueid/B_empty_list.yaml | 2 +- .../B_empty_object_list_element_rejected.yaml | 6 +- .../fixtures/blueid/B_empty_placeholder.yaml | 8 +- .../blueid/B_integer_1_vs_double_1_0.yaml | 2 +- ...large_integer_quoted_explicit_integer.yaml | 9 +- .../blueid/B_list_sugar_equivalence.yaml | 10 +- .../blueid/B_malformed_empty_rejected.yaml | 2 +- .../blueid/B_null_list_element_rejected.yaml | 6 +- .../blueid/B_object_field_null_removal.yaml | 2 +- .../B_payload_only_scalar_typed_identity.yaml | 11 + .../fixtures/blueid/B_pos_rejected.yaml | 5 +- .../B_previous_invalid_blueid_rejected.yaml | 7 +- .../fixtures/blueid/B_replace_rejected.yaml | 6 +- .../fixtures/blueid/B_root_empty_object.yaml | 2 +- .../fixtures/blueid/B_root_list.yaml | 6 +- .../blueid/B_root_pure_reference.yaml | 4 +- .../fixtures/blueid/B_root_scalar.yaml | 2 +- .../blueid/B_scalar_sugar_equivalence.yaml | 2 +- ...alias_rejected_in_direct_blueid_input.yaml | 1 + .../C_circular_reference_set_ids.yaml | 16 +- ...iminary_ids_deterministic_or_rejected.yaml | 16 +- .../C_three_document_cycle_stable_order.yaml | 24 +- ...C_zero_blueid_rejected_in_final_input.yaml | 2 +- ...rofile_era_language_conformance_terms.yaml | 45 + .../blue-language-1.0/fixtures/manifest.yaml | 409 +- ...ollapse_does_not_produce_mixed_blueid.yaml | 4 +- ..._nested_subtree_preserves_node_blueid.yaml | 7 +- .../F_collapse_preserves_node_blueid.yaml | 7 +- ...F_expand_missing_nested_content_fails.yaml | 2 +- ...ested_reference_preserves_node_blueid.yaml | 11 +- .../F_expand_preserves_node_blueid.yaml | 8 +- ...d_wrong_nested_provider_content_fails.yaml | 9 +- .../F_provider_missing_content_fails.yaml | 2 +- .../F_provider_wrong_blueid_rejected.yaml | 9 +- ...ngingCoreTypeDescriptionChangesBlueId.yaml | 9 + ...tryBooleanNodeHashesToPublishedBlueId.yaml | 7 + ...DictionaryNodeHashesToPublishedBlueId.yaml | 7 + ...stryDoubleNodeHashesToPublishedBlueId.yaml | 7 + ...tryIntegerNodeHashesToPublishedBlueId.yaml | 7 + ...gistryListNodeHashesToPublishedBlueId.yaml | 7 + ...gistryTextNodeHashesToPublishedBlueId.yaml | 7 + ..._canonical_overlay_no_previous_no_pos.yaml | 17 +- ...d_labels_materialize_until_overridden.yaml | 4 +- ...tracts_canonicalization_deterministic.yaml | 3 +- ..._type_compatibility_nominal_by_blueid.yaml | 11 + .../resolver/R_enum_integer_vs_double.yaml | 8 +- .../R_inherited_append_only_policy.yaml | 11 +- .../resolver/R_inherited_item_type.yaml | 6 +- .../R_inherited_keyType_valueType.yaml | 9 +- ...provider_reference_canonicalizes_back.yaml | 7 +- ..._reference_with_overlay_keeps_overlay.yaml | 7 +- .../R_schema_double_multiple_of_exact.yaml | 17 + ...iple_of_rejects_decimal_approximation.yaml | 12 + ...a_enum_order_and_duplicates_canonical.yaml | 26 + ..._schema_integer_multiple_of_lcm_merge.yaml | 17 + ...large_integer_minimum_with_type_alias.yaml | 6 +- .../resolver/R_schema_value_shapes.yaml | 1 + ...R_schema_wrong_kind_keywords_rejected.yaml | 10 + .../R_source_empty_object_list_to_empty.yaml | 12 +- .../resolver/R_source_null_list_to_empty.yaml | 12 +- ..._recursive_empty_object_list_to_empty.yaml | 15 + ...l_type_name_description_not_inherited.yaml | 9 +- ...liases_removed_from_canonical_overlay.yaml | 4 +- .../R_view_path_root_is_empty_string.yaml | 25 + src/test/resources/contract/1.0/spec.md | 3634 +++++++++++++++++ src/test/resources/language/1.0/spec.md | 2932 +++++++++++++ .../processor/contracts/all-contracts.blue | 20 +- 366 files changed, 22043 insertions(+), 1285 deletions(-) create mode 100644 src/main/java/blue/language/BlueContractsConformanceFailure.java create mode 100644 src/main/java/blue/language/BlueContractsConformanceReport.java create mode 100644 src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java create mode 100644 src/main/java/blue/language/BlueContractsFixtureCategory.java create mode 100644 src/main/java/blue/language/BlueLanguageErrorCategory.java create mode 100644 src/main/java/blue/language/BlueLanguageErrorClassifier.java create mode 100644 src/main/java/blue/language/BlueViewPath.java create mode 100644 src/main/java/blue/language/processor/CheckpointIdentityCalculator.java create mode 100644 src/main/java/blue/language/processor/ConformanceChangedPath.java create mode 100644 src/main/java/blue/language/processor/ConformancePlannerOverride.java create mode 100644 src/main/java/blue/language/processor/ContractEffectBuffer.java create mode 100644 src/main/java/blue/language/processor/ProcessingDocumentValidator.java create mode 100644 src/main/java/blue/language/processor/ProcessorErrorCategory.java create mode 100644 src/main/java/blue/language/processor/ProcessorFailureException.java create mode 100644 src/main/java/blue/language/processor/ProcessorStatus.java create mode 100644 src/main/java/blue/language/processor/TypeGeneralizationPolicyResolver.java create mode 100644 src/main/java/blue/language/processor/conformance/MockExternalChannel.java create mode 100644 src/main/java/blue/language/processor/conformance/MockExternalChannelProcessor.java create mode 100644 src/main/java/blue/language/processor/conformance/MockHandler.java create mode 100644 src/main/java/blue/language/processor/conformance/MockHandlerProcessor.java create mode 100644 src/main/java/blue/language/processor/conformance/MockTypeBlueIds.java create mode 100644 src/main/java/blue/language/processor/conformance/ScriptedContractsRuntime.java create mode 100644 src/main/java/blue/language/processor/model/TypeGeneralizationPolicy.java create mode 100644 src/main/java/blue/language/processor/model/TypeGeneralizationRule.java create mode 100644 src/main/java/blue/language/processor/registry/BlueRuntimeTypeRegistry.java create mode 100644 src/main/java/blue/language/processor/registry/RuntimeBlueIds.java create mode 100644 src/main/java/blue/language/processor/registry/RuntimeTypeKey.java create mode 100644 src/main/java/blue/language/registry/BlueCoreTypeRegistry.java create mode 100644 src/main/resources/registry/blue-contracts-1.0/Channel.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/ChannelEventCheckpoint.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/Contract.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/ContractExecutionResult.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/DocumentProcessingFatalError.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/DocumentProcessingInitiated.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/DocumentProcessingTerminated.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/DocumentUpdate.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/DocumentUpdateChannel.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/EmbeddedNodeChannel.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/Handler.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/JsonPatchEntry.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/LifecycleEventChannel.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/Marker.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/ProcessEmbedded.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/ProcessingInitializedMarker.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/ProcessingTerminatedMarker.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/TriggeredEventChannel.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/TypeGeneralizationPolicy.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/TypeGeneralizationRule.blue create mode 100644 src/main/resources/registry/blue-contracts-1.0/manifest.yaml create mode 100644 src/main/resources/registry/blue-language-1.0/Boolean.blue create mode 100644 src/main/resources/registry/blue-language-1.0/Dictionary.blue create mode 100644 src/main/resources/registry/blue-language-1.0/Double.blue create mode 100644 src/main/resources/registry/blue-language-1.0/Integer.blue create mode 100644 src/main/resources/registry/blue-language-1.0/List.blue create mode 100644 src/main/resources/registry/blue-language-1.0/Text.blue create mode 100644 src/main/resources/registry/blue-language-1.0/manifest.yaml create mode 100644 src/test/java/blue/language/BlueViewPathTest.java create mode 100644 src/test/java/blue/language/processor/CheckpointIdentityCalculatorTest.java create mode 100644 src/test/java/blue/language/processor/DocumentProcessorHandlerFailureTest.java create mode 100644 src/test/java/blue/language/processor/ProcessorStaticSafetyTest.java create mode 100644 src/test/java/blue/language/processor/ProcessorTestSupport.java create mode 100644 src/test/java/blue/language/processor/conformance/BlueContractsConformanceFixtureTest.java create mode 100644 src/test/java/blue/language/processor/conformance/BlueContractsConformanceReportTest.java rename src/{main => test}/java/blue/language/processor/model/ProcessingFailureMarker.java (88%) create mode 100644 src/test/java/blue/language/processor/registry/BlueRuntimeTypeRegistryTest.java create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/checkpoint/T012_checkpoint_lazy_create_and_update.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/checkpoint/T013_stale_event_no_checkpoint_update.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointDefaultUsesContentBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointEventIdDoesNotOverrideDefaultIdentity.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointNodeBlueIdModeRequiresBlueIdInput.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointStoresPreprocessedSubject.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointStoresRawChannelKeyWithSlashAndUsesEscapedPointerForWrite.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyEmptyRejected.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyReservedTypeRejected.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyReservedValueRejected.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeySlashStoredRawEscapedOnlyInPointer.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T018_dispatch_snapshot_stable_after_handler_mutation.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T064_removing_later_handler_does_not_affect_current_delivery.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T065_replacing_later_handler_does_not_affect_current_delivery_content.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T066_adding_handler_during_delivery_does_not_run_immediately.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T067_removing_later_external_channel_does_not_remove_current_phase3_candidate.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T068_document_update_delivery_snapshots_handlers_before_first_handler.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/embeddedNodeChannelAddedDuringBridgeDoesNotAffectAlreadySnapshottedEmission.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/embeddedNodeChannelRemovedDuringBridgeDoesNotRemoveCurrentEmissionDelivery.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/triggeredChannelAddedDuringDrainDoesNotAffectCurrentEventButCanAffectLaterEvent.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/document-update/T007_document_update_channel_added_by_patch_receives_same_update.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/document-update/T008_document_update_channel_removed_by_patch_does_not_receive_same_update.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/document-update/documentUpdateNullSentinelsAreRuntimePayloadOnly.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/effects/handlerCallingEmitThenPatchStillAppliesPatchBeforeEmission.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/effects/handlerCallingTerminateThenPatchStillAppliesPatchBeforeTermination.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/effects/handlerThrowsAfterBufferingPatchDiscardsOwnBuffer.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T010_embedded_bridge_before_parent_fifo.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T011_embedded_path_slash_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T029_duplicate_embedded_paths_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T030_malformed_embedded_path_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T031_missing_embedded_path_skipped_and_marked_processed.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T032_embedded_path_non_object_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T033_embedded_rereads_paths_after_each_child.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T034_embedded_no_resurrection_after_remove_and_readd.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T035_bridge_uses_processed_paths_insertion_order.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T036_bridge_charges_only_when_delivered_to_matching_channel.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/embedded/T037_embedded_node_handler_runs_in_parent_scope_and_cannot_patch_inside_child.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/events/processorEmittedEventsIncludeRuntimeTypeBlueIds.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/fixture_update_summary.md create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T019_gas_boundary_per_patch.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T069_boundary_gas_per_patch_exact.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T070_cascade_gas_only_for_participating_scopes_exact.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T071_external_channel_attempt_gas_for_rejected_candidates_exact.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T072_no_free_external_channel_prefiltering.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T073_emit_gas_only_after_validation.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T074_consume_gas_negative_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T075_scope_entry_gas_uses_embedded_depth_not_pointer_depth.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T076_lazy_checkpoint_creation_costs_zero_gas.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T077_checkpoint_update_costs_configured_amount.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/gas/T078_direct_write_termination_costs_configured_amount.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/generalization/T079_generalization_nearest_valid_child_type.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/generalization/T080_generalization_propagates_to_parent_type.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/generalization/T081_generalization_policy_floor_rejects_overgeneralization.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/generalization/T082_generalization_reject_mode_fatal_no_commit.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/generalization/T083_generalization_type_writes_emit_document_updates.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/generalization/T084_embedded_child_patch_cannot_generalize_parent_scope.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/initialization/T002_process_uninitialized_document_initializes_scope.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/initialization/T021_initialization_lifecycle_before_marker_write.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/initialization/T022_initialization_marker_patch_triggers_document_update.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/initialization/T023_initialization_does_not_create_checkpoint.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/initialization/T024_lifecycle_emitted_triggered_event_drains_only_in_phase5.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/initialization/initializationContentBlueIdComputedBeforeInitializedMarker.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/manifest.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/must-understand/T003_must_understand_initial_unsupported_no_mutation.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/must-understand/T004_unsupported_contract_in_terminated_scope_ignored.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/must-understand/T005_runtime_unsupported_contract_after_patch_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/must-understand/T025_initial_closure_includes_embedded_scopes.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/must-understand/T026_unsupported_in_terminated_scope_ignored.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/must-understand/T027_invalid_terminated_marker_in_initial_closure_capability_failure.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/must-understand/T028_runtime_unsupported_after_patch_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/must-understand/extensionRoleUnsupportedSubjectToMustUnderstand.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/normalization/emitGasBytesUseRuntimeInsertionNormalization.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/normalization/patchGasBytesUseRuntimeInsertionNormalization.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/normalization/runtimeNodeInsertionRejectsRootBlueDirective.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T001_dynamic_embedded_paths_mutation_allowed_only_for_paths.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T001b_embedded_marker_type_patch_still_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T001c_embedded_marker_whole_replace_still_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T006_patch_cascade_after_each_patch.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T016_reserved_key_patch_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T041_patch_root_path_rejected.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T042_patch_add_missing_intermediate_objects_materializes.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T043_patch_does_not_auto_materialize_arrays.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T044_patch_remove_missing_object_member_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T045_patch_replace_object_member_upserts.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T046_patch_array_leading_zero_index_rejected.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T047_patch_array_dash_only_allowed_for_add.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T048_ab_not_inside_a_for_patch_boundaries.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T049_reserved_initialized_path_patch_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T050_reserved_checkpoint_descendant_patch_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T051_contracts_whole_map_patch_preserving_reserved_subtrees_allowed.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T052_contracts_whole_map_patch_changing_reserved_subtree_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T053_parent_may_replace_embedded_child_root_containing_reserved_keys.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/patching/T054_parent_may_not_patch_inside_embedded_child_reserved_key.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/pointer/T017_pointer_ab_not_inside_a.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/pointer/T038_pointer_empty_string_rejected.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/pointer/T039_pointer_bad_tilde_rejected.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/pointer/T040_pointer_trailing_slash_rejected.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/T001_registry_runtime_type_blueids.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/changingRuntimeTypeDescriptionChangesBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryBlueIdsRecomputeFromPublishedPreprocessingEnvironment.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryChannelEventCheckpointNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryChannelNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryContractExecutionResultNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryContractNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentIdFieldsUseTextBlueIdStrings.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentUpdateChannelNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentUpdateEventNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryEmbeddedNodeChannelNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryFatalErrorEventNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryHandlerNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryJsonPatchEntryNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryLifecycleEventChannelNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryMarkerNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessEmbeddedNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingInitializedMarkerNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingInitiatedEventNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingTerminatedEventNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingTerminatedMarkerNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTriggeredEventChannelNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTypeGeneralizationPolicyNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTypeGeneralizationRuleNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T014_root_graceful_termination.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T015_root_fatal_termination_event_order.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T055_termination_marker_direct_write_does_not_cascade.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T056_graceful_root_termination_ends_run.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T057_root_fatal_appends_terminated_then_fatal_event.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T058_fatal_error_not_lifecycle_delivered.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T059_termination_reentrancy_no_duplicate_marker_or_event.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T060_post_termination_emit_and_patch_noop.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T061_child_termination_lifecycle_bridges_to_parent.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T062_non_root_fatal_does_not_escalate_to_root_by_default.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/T063_fatal_during_root_termination_lifecycle_appends_exactly_one_fatal.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/termination/terminationDirectWriteMalformedContractsFallbackOrTerminationError.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/triggered-fifo/T009_triggered_fifo_not_drained_during_cascade.yaml create mode 100644 src/test/resources/blue-contracts-1.0/fixtures/triggered-fifo/T020_emit_invalid_event_fatal_before_gas.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_double_negative_zero.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_double_overflow_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/blueid/B_payload_only_scalar_typed_identity.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/lint/L_no_profile_era_language_conformance_terms.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/registry/changingCoreTypeDescriptionChangesBlueId.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryBooleanNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryDictionaryNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryDoubleNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryIntegerNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryListNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryTextNodeHashesToPublishedBlueId.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_core_type_compatibility_nominal_by_blueid.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_double_multiple_of_exact.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_double_multiple_of_rejects_decimal_approximation.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_enum_order_and_duplicates_canonical.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_integer_multiple_of_lcm_merge.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_wrong_kind_keywords_rejected.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_source_recursive_empty_object_list_to_empty.yaml create mode 100644 src/test/resources/blue-language-1.0/fixtures/resolver/R_view_path_root_is_empty_string.yaml create mode 100644 src/test/resources/contract/1.0/spec.md create mode 100644 src/test/resources/language/1.0/spec.md 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 fe0a7a9..8195d2d 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,12 @@ Java implementation of the Blue language core: https://language.blue/docs/reference/specification -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: @@ -634,10 +639,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; @@ -681,10 +684,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 NodeProvider nodeProvider; private NodeProvider originalNodeProvider; private MergingProcessor mergingProcessor; @@ -73,6 +66,7 @@ public class Blue implements NodeResolver { private Limits globalLimits = NO_LIMITS; private DocumentProcessor documentProcessor; private final ConcurrentMap resolvedSnapshotsByBlueId = new ConcurrentHashMap<>(); + private final ConcurrentMap externalContractTypeNodes = new ConcurrentHashMap<>(); private final ResolvedReferenceCache resolvedReferenceCache = new ResolvedReferenceCache(); private final DictionaryRegistry dictionaryRegistry = new DictionaryRegistry(); @@ -387,7 +381,7 @@ public BlueConformanceReport conformanceReport() { Map fixtureCategories = BlueConformanceReport.loadFixtureCategories(); return new BlueConformanceReport( languageVersion(), - new LinkedHashMap<>(Properties.CORE_TYPE_NAME_TO_BLUE_ID_MAP), + new LinkedHashMap<>(BlueCoreTypeRegistry.INSTANCE.blueIdsByName()), fixturePackageIdentity, fixtureIds, Collections.emptyList(), @@ -400,6 +394,26 @@ 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); @@ -644,6 +658,19 @@ public Blue registerContractProcessor(String blueId, ContractProcessor processor) { + return registerExternalContractType(blueId, canonicalTypeNode, processor); + } + + public Blue registerExternalContractType(String blueId, + Node canonicalTypeNode, + ContractProcessor processor) { + registerExternalTypeNode(blueId, canonicalTypeNode); + return registerContractProcessor(blueId, processor); + } + public DocumentProcessingResult processDocument(Node document, Node event) { DocumentProcessor processor = ensureDocumentProcessor(); long start = System.nanoTime(); @@ -780,12 +807,16 @@ 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; @@ -804,7 +835,7 @@ private void refreshDocumentProcessorConformanceEngine() { if (documentProcessor != null) { documentProcessor = new DocumentProcessor(documentProcessor.getContractRegistry(), documentProcessor.getContractTypeResolver(), - conformanceEngine(), + processorConformanceEngine(), processingSnapshotManager(), new ContractMatchingService(this), documentProcessor.processingMetricsSink()); @@ -833,7 +864,7 @@ 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); + .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())); @@ -875,10 +906,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; @@ -908,16 +983,38 @@ 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 NodeProviderWrapper.unverified(blueId -> { - if (processorTypeBlueIds.contains(blueId) || !BlueIds.isPotentialBlueId(blueId)) { - return Collections.singletonList(new Node().name(blueId)); + return new SequentialNodeProvider( + BootstrapProvider.INSTANCE, + BlueRuntimeTypeRegistry.getDefault().asProcessorSnapshotProvider(), + registeredExtensionTypeProvider(), + blueId -> BlueIds.isPotentialBlueId(blueId) + ? nodeProvider.fetchByBlueId(blueId) + : null); + } + + private NodeProvider registeredExtensionTypeProvider() { + return blueId -> { + if (!BlueIds.isPotentialBlueId(blueId) + || BlueRuntimeTypeRegistry.getDefault().isProcessorManagedTypeBlueId(blueId)) { + return null; } - return originalNodeProvider.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) { diff --git a/src/main/java/blue/language/BlueConformanceFailure.java b/src/main/java/blue/language/BlueConformanceFailure.java index e227e04..d115afb 100644 --- a/src/main/java/blue/language/BlueConformanceFailure.java +++ b/src/main/java/blue/language/BlueConformanceFailure.java @@ -7,17 +7,28 @@ public final class BlueConformanceFailure { 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() { @@ -39,4 +50,8 @@ public String getExceptionClass() { 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 index dc2c9d8..1f30777 100644 --- a/src/main/java/blue/language/BlueConformanceReport.java +++ b/src/main/java/blue/language/BlueConformanceReport.java @@ -288,6 +288,14 @@ private static String toHex(byte[] bytes) { 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", @@ -312,11 +320,12 @@ private static Set requiredFixtureIds() { "B_pos_rejected", "B_replace_rejected", "R_blue_imports_type_itemType_keyType_valueType", - "R_blue_imports", "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", @@ -345,7 +354,17 @@ private static Set requiredFixtureIds() { "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"); + "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) { diff --git a/src/main/java/blue/language/BlueConformanceSuiteRunner.java b/src/main/java/blue/language/BlueConformanceSuiteRunner.java index f241d75..a1263e2 100644 --- a/src/main/java/blue/language/BlueConformanceSuiteRunner.java +++ b/src/main/java/blue/language/BlueConformanceSuiteRunner.java @@ -2,14 +2,18 @@ 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; @@ -34,7 +38,11 @@ public final class BlueConformanceSuiteRunner { "calculateSemanticBlueId", "expand", "collapse", - "assertSameNodeBlueId" + "assertSameNodeBlueId", + "assertViewPath", + "registryNodeHashesToPublishedBlueId", + "changingRegistryDescriptionChangesBlueId", + "lintPublishableDocumentation" ))); private BlueConformanceSuiteRunner() { @@ -97,6 +105,7 @@ private static void runFixture(FixtureEntry fixture) { try { runOperation(spec, operation); } catch (RuntimeException expected) { + assertExpectedErrorCategory(spec, expected); return; } throw new AssertionError("Fixture expected an error but operation succeeded: " + fixture.id); @@ -129,6 +138,12 @@ private static void runFixture(FixtureEntry fixture) { } 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. } } @@ -177,9 +192,115 @@ private static Object runOperation(JsonNode spec, String operation) { 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; @@ -224,6 +345,8 @@ private static void validateFixtureMetadata(JsonNode spec) { } if (!spec.path("expectError").asBoolean(false)) { requireExpectedOutput(spec, operation); + } else { + validateExpectedErrorCategoryFields(spec); } } @@ -239,7 +362,8 @@ private static BlueConformanceFailure failure(FixtureEntry fixture, Throwable th BlueFixtureCategory.fromLabel(fixture.category), operation, throwable.getClass().getName(), - throwable.getMessage()); + throwable.getMessage(), + BlueLanguageErrorClassifier.classify(throwable)); } private static void requireExpectedOutput(JsonNode spec, String operation) { @@ -280,9 +404,64 @@ private static void requireExpectedOutput(JsonNode spec, String 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); } @@ -300,8 +479,12 @@ private static void assertExpectedTextList(JsonNode spec, String field, List= 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); } 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 index d08a187..183132c 100644 --- a/src/main/java/blue/language/BlueFixtureCategory.java +++ b/src/main/java/blue/language/BlueFixtureCategory.java @@ -9,7 +9,9 @@ public enum BlueFixtureCategory { RESOLUTION("Resolution"), CANONICALIZATION("Canonicalization"), PROVIDER("Provider"), - CIRCULAR("Circular"); + CIRCULAR("Circular"), + REGISTRY("Registry"), + DOCUMENTATION_LINT("DocumentationLint"); private final String label; 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 2a531a8..32d697c 100644 --- a/src/main/java/blue/language/conformance/FrozenConformancePlanner.java +++ b/src/main/java/blue/language/conformance/FrozenConformancePlanner.java @@ -79,30 +79,69 @@ 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 { - Node canonical = new MergeReverser().reverse(node.toNode()); new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache).resolve(canonical, Limits.NO_LIMITS); return ConformanceResult.conformant(); } catch (RuntimeException ex) { @@ -110,6 +149,25 @@ private ConformanceResult check(FrozenNode node) { } } + 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) { @@ -131,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) { diff --git a/src/main/java/blue/language/merge/processor/SchemaPropagator.java b/src/main/java/blue/language/merge/processor/SchemaPropagator.java index 326910b..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; @@ -145,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; } @@ -158,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) { @@ -173,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 6c9bf3d..8b9e1af 100644 --- a/src/main/java/blue/language/merge/processor/SchemaVerifier.java +++ b/src/main/java/blue/language/merge/processor/SchemaVerifier.java @@ -6,6 +6,7 @@ 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; @@ -33,18 +34,18 @@ public void postProcess(Node target, Node source, NodeProvider nodeProvider, Nod verifyWellFormed(schema); verifyRequired(schema.getRequiredValue(), target); - verifyMinLength(schema.getMinLengthExact(), target.getValue()); - verifyMaxLength(schema.getMaxLengthExact(), 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.getMinItemsExact(), target.getItems()); - verifyMaxItems(schema.getMaxItemsExact(), target.getItems()); - verifyUniqueItems(schema.getUniqueItemsValue(), target.getItems()); - verifyMinFields(schema.getMinFieldsExact(), target.getProperties()); - verifyMaxFields(schema.getMaxFieldsExact(), 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); } @@ -107,18 +108,28 @@ private boolean hasPayload(Node node) { || (node.getProperties() != null && !node.getProperties().isEmpty()); } - private void verifyMinLength(BigInteger minLength, Object value) { - if (minLength != null - && value instanceof String - && BigInteger.valueOf(codePointLength((String) value)).compareTo(minLength) < 0) { + 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(BigInteger maxLength, Object value) { - if (maxLength != null - && value instanceof String - && BigInteger.valueOf(codePointLength((String) value)).compareTo(maxLength) > 0) { + 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,67 +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(BigInteger minItems, List items) { + 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 (minItems != null && BigInteger.valueOf(size).compareTo(minItems) < 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(BigInteger maxItems, List items) { - if (maxItems != null && items != null && BigInteger.valueOf(items.size()).compareTo(maxItems) > 0) { + 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)) @@ -199,16 +248,26 @@ private void verifyUniqueItems(Boolean uniqueItems, List items) { } } - private void verifyMinFields(BigInteger 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 && BigInteger.valueOf(fieldCount).compareTo(minFields) < 0) { + 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(BigInteger 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 && BigInteger.valueOf(fieldCount).compareTo(maxFields) > 0) { + if (BigInteger.valueOf(fieldCount).compareTo(maxFields) > 0) { throw new IllegalArgumentException("Number of fields " + fieldCount + " is greater than the maximum allowed fields of " + maxFields + "."); } } @@ -217,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() @@ -232,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/preprocess/Preprocessor.java b/src/main/java/blue/language/preprocess/Preprocessor.java index af06573..4084abf 100644 --- a/src/main/java/blue/language/preprocess/Preprocessor.java +++ b/src/main/java/blue/language/preprocess/Preprocessor.java @@ -60,33 +60,43 @@ public Node preprocessWithDefaultBlue(Node document) { public Node preprocess(Node document, Node defaultBlue) { Node processedDocument = new NormalizeListPlaceholders().process(document.clone()); + if (defaultBlue != null) { + processedDocument = applyStandardBaseline(processedDocument); + } processedDocument = applyPortableImports(processedDocument); - Node blueNode = processedDocument.getBlue(); - if (blueNode == null && defaultBlue != null) { - blueNode = defaultBlue.clone(); + Node blueNode = processedDocument.getBlue(); + if (blueNode != null) { + processedDocument = applyDeclaredBlueTransformations(processedDocument, blueNode); } - if (blueNode != null) { + return processedDocument; + } - new NodeExtender(nodeProvider).extend(blueNode, PathLimits.withSinglePath("/*")); + private Node applyStandardBaseline(Node document) { + Node transformed = new ReplaceInlineValuesForTypeAttributesWithImports(CORE_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; } diff --git a/src/main/java/blue/language/processor/BatchPatchTransaction.java b/src/main/java/blue/language/processor/BatchPatchTransaction.java index f9bc9ad..13f192d 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,17 +19,20 @@ 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; BatchPatchTransaction(String originScopePath, List patches, DocumentProcessingRuntime.PlanningContext planning, ConformanceEngine conformanceEngine, + ConformancePlannerOverride conformancePlannerOverride, DocumentProcessingRuntime.UpdateMaterializationMetrics materializationMetrics) { this.originScopePath = originScopePath; this.patches = Collections.unmodifiableList(new ArrayList<>(patches)); this.planning = planning; this.conformanceEngine = conformanceEngine; + this.conformancePlannerOverride = conformancePlannerOverride; this.materializationMetrics = materializationMetrics; } @@ -57,15 +61,21 @@ 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); + List updates = buildUpdates(records, + preConformanceResolved, + finalResolved, + conformancePlan.changedPaths(), + includeGeneratedUpdates); long buildUpdatesNanos = System.nanoTime() - buildUpdatesStart; return new BatchPatchResult(finalCanonical, finalResolved, @@ -78,22 +88,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 +163,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 +184,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 +216,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..0108ed2 100644 --- a/src/main/java/blue/language/processor/ChannelRunner.java +++ b/src/main/java/blue/language/processor/ChannelRunner.java @@ -1,5 +1,6 @@ package blue.language.processor; +import blue.language.Blue; import blue.language.model.Node; import blue.language.processor.model.ChannelContract; @@ -44,6 +45,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); } @@ -57,11 +64,20 @@ void runExternalChannel(String scopePath, Node eventForHandlers = match.eventNode() != null ? match.eventNode() : event; Node checkpointEvent = event != null ? event.clone() : null; 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); + CheckpointManager.CheckpointRecord checkpoint; + String eventSignature; + try { + checkpointManager.ensureCheckpointMarker(scopePath, bundle); + checkpoint = checkpointManager.findCheckpoint(bundle, channel.key()); + eventSignature = eventSignature(checkpointEvent); + } catch (RuntimeException ex) { + metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart); + execution.enterFatalTermination(scopePath, + bundle, + execution.fatalCategory(ex, ProcessorErrorCategory.CheckpointError), + execution.fatalReason(ex, "Checkpoint error")); + return; + } if (checkpointManager.isDuplicate(checkpoint, eventSignature)) { metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart); return; @@ -83,7 +99,14 @@ 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.addCheckpointUpdateNanos(System.nanoTime() - checkpointPersistStart); } @@ -94,8 +117,18 @@ 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 { + checkpointManager.ensureCheckpointMarker(scopePath, bundle); + fallbackSignature = eventSignature(checkpointEvent); + } 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)) { @@ -106,7 +139,7 @@ private void runDeliveries(String scopePath, : channel.key(); long checkpointStart = System.nanoTime(); CheckpointManager.CheckpointRecord checkpoint = checkpointManager.findCheckpoint(bundle, checkpointKey); - String eventSignature = delivery.eventId() != null ? delivery.eventId() : fallbackSignature; + String eventSignature = eventSignature(checkpointEvent, fallbackSignature); if (checkpointManager.isDuplicate(checkpoint, eventSignature)) { metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart); continue; @@ -138,11 +171,31 @@ 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.addCheckpointUpdateNanos(System.nanoTime() - checkpointPersistStart); } } + private String eventSignature(Node fallbackEvent) { + return eventSignature(fallbackEvent, null); + } + + private String eventSignature(Node fallbackEvent, String fallbackSignature) { + return eventSignature(fallbackEvent, fallbackSignature, owner.matchingService().blue()); + } + + private static String eventSignature(Node fallbackEvent, String fallbackSignature, Blue blue) { + return fallbackSignature != null ? fallbackSignature : CheckpointIdentityCalculator.identity(fallbackEvent, blue); + } + void runHandlers(String scopePath, ContractBundle bundle, String channelKey, @@ -160,6 +213,8 @@ void runHandlers(String scopePath, break; } HandlerMatchContext matchContext = new HandlerMatchContext(scopePath, + handler.key(), + channelKey, event, bundle.markers(), owner.matchingService()); @@ -186,6 +241,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/CheckpointIdentityCalculator.java b/src/main/java/blue/language/processor/CheckpointIdentityCalculator.java new file mode 100644 index 0000000..b3ce903 --- /dev/null +++ b/src/main/java/blue/language/processor/CheckpointIdentityCalculator.java @@ -0,0 +1,35 @@ +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) { + if (event == null) { + return null; + } + try { + return BlueIdCalculator.calculateBlueId(event); + } catch (RuntimeException directFailure) { + if (blue == null) { + throw new IllegalStateException( + "Checkpoint event identity requires valid BlueId Input or a Blue canonicalization context", + directFailure); + } + try { + return blue.calculateSemanticBlueId(event.clone()); + } catch (RuntimeException semanticFailure) { + return ProcessorEngine.canonicalSignature(event.clone()); + } + } + } +} diff --git a/src/main/java/blue/language/processor/CheckpointManager.java b/src/main/java/blue/language/processor/CheckpointManager.java index ba83684..4ae6589 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,19 @@ final class CheckpointManager { private final DocumentProcessingRuntime runtime; - private final Function signatureFn; + private final Blue blue; - CheckpointManager(DocumentProcessingRuntime runtime, Function signatureFn) { + CheckpointManager(DocumentProcessingRuntime runtime) { + this(runtime, (Blue) null); + } + + CheckpointManager(DocumentProcessingRuntime runtime, Blue blue) { this.runtime = Objects.requireNonNull(runtime, "runtime"); - this.signatureFn = Objects.requireNonNull(signatureFn, "signatureFn"); + this.blue = blue; + } + + CheckpointManager(DocumentProcessingRuntime runtime, Function ignoredSignatureFn) { + this(runtime, (Blue) null); } void ensureCheckpointMarker(String scopePath, ContractBundle bundle) { @@ -30,9 +40,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 +58,7 @@ 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); + record.lastEventSignature = eventIdentity(stored); return record; } } @@ -76,14 +84,13 @@ 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; } + private String eventIdentity(Node event) { + return CheckpointIdentityCalculator.identity(event, blue); + } + static final class CheckpointRecord { final String markerKey; final ChannelEventCheckpoint checkpoint; 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..92f302d --- /dev/null +++ b/src/main/java/blue/language/processor/ContractEffectBuffer.java @@ -0,0 +1,101 @@ +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 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) { + patches.add(copyPatch(patch)); + } + } + + void addPatches(List input) { + if (input == null || input.isEmpty()) { + return; + } + for (JsonPatch patch : input) { + addPatch(patch); + } + } + + List patches() { + return Collections.unmodifiableList(patches); + } + + void emit(Node event) { + emittedEvents.add(event != null ? event.clone() : null); + } + + List emittedEvents() { + return Collections.unmodifiableList(emittedEvents); + } + + void terminate(ScopeRuntimeContext.TerminationKind kind, String reason) { + if (terminationRequest == null) { + terminationRequest = new TerminationRequest(kind, reason); + } + } + + TerminationRequest terminationRequest() { + return terminationRequest; + } + + private JsonPatch copyPatch(JsonPatch patch) { + switch (patch.getOp()) { + case ADD: + return JsonPatch.add(patch.getPath(), patch.getVal().clone()); + case REPLACE: + return JsonPatch.replace(patch.getPath(), patch.getVal().clone()); + case REMOVE: + return JsonPatch.remove(patch.getPath()); + default: + throw new IllegalStateException("Unsupported patch op: " + patch.getOp()); + } + } + + static final class TerminationRequest { + private final ScopeRuntimeContext.TerminationKind kind; + private final String reason; + + private TerminationRequest(ScopeRuntimeContext.TerminationKind kind, String reason) { + this.kind = kind; + this.reason = reason; + } + + ScopeRuntimeContext.TerminationKind kind() { + return kind; + } + + String reason() { + return reason; + } + } +} diff --git a/src/main/java/blue/language/processor/ContractLoader.java b/src/main/java/blue/language/processor/ContractLoader.java index 3559ce4..7dae7be 100644 --- a/src/main/java/blue/language/processor/ContractLoader.java +++ b/src/main/java/blue/language/processor/ContractLoader.java @@ -15,8 +15,10 @@ import java.util.Map; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -25,6 +27,18 @@ */ final class ContractLoader { + private static final Set INVALID_CONTRACT_KEYS = new LinkedHashSet<>(); + + static { + INVALID_CONTRACT_KEYS.add("type"); + INVALID_CONTRACT_KEYS.add("value"); + INVALID_CONTRACT_KEYS.add("items"); + INVALID_CONTRACT_KEYS.add("schema"); + INVALID_CONTRACT_KEYS.add("contracts"); + INVALID_CONTRACT_KEYS.add("properties"); + INVALID_CONTRACT_KEYS.add("constraints"); + } + private final ContractProcessorRegistry registry; private final NodeToObjectConverter converter; private final TypeClassResolver typeResolver; @@ -100,7 +114,8 @@ private ContractBundle build(FrozenNode scopeNode, String scopePath) { if (contractsNode.isEmptyNode()) { return builder.build(); } - throw new MustUnderstandFailureException("Contracts must be an object map"); + throw new MustUnderstandFailureException("Contracts must be an object map", + ProcessorErrorCategory.InvalidProcessingDocument); } Map contractNodes = new LinkedHashMap<>(contractsNode.getProperties()); @@ -114,14 +129,17 @@ private ContractBundle build(FrozenNode scopeNode, String scopePath) { for (Map.Entry entry : contractNodes.entrySet()) { String key = entry.getKey(); + validateContractKey(key); String typeBlueId = contractTypeBlueIds.get(key); if (typeBlueId == null) { throw new MustUnderstandFailureException( - "Contract '" + key + "' must declare a type"); + "Contract '" + key + "' must declare a type", + ProcessorErrorCategory.UnsupportedContract); } Class contractClass = typeResolver.resolveClass(typeBlueId); if (contractClass == null || !Contract.class.isAssignableFrom(contractClass)) { - throw new MustUnderstandFailureException("Unsupported contract type: " + typeBlueId); + throw new MustUnderstandFailureException("Unsupported contract type: " + typeBlueId, + ProcessorErrorCategory.UnsupportedContract); } Contract contract = converter.convertWithType(entry.getValue().toNode(), Contract.class, false); if (contract == null) { @@ -134,7 +152,8 @@ private ContractBundle build(FrozenNode scopeNode, String scopePath) { if (!ProcessorContractConstants.isProcessorManagedChannel(channel) && !registry.lookupChannel(channel).isPresent()) { throw new MustUnderstandFailureException( - "Unsupported contract type: " + typeBlueId); + "Unsupported contract type: " + typeBlueId, + ProcessorErrorCategory.UnsupportedContract); } builder.addChannel(key, channel, entry.getValue()); } else if (contract instanceof HandlerContract) { @@ -142,7 +161,8 @@ private ContractBundle build(FrozenNode scopeNode, String scopePath) { Optional> processor = registry.lookupHandler(handler); if (!processor.isPresent()) { throw new MustUnderstandFailureException( - "Unsupported contract type: " + typeBlueId); + "Unsupported contract type: " + typeBlueId, + ProcessorErrorCategory.UnsupportedContract); } String channelKey = resolveHandlerChannel(scopePath, key, @@ -151,9 +171,11 @@ private ContractBundle build(FrozenNode scopeNode, String scopePath) { contractNodes, contractTypeBlueIds); handler.setChannelKey(channelKey); - requireRegisteredChannel(key, channelKey, contractNodes, contractTypeBlueIds); - builder.addHandler(key, handler, entry.getValue()); + if (hasRegisteredSameScopeChannel(channelKey, contractNodes, contractTypeBlueIds)) { + builder.addHandler(key, handler, entry.getValue()); + } } else if (contract instanceof ProcessEmbedded) { + validateEmbeddedPaths((ProcessEmbedded) contract); builder.setEmbedded((ProcessEmbedded) contract, entry.getValue()); } else if (contract instanceof MarkerContract) { builder.addMarker(key, (MarkerContract) contract, entry.getValue()); @@ -163,6 +185,27 @@ private ContractBundle build(FrozenNode scopeNode, String scopePath) { return builder.build(); } + private void validateContractKey(String key) { + if (key == null || key.isEmpty()) { + throw new MustUnderstandFailureException("Invalid contract key: key must be non-empty", + ProcessorErrorCategory.InvalidRuntimePointer); + } + if (INVALID_CONTRACT_KEYS.contains(key)) { + throw new MustUnderstandFailureException("Invalid contract key: reserved key '" + key + "'", + ProcessorErrorCategory.InvalidReservedMarker); + } + } + + private void validateEmbeddedPaths(ProcessEmbedded embedded) { + Set seen = new LinkedHashSet<>(); + for (String path : embedded.getPaths()) { + if (!seen.add(path)) { + throw new MustUnderstandFailureException("Unique items are required for Process Embedded paths", + ProcessorErrorCategory.BoundaryViolation); + } + } + } + private BundleCacheKey cacheKey(FrozenNode scopeNode, String scopePath) { FrozenNode contractsNode = property(scopeNode, "contracts"); FrozenNode channelBindingsNode = property(scopeNode, "channelBindings"); @@ -202,7 +245,6 @@ private String checkpointStaticSignature(FrozenNode checkpointNode) { Node node = checkpointNode.toNode(); if (node.getProperties() != null) { node.getProperties().remove("lastEvents"); - node.getProperties().remove("lastSignatures"); } return FrozenNode.fromResolvedNode(node).blueId(); } @@ -288,38 +330,33 @@ private String resolveHandlerChannel(String scopePath, return channelKey; } - private void requireRegisteredChannel(String handlerKey, - String channelKey, - Map contractNodes, - Map contractTypeBlueIds) { + private boolean hasRegisteredSameScopeChannel(String channelKey, + Map contractNodes, + Map contractTypeBlueIds) { FrozenNode channelNode = contractNodes.get(channelKey); if (channelNode == null) { - throw new IllegalStateException( - "Handler " + handlerKey + " references unknown channel '" + channelKey + "'"); + return false; } String channelTypeBlueId = contractTypeBlueIds.get(channelKey); if (channelTypeBlueId == null) { - throw new IllegalStateException( - "Handler " + handlerKey + " references contract '" + channelKey + "' without a type"); + return false; } Class channelClass = typeResolver.resolveClass(channelTypeBlueId); if (channelClass == null || !ChannelContract.class.isAssignableFrom(channelClass)) { - throw new IllegalStateException( - "Handler " + handlerKey + " references non-channel contract '" + channelKey + "'"); + return false; } Contract channelContract = converter.convertWithType(channelNode.toNode(), Contract.class, false); if (!(channelContract instanceof ChannelContract)) { - throw new IllegalStateException( - "Handler " + handlerKey + " references non-channel contract '" + channelKey + "'"); + return false; } ChannelContract channel = (ChannelContract) channelContract; channel.setKey(channelKey); channel.setTypeBlueId(channelTypeBlueId); if (!ProcessorContractConstants.isProcessorManagedChannel(channel) && !registry.lookupChannel(channel).isPresent()) { - throw new IllegalStateException( - "Handler " + handlerKey + " references unsupported channel '" + channelKey + "'"); + return false; } + return true; } private String trimToNull(String value) { diff --git a/src/main/java/blue/language/processor/ContractMatchingService.java b/src/main/java/blue/language/processor/ContractMatchingService.java index 9887609..8049859 100644 --- a/src/main/java/blue/language/processor/ContractMatchingService.java +++ b/src/main/java/blue/language/processor/ContractMatchingService.java @@ -10,6 +10,7 @@ */ public final class ContractMatchingService { + private final Blue blue; private final FrozenTypeMatcher matcher; public ContractMatchingService() { @@ -17,9 +18,14 @@ public ContractMatchingService() { } public ContractMatchingService(Blue blue) { + this.blue = blue; this.matcher = new FrozenTypeMatcher(blue); } + Blue blue() { + return blue; + } + public boolean matches(FrozenNode event, FrozenNode pattern) { if (pattern == null) { return true; diff --git a/src/main/java/blue/language/processor/DocumentProcessingResult.java b/src/main/java/blue/language/processor/DocumentProcessingResult.java index 97eb0c6..8509885 100644 --- a/src/main/java/blue/language/processor/DocumentProcessingResult.java +++ b/src/main/java/blue/language/processor/DocumentProcessingResult.java @@ -18,6 +18,8 @@ public final class DocumentProcessingResult { private final long totalGas; private final boolean capabilityFailure; private final String failureReason; + private final ProcessorStatus status; + private final ProcessorErrorCategory errorCategory; private final ResolvedSnapshot snapshot; private DocumentProcessingResult(Node document, @@ -25,19 +27,32 @@ private DocumentProcessingResult(Node document, long totalGas, boolean capabilityFailure, String failureReason, + ProcessorStatus status, + ProcessorErrorCategory errorCategory, ResolvedSnapshot snapshot) { this.document = document; this.triggeredEvents = Collections.unmodifiableList(new ArrayList<>(triggeredEvents)); this.totalGas = totalGas; this.capabilityFailure = capabilityFailure; this.failureReason = failureReason; + this.status = status != null + ? status + : (capabilityFailure ? ProcessorStatus.CAPABILITY_FAILURE : ProcessorStatus.SUCCESS); + this.errorCategory = errorCategory; this.snapshot = snapshot; } public static DocumentProcessingResult of(Node document, List triggeredEvents, long totalGas) { Objects.requireNonNull(document, "document"); Objects.requireNonNull(triggeredEvents, "triggeredEvents"); - return new DocumentProcessingResult(document, new ArrayList<>(triggeredEvents), totalGas, false, null, null); + return new DocumentProcessingResult(document, + new ArrayList<>(triggeredEvents), + totalGas, + false, + null, + ProcessorStatus.SUCCESS, + null, + null); } public static DocumentProcessingResult of(ResolvedSnapshot snapshot, List triggeredEvents, long totalGas) { @@ -48,12 +63,91 @@ public static DocumentProcessingResult of(ResolvedSnapshot snapshot, List totalGas, false, null, + ProcessorStatus.SUCCESS, + null, + snapshot); + } + + public static DocumentProcessingResult of(ResolvedSnapshot snapshot, + List triggeredEvents, + long totalGas, + ProcessorStatus status, + ProcessorErrorCategory errorCategory, + String failureReason) { + Objects.requireNonNull(snapshot, "snapshot"); + Objects.requireNonNull(triggeredEvents, "triggeredEvents"); + return new DocumentProcessingResult(snapshot.canonicalRoot(), + new ArrayList<>(triggeredEvents), + totalGas, + status == ProcessorStatus.CAPABILITY_FAILURE + || status == ProcessorStatus.INVALID_PROCESSING_DOCUMENT, + failureReason, + status, + errorCategory, snapshot); } + public static DocumentProcessingResult of(Node document, + List triggeredEvents, + long totalGas, + ProcessorStatus status, + ProcessorErrorCategory errorCategory, + String failureReason) { + Objects.requireNonNull(document, "document"); + Objects.requireNonNull(triggeredEvents, "triggeredEvents"); + return new DocumentProcessingResult(document, + new ArrayList<>(triggeredEvents), + totalGas, + status == ProcessorStatus.CAPABILITY_FAILURE + || status == ProcessorStatus.INVALID_PROCESSING_DOCUMENT, + failureReason, + status, + errorCategory, + null); + } + public static DocumentProcessingResult capabilityFailure(Node document, String reason) { + return capabilityFailure(document, reason, ProcessorErrorCategory.UnsupportedContract); + } + + public static DocumentProcessingResult capabilityFailure(Node document, + String reason, + ProcessorErrorCategory errorCategory) { + Objects.requireNonNull(document, "document"); + return new DocumentProcessingResult(document, + Collections.emptyList(), + 0L, + true, + reason, + ProcessorStatus.CAPABILITY_FAILURE, + errorCategory, + null); + } + + public static DocumentProcessingResult invalidProcessingDocument(Node document, String reason) { Objects.requireNonNull(document, "document"); - return new DocumentProcessingResult(document, Collections.emptyList(), 0L, true, reason, null); + return new DocumentProcessingResult(document, + Collections.emptyList(), + 0L, + true, + reason, + ProcessorStatus.INVALID_PROCESSING_DOCUMENT, + ProcessorErrorCategory.InvalidProcessingDocument, + null); + } + + public static DocumentProcessingResult runtimeFatal(Node document, + String reason, + ProcessorErrorCategory errorCategory) { + Objects.requireNonNull(document, "document"); + return new DocumentProcessingResult(document, + Collections.emptyList(), + 0L, + false, + reason, + ProcessorStatus.RUNTIME_FATAL, + errorCategory, + null); } public DocumentProcessingResult withSnapshot(ResolvedSnapshot snapshot) { @@ -63,6 +157,8 @@ public DocumentProcessingResult withSnapshot(ResolvedSnapshot snapshot) { totalGas, capabilityFailure, failureReason, + status, + errorCategory, snapshot); } @@ -86,6 +182,14 @@ public String failureReason() { return failureReason; } + public ProcessorStatus status() { + return status; + } + + public ProcessorErrorCategory errorCategory() { + return errorCategory; + } + public ResolvedSnapshot snapshot() { return snapshot; } diff --git a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java index 681eab4..de438a1 100644 --- a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java +++ b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java @@ -21,6 +21,7 @@ public final class DocumentProcessingRuntime { private final EmissionRegistry emissionRegistry; private final GasMeter gasMeter; private final ConformanceEngine conformanceEngine; + private final ConformancePlannerOverride conformancePlannerOverride; private final ProcessingSnapshotManager snapshotManager; private final ProcessingMetricsSink metrics; private ResolvedSnapshot snapshot; @@ -53,10 +54,19 @@ public DocumentProcessingRuntime(Node document, ConformanceEngine conformanceEngine, ProcessingSnapshotManager snapshotManager, ProcessingMetricsSink metrics) { + this(document, conformanceEngine, null, snapshotManager, metrics); + } + + public DocumentProcessingRuntime(Node document, + ConformanceEngine conformanceEngine, + ConformancePlannerOverride conformancePlannerOverride, + ProcessingSnapshotManager snapshotManager, + ProcessingMetricsSink metrics) { this.materializedView = new MaterializedDocumentView(Objects.requireNonNull(document, "document")); this.emissionRegistry = new EmissionRegistry(); this.gasMeter = new GasMeter(); this.conformanceEngine = conformanceEngine; + this.conformancePlannerOverride = conformancePlannerOverride; this.snapshotManager = snapshotManager; this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; } @@ -71,11 +81,20 @@ public DocumentProcessingRuntime(ResolvedSnapshot snapshot, ConformanceEngine conformanceEngine, ProcessingSnapshotManager snapshotManager, ProcessingMetricsSink metrics) { + this(snapshot, conformanceEngine, null, snapshotManager, metrics); + } + + public DocumentProcessingRuntime(ResolvedSnapshot snapshot, + ConformanceEngine conformanceEngine, + ConformancePlannerOverride conformancePlannerOverride, + ProcessingSnapshotManager snapshotManager, + ProcessingMetricsSink metrics) { ResolvedSnapshot processorSnapshot = processorSnapshot(Objects.requireNonNull(snapshot, "snapshot")); this.materializedView = new MaterializedDocumentView(processorSnapshot.canonicalRoot()); this.emissionRegistry = new EmissionRegistry(); this.gasMeter = new GasMeter(); this.conformanceEngine = conformanceEngine; + this.conformancePlannerOverride = conformancePlannerOverride; this.snapshotManager = snapshotManager; this.snapshot = processorSnapshot; this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; @@ -93,12 +112,21 @@ public Node document() { return materializedView.root(); } + void replaceDocument(Node document) { + materializedView.replaceWith(Objects.requireNonNull(document, "document")); + snapshot = null; + } + public Map scopes() { return emissionRegistry.scopes(); } public ScopeRuntimeContext scope(String scopePath) { - return emissionRegistry.scope(scopePath); + ScopeRuntimeContext context = emissionRegistry.scope(scopePath); + if ("/".equals(PointerUtils.normalizeScope(scopePath))) { + context.setEmbeddedDepth(0); + } + return context; } public ScopeRuntimeContext existingScope(String scopePath) { @@ -122,7 +150,15 @@ public long totalGas() { } public void chargeScopeEntry(String scopePath) { - gasMeter.chargeScopeEntry(scopePath); + gasMeter.chargeScopeEntry(scope(scopePath).embeddedDepth()); + } + + public void setScopeEmbeddedDepth(String scopePath, int depth) { + scope(scopePath).setEmbeddedDepth(depth); + } + + public int scopeEmbeddedDepth(String scopePath) { + return scope(scopePath).embeddedDepth(); } public void chargeInitialization() { @@ -264,6 +300,27 @@ public boolean hasInitializationMarker(String scopePath) { return true; } + public ProcessorEngine.TerminationMarker terminationMarker(String scopePath) { + String pointer = PointerUtils.resolvePointer(scopePath, ProcessorPointerConstants.RELATIVE_TERMINATED); + Node marker = canonicalNodeAt(pointer); + if (marker == null) { + return null; + } + return ProcessorEngine.validateTerminationMarker(marker, pointer); + } + + public boolean hasTerminationMarker(String scopePath) { + return terminationMarker(scopePath) != null; + } + + public void markScopeTerminatedFromMarker(String scopePath) { + ProcessorEngine.TerminationMarker marker = terminationMarker(scopePath); + if (marker == null) { + return; + } + scope(scopePath).finalizeTermination(marker.kind, marker.reason); + } + public void directWrite(String path, Node value) { Node rollback = materializedView.copyRoot(); ResolvedSnapshot snapshotRollback = snapshot; @@ -307,6 +364,7 @@ public List applyPatches(String originScopePath, List markers; private final ContractMatchingService matchingService; HandlerMatchContext(String scopePath, + String handlerKey, + String channelKey, Node event, Map markers, ContractMatchingService matchingService) { this.scopePath = Objects.requireNonNull(scopePath, "scopePath"); + this.handlerKey = handlerKey; + this.channelKey = channelKey; this.event = event != null ? event.clone() : null; this.eventFrozen = event != null ? FrozenNode.fromResolvedNode(event) : null; this.markers = markers == null @@ -37,6 +43,14 @@ public String scopePath() { return scopePath; } + public String handlerKey() { + return handlerKey; + } + + public String channelKey() { + return channelKey; + } + public Node event() { return event != null ? event.clone() : null; } diff --git a/src/main/java/blue/language/processor/MustUnderstandFailureException.java b/src/main/java/blue/language/processor/MustUnderstandFailureException.java index 765b5cf..e134975 100644 --- a/src/main/java/blue/language/processor/MustUnderstandFailureException.java +++ b/src/main/java/blue/language/processor/MustUnderstandFailureException.java @@ -1,7 +1,21 @@ package blue.language.processor; class MustUnderstandFailureException extends RuntimeException { + + private final ProcessorErrorCategory errorCategory; + MustUnderstandFailureException(String message) { + this(message, ProcessorErrorCategory.UnsupportedContract); + } + + MustUnderstandFailureException(String message, ProcessorErrorCategory errorCategory) { super(message); + this.errorCategory = errorCategory != null + ? errorCategory + : ProcessorErrorCategory.UnsupportedContract; + } + + ProcessorErrorCategory errorCategory() { + return errorCategory; } } diff --git a/src/main/java/blue/language/processor/ProcessingDocumentValidator.java b/src/main/java/blue/language/processor/ProcessingDocumentValidator.java new file mode 100644 index 0000000..eedb323 --- /dev/null +++ b/src/main/java/blue/language/processor/ProcessingDocumentValidator.java @@ -0,0 +1,120 @@ +package blue.language.processor; + +import blue.language.model.Node; +import blue.language.utils.UncheckedObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Production validation that must run before a Processing Document is converted + * into the Node model when raw map keys still need to be inspected. + */ +public final class ProcessingDocumentValidator { + + private static final Set INVALID_CONTRACT_KEYS = new LinkedHashSet<>(Arrays.asList( + "type", + "value", + "items", + "schema", + "contracts", + "properties", + "constraints")); + + private ProcessingDocumentValidator() { + } + + public static DocumentProcessingResult validateRaw(JsonNode rawDocument, Node parsedDocument) { + if (rawDocument == null || rawDocument.isNull()) { + return DocumentProcessingResult.invalidProcessingDocument( + fallbackDocument(parsedDocument), + "Invalid Processing Document: root scope must be an object"); + } + if (!rawDocument.isObject()) { + return DocumentProcessingResult.invalidProcessingDocument( + fallbackDocument(parsedDocument), + "Invalid Processing Document: root scope must be an object"); + } + JsonNode contracts = rawDocument.get("contracts"); + if (contracts == null || !contracts.isObject()) { + return null; + } + for (String key : iterable(contracts.fieldNames())) { + if (key == null || key.isEmpty()) { + return DocumentProcessingResult.runtimeFatal( + fallbackDocument(parsedDocument), + "Invalid contract key: key must be non-empty", + ProcessorErrorCategory.InvalidRuntimePointer); + } + if (INVALID_CONTRACT_KEYS.contains(key)) { + return DocumentProcessingResult.runtimeFatal( + fallbackDocument(parsedDocument), + "Invalid contract key: reserved key '" + key + "'", + ProcessorErrorCategory.InvalidReservedMarker); + } + } + return null; + } + + public static Node readProcessingDocument(JsonNode rawDocument) { + JsonNode normalizedRawDocument = normalizeObjectValuedValueWrappers(rawDocument); + try { + return UncheckedObjectMapper.JSON_MAPPER.convertValue(normalizedRawDocument, Node.class); + } catch (IllegalArgumentException ex) { + if (normalizedRawDocument == null || !normalizedRawDocument.isObject()) { + throw ex; + } + JsonNode rawContracts = normalizedRawDocument.get("contracts"); + if (rawContracts == null || rawContracts.isObject()) { + throw ex; + } + ObjectNode copy = normalizedRawDocument.deepCopy(); + copy.remove("contracts"); + Node document = UncheckedObjectMapper.JSON_MAPPER.convertValue(copy, Node.class); + document.contracts(UncheckedObjectMapper.JSON_MAPPER.convertValue(rawContracts, Node.class)); + return document; + } + } + + private static JsonNode normalizeObjectValuedValueWrappers(JsonNode node) { + if (node == null || node.isNull()) { + return node; + } + if (node.isObject()) { + JsonNode value = node.get("value"); + if (value != null && (value.isObject() || value.isArray()) && node.size() == 1) { + return normalizeObjectValuedValueWrappers(value); + } + ObjectNode copy = ((ObjectNode) node).deepCopy(); + java.util.Iterator names = copy.fieldNames(); + java.util.List fields = new java.util.ArrayList<>(); + while (names.hasNext()) { + fields.add(names.next()); + } + for (String field : fields) { + copy.set(field, normalizeObjectValuedValueWrappers(copy.get(field))); + } + return copy; + } + if (node.isArray()) { + ArrayNode copy = UncheckedObjectMapper.JSON_MAPPER.createArrayNode(); + for (JsonNode item : node) { + copy.add(normalizeObjectValuedValueWrappers(item)); + } + return copy; + } + return node; + } + + private static Node fallbackDocument(Node parsedDocument) { + return parsedDocument != null ? parsedDocument.clone() : new Node(); + } + + private static Iterable iterable(java.util.Iterator iterator) { + return () -> iterator; + } +} diff --git a/src/main/java/blue/language/processor/ProcessorEngine.java b/src/main/java/blue/language/processor/ProcessorEngine.java index 82e774a..fe69780 100644 --- a/src/main/java/blue/language/processor/ProcessorEngine.java +++ b/src/main/java/blue/language/processor/ProcessorEngine.java @@ -1,10 +1,13 @@ package blue.language.processor; +import blue.language.Blue; import blue.language.model.Node; import blue.language.processor.model.ChannelContract; import blue.language.processor.model.Contract; import blue.language.processor.model.HandlerContract; import blue.language.processor.model.JsonPatch; +import blue.language.processor.conformance.ScriptedContractsRuntime; +import blue.language.processor.registry.RuntimeBlueIds; import blue.language.processor.util.PointerUtils; import blue.language.processor.util.ProcessorContractConstants; import blue.language.processor.util.ProcessorPointerConstants; @@ -40,7 +43,7 @@ static DocumentProcessingResult initializeDocument(DocumentProcessor owner, Node } catch (RunTerminationException ignored) { // Initialization run terminated early (e.g., graceful root termination). } catch (MustUnderstandFailureException ex) { - return DocumentProcessingResult.capabilityFailure(document.clone(), ex.getMessage()); + return DocumentProcessingResult.capabilityFailure(document.clone(), ex.getMessage(), ex.errorCategory()); } return execution.result(); } @@ -56,7 +59,7 @@ static DocumentProcessingResult initializeDocument(DocumentProcessor owner, Reso } catch (RunTerminationException ignored) { // Initialization run terminated early (e.g., graceful root termination). } catch (MustUnderstandFailureException ex) { - return DocumentProcessingResult.capabilityFailure(snapshot.canonicalRoot(), ex.getMessage()); + return DocumentProcessingResult.capabilityFailure(snapshot.canonicalRoot(), ex.getMessage(), ex.errorCategory()); } return execution.result(); } @@ -69,12 +72,16 @@ static DocumentProcessingResult processDocument(DocumentProcessor owner, Node do long preprocessStart = System.nanoTime(); Execution execution = null; try { - if (!isInitialized(owner, document)) { - throw new IllegalStateException("Document not initialized"); + DocumentProcessingResult invalid = validateProcessingDocument(document); + if (invalid != null) { + return invalid; } Node cloned = document.clone(); execution = new Execution(owner, cloned); metrics.addEventPreprocessNanos(System.nanoTime() - preprocessStart); + if (execution.applyScriptedForcedFatalIfPresent()) { + return execution.result(); + } long bundleStart = System.nanoTime(); execution.loadBundles("/"); metrics.addBundleLoadNanos(System.nanoTime() - bundleStart); @@ -83,7 +90,7 @@ static DocumentProcessingResult processDocument(DocumentProcessor owner, Node do // Processing terminated early; result still returned. } catch (MustUnderstandFailureException ex) { metrics.addProcessDocumentNanos(System.nanoTime() - processStart); - return DocumentProcessingResult.capabilityFailure(document.clone(), ex.getMessage()); + return DocumentProcessingResult.capabilityFailure(document.clone(), ex.getMessage(), ex.errorCategory()); } long postStart = System.nanoTime(); try { @@ -102,11 +109,15 @@ static DocumentProcessingResult processDocument(DocumentProcessor owner, Resolve long preprocessStart = System.nanoTime(); Execution execution = null; try { - if (!isInitialized(owner, snapshot)) { - throw new IllegalStateException("Document not initialized"); + DocumentProcessingResult invalid = validateProcessingDocument(snapshot.canonicalRoot()); + if (invalid != null) { + return invalid.withSnapshot(snapshot); } execution = new Execution(owner, snapshot); metrics.addEventPreprocessNanos(System.nanoTime() - preprocessStart); + if (execution.applyScriptedForcedFatalIfPresent()) { + return execution.result(); + } long bundleStart = System.nanoTime(); execution.loadBundles("/"); metrics.addBundleLoadNanos(System.nanoTime() - bundleStart); @@ -115,7 +126,7 @@ static DocumentProcessingResult processDocument(DocumentProcessor owner, Resolve // Processing terminated early; result still returned. } catch (MustUnderstandFailureException ex) { metrics.addProcessDocumentNanos(System.nanoTime() - processStart); - return DocumentProcessingResult.capabilityFailure(snapshot.canonicalRoot(), ex.getMessage()); + return DocumentProcessingResult.capabilityFailure(snapshot.canonicalRoot(), ex.getMessage(), ex.errorCategory()); } long postStart = System.nanoTime(); try { @@ -141,6 +152,21 @@ static boolean isInitialized(DocumentProcessor owner, Node document) { return true; } + private static DocumentProcessingResult validateProcessingDocument(Node document) { + if (document == null) { + throw new NullPointerException("document"); + } + if (document.getBlue() != null) { + return DocumentProcessingResult.invalidProcessingDocument(document.clone(), + "Invalid Processing Document: root blue directive is not allowed"); + } + if (document.getValue() != null || document.getItems() != null || document.isReferenceOnly()) { + return DocumentProcessingResult.invalidProcessingDocument(document.clone(), + "Invalid Processing Document: root scope must be an object"); + } + return null; + } + static boolean isInitialized(DocumentProcessor owner, ResolvedSnapshot snapshot) { Objects.requireNonNull(snapshot, "snapshot"); String pointer = resolvePointer("/", ProcessorPointerConstants.RELATIVE_INITIALIZED); @@ -215,7 +241,7 @@ static ChannelMatch evaluateChannel(DocumentProcessor owner, } static Node createLifecycleInitiatedEvent(String documentId) { - Node event = new Node().properties("type", new Node().value("Document Processing Initiated")); + Node event = new Node().type(new Node().blueId(RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED)); event.properties("documentId", new Node().value(documentId)); return event; } @@ -224,7 +250,7 @@ static String canonicalSignature(Node node) { if (node == null) { return null; } - Object canonical = NodeToMapListOrValue.get(node); + Object canonical = NodeToMapListOrValue.get(normalizeSignatureNode(node.clone())); try { String json = UncheckedObjectMapper.JSON_MAPPER.writeValueAsString(canonical); return new JsonCanonicalizer(json).getEncodedString(); @@ -233,9 +259,58 @@ static String canonicalSignature(Node node) { } } + private static Node normalizeSignatureNode(Node node) { + if (node == null) { + return null; + } + node.type(normalizeSignatureReference(node.getType())); + node.itemType(normalizeSignatureReference(node.getItemType())); + node.keyType(normalizeSignatureReference(node.getKeyType())); + node.valueType(normalizeSignatureReference(node.getValueType())); + if (node.getItems() != null) { + node.getItems().replaceAll(ProcessorEngine::normalizeSignatureNode); + } + if (node.getProperties() != null) { + node.getProperties().replaceAll((key, value) -> { + if (isTypeReferenceKey(key)) { + return normalizeSignatureReference(value); + } + return normalizeSignatureNode(value); + }); + } + if (node.getContracts() != null) { + node.contracts(normalizeSignatureNode(node.getContracts())); + } + if (node.getBlue() != null) { + node.blue(normalizeSignatureNode(node.getBlue())); + } + return node; + } + + private static boolean isTypeReferenceKey(String key) { + return "type".equals(key) + || "itemType".equals(key) + || "keyType".equals(key) + || "valueType".equals(key); + } + + private static Node normalizeSignatureReference(Node reference) { + if (reference == null) { + return null; + } + normalizeSignatureNode(reference); + if (reference.getBlueId() != null) { + return new Node().blueId(reference.getBlueId()); + } + if (reference.getBlueId() == null && reference.getName() != null) { + return new Node().blueId(BlueIdCalculator.calculateBlueId(reference)); + } + return reference; + } + static Node createDocumentUpdateEvent(DocumentProcessingRuntime.DocumentUpdateData data, String scopePath) { String relativePath = relativizePointer(scopePath, data.path()); - Node event = new Node().properties("type", new Node().value("Document Update")); + Node event = new Node().type(new Node().blueId(RuntimeBlueIds.DOCUMENT_UPDATE)); event.properties("op", new Node().value(data.op().name().toLowerCase())); Node beforeNode = data.before() != null ? data.before().clone() : new Node().value(null); Node afterNode = data.after() != null ? data.after().clone() : new Node().value(null); @@ -251,13 +326,7 @@ static boolean matchesDocumentUpdate(String scopePath, String watchPath, String } String watch = PointerUtils.normalizePointer(PointerUtils.resolvePointer(scopePath, watchPath)); String changed = PointerUtils.normalizePointer(changedPath); - if (watch.equals("/")) { - return true; - } - if (changed.equals(watch)) { - return true; - } - return changed.startsWith(watch + "/"); + return PointerUtils.descendantOrEqual(changed, watch); } static Node nodeAt(Node root, String pointer) { @@ -303,14 +372,77 @@ static boolean hasInitializationMarker(Node root, String scopePath) { return true; } + static TerminationMarker terminationMarker(Node root, String scopePath) { + String pointer = resolvePointer(scopePath, ProcessorPointerConstants.RELATIVE_TERMINATED); + Node marker; + try { + marker = nodeAt(root, pointer); + } catch (Exception ignored) { + return null; + } + if (marker == null) { + return null; + } + return validateTerminationMarker(marker, pointer); + } + static void validateInitializationMarker(Node marker, String pointer) { if (marker == null) { return; } Node type = marker.getType(); - if (type == null || type.getBlueId() == null || !"InitializationMarker".equals(type.getBlueId())) { + if (type == null || !RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER.equals(runtimeTypeBlueId(type))) { throw new IllegalStateException( - "Reserved key 'initialized' must contain an Initialization Marker at " + pointer); + "Reserved key 'initialized' must contain a Processing Initialized Marker at " + pointer); + } + } + + static TerminationMarker validateTerminationMarker(Node marker, String pointer) { + if (marker == null) { + return null; + } + Node type = marker.getType(); + if (type == null || !RuntimeBlueIds.PROCESSING_TERMINATED_MARKER.equals(runtimeTypeBlueId(type))) { + throw new IllegalStateException( + "Reserved key 'terminated' must contain a Processing Terminated Marker at " + pointer); + } + String cause = stringProperty(marker, "cause"); + ScopeRuntimeContext.TerminationKind kind = "fatal".equals(cause) + ? ScopeRuntimeContext.TerminationKind.FATAL + : ScopeRuntimeContext.TerminationKind.GRACEFUL; + return new TerminationMarker(kind, stringProperty(marker, "reason")); + } + + private static String runtimeTypeBlueId(Node type) { + if (type == null) { + return null; + } + if (type.getBlueId() != null) { + return type.getBlueId(); + } + try { + return BlueIdCalculator.calculateBlueId(type); + } catch (RuntimeException ex) { + return null; + } + } + + private static String stringProperty(Node node, String key) { + if (node == null || node.getProperties() == null) { + return null; + } + Node value = node.getProperties().get(key); + Object raw = value != null ? value.getValue() : null; + return raw instanceof String ? (String) raw : null; + } + + static final class TerminationMarker { + final ScopeRuntimeContext.TerminationKind kind; + final String reason; + + TerminationMarker(ScopeRuntimeContext.TerminationKind kind, String reason) { + this.kind = kind; + this.reason = reason; } } @@ -319,6 +451,7 @@ static final class Execution { private final DocumentProcessingRuntime runtime; private final Map bundles = new LinkedHashMap<>(); private final Map pendingTerminations = new LinkedHashMap<>(); + private final Map terminationCategories = new LinkedHashMap<>(); private final Set cutOffScopes = new LinkedHashSet<>(); private final CheckpointManager checkpointManager; private final TerminationService terminationService; @@ -329,9 +462,10 @@ static final class Execution { this.owner = owner; this.runtime = new DocumentProcessingRuntime(document, owner.conformanceEngine(), + owner.conformancePlannerOverride(), owner.snapshotManager(), owner.metricsSink()); - this.checkpointManager = new CheckpointManager(runtime, ProcessorEngine::canonicalSignature); + this.checkpointManager = new CheckpointManager(runtime, owner.matchingService().blue()); this.terminationService = new TerminationService(runtime); this.channelRunner = new ChannelRunner(owner, this, runtime, checkpointManager); this.scopeExecutor = new ScopeExecutor(owner, this, runtime, bundles, channelRunner); @@ -341,9 +475,10 @@ static final class Execution { this.owner = owner; this.runtime = new DocumentProcessingRuntime(snapshot, owner.conformanceEngine(), + owner.conformancePlannerOverride(), owner.snapshotManager(), owner.metricsSink()); - this.checkpointManager = new CheckpointManager(runtime, ProcessorEngine::canonicalSignature); + this.checkpointManager = new CheckpointManager(runtime, owner.matchingService().blue()); this.terminationService = new TerminationService(runtime); this.channelRunner = new ChannelRunner(owner, this, runtime, checkpointManager); this.scopeExecutor = new ScopeExecutor(owner, this, runtime, bundles, channelRunner); @@ -361,6 +496,38 @@ void processExternalEvent(String scopePath, Node event) { scopeExecutor.processExternalEvent(scopePath, event); } + boolean applyScriptedForcedFatalIfPresent() { + ScriptedContractsRuntime scriptedRuntime = ScriptedContractsRuntime.active(); + if (scriptedRuntime == null || !scriptedRuntime.hasForcedFatal()) { + return false; + } + ScriptedContractsRuntime.ForcedFatal forcedFatal = scriptedRuntime.consumeForcedFatal(); + String scope = forcedFatal.scope() != null ? forcedFatal.scope() : "/"; + ensureContractsContainerForForcedFatal(scope); + enterFatalTermination(scope, + bundleForScope(ProcessorEngine.normalizeScope(scope)), + ProcessorErrorCategory.TerminationError, + forcedFatal.reason()); + return true; + } + + private void ensureContractsContainerForForcedFatal(String scopePath) { + String contractsPointer = ProcessorEngine.resolvePointer(scopePath, ProcessorPointerConstants.RELATIVE_CONTRACTS); + Node contracts = null; + try { + contracts = runtime.nodeAt(contractsPointer); + } catch (RuntimeException ignored) { + } + if (contracts != null && contracts.getProperties() != null) { + return; + } + Node replacement = runtime.document().clone(); + if ("/".equals(ProcessorEngine.normalizeScope(scopePath))) { + replacement.contracts(new Node()); + runtime.replaceDocument(replacement); + } + } + void handlePatch(String scopePath, ContractBundle bundle, JsonPatch patch, @@ -415,13 +582,24 @@ ProcessorExecutionContext createContext(String scopePath, } DocumentProcessingResult result() { + ProcessorStatus status = processingStatus(); + ProcessorErrorCategory category = rootErrorCategory(); + String reason = rootFailureReason(); ResolvedSnapshot snapshot = runtime.snapshot(); if (snapshot != null) { - return DocumentProcessingResult.of(snapshot, runtime.rootEmissions(), runtime.totalGas()); + return DocumentProcessingResult.of(snapshot, + runtime.rootEmissions(), + runtime.totalGas(), + status, + category, + reason); } return DocumentProcessingResult.of(runtime.document(), runtime.rootEmissions(), - runtime.totalGas()); + runtime.totalGas(), + status, + category, + reason); } DocumentProcessingResult partialResult() { @@ -438,6 +616,10 @@ DocumentProcessingRuntime runtime() { return runtime; } + Blue blue() { + return owner.matchingService().blue(); + } + boolean isScopeInactive(String scopePath) { String normalized = ProcessorEngine.normalizeScope(scopePath); return cutOffScopes.contains(normalized) @@ -450,6 +632,17 @@ void enterGracefulTermination(String scopePath, ContractBundle bundle, String re } void enterFatalTermination(String scopePath, ContractBundle bundle, String reason) { + enterFatalTermination(scopePath, bundle, ProcessorErrorCategory.InternalProcessorError, reason); + } + + void enterFatalTermination(String scopePath, + ContractBundle bundle, + ProcessorErrorCategory errorCategory, + String reason) { + String normalized = ProcessorEngine.normalizeScope(scopePath); + terminationCategories.put(normalized, errorCategory != null + ? errorCategory + : ProcessorErrorCategory.InternalProcessorError); terminate(scopePath, bundle, ScopeRuntimeContext.TerminationKind.FATAL, reason); } @@ -502,6 +695,67 @@ String fatalReason(Throwable throwable, String defaultReason) { return message != null ? message : defaultReason; } + ProcessorErrorCategory fatalCategory(Throwable throwable, ProcessorErrorCategory defaultCategory) { + if (throwable instanceof ProcessorFailureException) { + return ((ProcessorFailureException) throwable).errorCategory(); + } + if (throwable instanceof ProcessorFatalException) { + return ((ProcessorFatalException) throwable).errorCategory(); + } + if (throwable instanceof MustUnderstandFailureException) { + return ((MustUnderstandFailureException) throwable).errorCategory(); + } + return defaultCategory != null ? defaultCategory : ProcessorErrorCategory.InternalProcessorError; + } + + private ProcessorStatus processingStatus() { + PendingTermination pendingRoot = pendingTerminations.get("/"); + if (pendingRoot != null && pendingRoot.kind == ScopeRuntimeContext.TerminationKind.FATAL) { + return ProcessorStatus.RUNTIME_FATAL; + } + for (PendingTermination pending : pendingTerminations.values()) { + if (pending.kind == ScopeRuntimeContext.TerminationKind.FATAL) { + return ProcessorStatus.RUNTIME_FATAL; + } + } + if (!terminationCategories.isEmpty()) { + return ProcessorStatus.RUNTIME_FATAL; + } + ProcessorEngine.TerminationMarker marker = runtime.terminationMarker("/"); + if (marker != null && marker.kind == ScopeRuntimeContext.TerminationKind.FATAL) { + return ProcessorStatus.RUNTIME_FATAL; + } + return ProcessorStatus.SUCCESS; + } + + private ProcessorErrorCategory rootErrorCategory() { + if (processingStatus() != ProcessorStatus.RUNTIME_FATAL) { + return null; + } + ProcessorErrorCategory category = terminationCategories.get("/"); + if (category != null) { + return category; + } + if (!terminationCategories.isEmpty()) { + return terminationCategories.values().iterator().next(); + } + return ProcessorErrorCategory.InternalProcessorError; + } + + private String rootFailureReason() { + PendingTermination pendingRoot = pendingTerminations.get("/"); + if (pendingRoot != null && pendingRoot.reason != null) { + return pendingRoot.reason; + } + for (PendingTermination pending : pendingTerminations.values()) { + if (pending.reason != null) { + return pending.reason; + } + } + ProcessorEngine.TerminationMarker marker = runtime.terminationMarker("/"); + return marker != null ? marker.reason : null; + } + void deliverLifecycle(String scopePath, ContractBundle bundle, Node event, diff --git a/src/main/java/blue/language/processor/ProcessorErrorCategory.java b/src/main/java/blue/language/processor/ProcessorErrorCategory.java new file mode 100644 index 0000000..e432bd7 --- /dev/null +++ b/src/main/java/blue/language/processor/ProcessorErrorCategory.java @@ -0,0 +1,23 @@ +package blue.language.processor; + +/** + * Stable diagnostic categories used by Blue Contracts conformance checks. + */ +public enum ProcessorErrorCategory { + InvalidProcessingDocument, + UnsupportedContract, + InvalidReservedMarker, + InvalidRuntimePointer, + BoundaryViolation, + ReservedKeyWrite, + InvalidPatch, + InvalidPatchValue, + HandlerExecutionError, + CheckpointError, + TerminationError, + GasError, + GeneralizationRejected, + GeneralizationNoValidType, + TypeSoundnessViolation, + InternalProcessorError +} diff --git a/src/main/java/blue/language/processor/ProcessorExecutionContext.java b/src/main/java/blue/language/processor/ProcessorExecutionContext.java index 260e937..ce0c3a6 100644 --- a/src/main/java/blue/language/processor/ProcessorExecutionContext.java +++ b/src/main/java/blue/language/processor/ProcessorExecutionContext.java @@ -1,6 +1,7 @@ package blue.language.processor; import blue.language.model.Node; +import blue.language.processor.conformance.ScriptedContractsRuntime; import blue.language.processor.model.JsonPatch; import blue.language.snapshot.FrozenNode; @@ -21,6 +22,8 @@ public final class ProcessorExecutionContext { private final Node event; private final boolean allowTerminatedWork; private final boolean allowReservedMutation; + private final ContractEffectBuffer effects = new ContractEffectBuffer(); + private boolean effectsApplied; ProcessorExecutionContext(ProcessorEngine.Execution execution, ContractBundle bundle, @@ -74,7 +77,7 @@ public void applyPatches(List patches) { if (patches == null || patches.isEmpty()) { return; } - execution.handlePatches(scopePath, bundle, patches, allowReservedMutation); + effects.addPatches(patches); } public void emitEvent(Node emission) { @@ -82,14 +85,55 @@ public void emitEvent(Node emission) { return; } Objects.requireNonNull(emission, "emission"); - DocumentProcessingRuntime runtime = runtime(); - ScopeRuntimeContext scopeContext = runtime.scope(scopePath); - runtime.chargeEmitEvent(emission); - Node queued = emission.clone(); - scopeContext.enqueueTriggered(queued); - scopeContext.recordBridgeable(queued.clone()); - if ("/".equals(scopeContext.scopePath())) { - runtime.recordRootEmission(queued.clone()); + effects.emit(emission); + } + + void applyBufferedEffects() { + if (effectsApplied) { + return; + } + effectsApplied = true; + if (!allowTerminatedWork && execution.isScopeInactive(scopePath)) { + return; + } + if (effects.invalidGasReason() != null) { + execution.enterFatalTermination(scopePath, + bundle, + ProcessorErrorCategory.GasError, + effects.invalidGasReason()); + return; + } + if (effects.gas() > 0L) { + runtime().addGas(effects.gas()); + } + if (!effects.patches().isEmpty()) { + execution.handlePatches(scopePath, bundle, effects.patches(), allowReservedMutation); + if (!allowTerminatedWork && execution.isScopeInactive(scopePath)) { + return; + } + } + for (Node emission : effects.emittedEvents()) { + if (!emitEventNow(emission)) { + return; + } + if (!allowTerminatedWork && execution.isScopeInactive(scopePath)) { + return; + } + } + ContractEffectBuffer.TerminationRequest termination = effects.terminationRequest(); + if (termination != null) { + ScriptedContractsRuntime scriptedRuntime = ScriptedContractsRuntime.active(); + if (scriptedRuntime != null) { + scriptedRuntime.recordTermination(runtime(), termination.kind()); + } + if (termination.kind() == ScopeRuntimeContext.TerminationKind.FATAL) { + execution.enterFatalTermination(scopePath, + bundle, + ProcessorErrorCategory.InternalProcessorError, + termination.reason()); + } else { + execution.enterGracefulTermination(scopePath, bundle, termination.reason()); + } } } @@ -97,11 +141,14 @@ public void consumeGas(long units) { if (!allowTerminatedWork && execution.isScopeInactive(scopePath)) { return; } - runtime().addGas(units); + effects.addGas(units); } public void throwFatal(String reason) { - throw new ProcessorFatalException(reason, execution.partialResult()); + applyBufferedEffects(); + throw new ProcessorFatalException(reason, + execution.partialResult(), + ProcessorErrorCategory.HandlerExecutionError); } public String resolvePointer(String pointer) { @@ -137,11 +184,40 @@ public boolean documentContains(String absolutePointer) { } public void terminateGracefully(String reason) { - execution.enterGracefulTermination(scopePath, bundle, reason); + effects.terminate(ScopeRuntimeContext.TerminationKind.GRACEFUL, reason); } public void terminateFatally(String reason) { - execution.enterFatalTermination(scopePath, bundle, reason); + effects.terminate(ScopeRuntimeContext.TerminationKind.FATAL, reason); + } + + private boolean emitEventNow(Node emission) { + try { + CheckpointIdentityCalculator.identity(emission, execution.blue()); + } catch (RuntimeException ex) { + execution.enterFatalTermination(scopePath, + bundle, + ProcessorErrorCategory.InvalidPatchValue, + "Invalid emitted event: " + ex.getMessage()); + return false; + } + if (!allowTerminatedWork && execution.isScopeInactive(scopePath)) { + return false; + } + DocumentProcessingRuntime runtime = runtime(); + ScopeRuntimeContext scopeContext = runtime.scope(scopePath); + runtime.chargeEmitEvent(emission); + Node queued = emission.clone(); + scopeContext.enqueueTriggered(queued); + scopeContext.recordBridgeable(queued.clone()); + ScriptedContractsRuntime scriptedRuntime = ScriptedContractsRuntime.active(); + if (scriptedRuntime != null) { + scriptedRuntime.recordTriggeredEvent(runtime, queued); + } + if ("/".equals(scopeContext.scopePath())) { + runtime.recordRootEmission(queued.clone()); + } + return true; } private DocumentProcessingRuntime runtime() { diff --git a/src/main/java/blue/language/processor/ProcessorFailureException.java b/src/main/java/blue/language/processor/ProcessorFailureException.java new file mode 100644 index 0000000..a98c502 --- /dev/null +++ b/src/main/java/blue/language/processor/ProcessorFailureException.java @@ -0,0 +1,27 @@ +package blue.language.processor; + +/** + * Runtime exception carrying a processor diagnostic category. + */ +public class ProcessorFailureException extends IllegalArgumentException { + + private final ProcessorErrorCategory errorCategory; + + public ProcessorFailureException(ProcessorErrorCategory errorCategory, String message) { + super(message); + this.errorCategory = errorCategory != null + ? errorCategory + : ProcessorErrorCategory.InternalProcessorError; + } + + public ProcessorFailureException(ProcessorErrorCategory errorCategory, String message, Throwable cause) { + super(message, cause); + this.errorCategory = errorCategory != null + ? errorCategory + : ProcessorErrorCategory.InternalProcessorError; + } + + public ProcessorErrorCategory errorCategory() { + return errorCategory; + } +} diff --git a/src/main/java/blue/language/processor/ProcessorFatalException.java b/src/main/java/blue/language/processor/ProcessorFatalException.java index bf3f13a..d14c23d 100644 --- a/src/main/java/blue/language/processor/ProcessorFatalException.java +++ b/src/main/java/blue/language/processor/ProcessorFatalException.java @@ -3,14 +3,24 @@ public class ProcessorFatalException extends RuntimeException { private final DocumentProcessingResult partialResult; + private final ProcessorErrorCategory errorCategory; public ProcessorFatalException(String message) { this(message, null); } public ProcessorFatalException(String message, DocumentProcessingResult partialResult) { + this(message, partialResult, ProcessorErrorCategory.InternalProcessorError); + } + + public ProcessorFatalException(String message, + DocumentProcessingResult partialResult, + ProcessorErrorCategory errorCategory) { super(message); this.partialResult = partialResult; + this.errorCategory = errorCategory != null + ? errorCategory + : ProcessorErrorCategory.InternalProcessorError; } public DocumentProcessingResult partialResult() { @@ -20,4 +30,8 @@ public DocumentProcessingResult partialResult() { public long totalGas() { return partialResult != null ? partialResult.totalGas() : 0L; } + + public ProcessorErrorCategory errorCategory() { + return errorCategory; + } } diff --git a/src/main/java/blue/language/processor/ProcessorStatus.java b/src/main/java/blue/language/processor/ProcessorStatus.java new file mode 100644 index 0000000..3b518e1 --- /dev/null +++ b/src/main/java/blue/language/processor/ProcessorStatus.java @@ -0,0 +1,21 @@ +package blue.language.processor; + +/** + * Processor-visible status for a PROCESS run. + */ +public enum ProcessorStatus { + SUCCESS("success"), + CAPABILITY_FAILURE("capability-failure"), + RUNTIME_FATAL("runtime-fatal"), + INVALID_PROCESSING_DOCUMENT("invalid-processing-document"); + + private final String wireValue; + + ProcessorStatus(String wireValue) { + this.wireValue = wireValue; + } + + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/blue/language/processor/ScopeExecutor.java b/src/main/java/blue/language/processor/ScopeExecutor.java index 0455fd4..c928da1 100644 --- a/src/main/java/blue/language/processor/ScopeExecutor.java +++ b/src/main/java/blue/language/processor/ScopeExecutor.java @@ -1,17 +1,21 @@ package blue.language.processor; import blue.language.model.Node; +import blue.language.processor.conformance.ScriptedContractsRuntime; import blue.language.processor.model.ChannelContract; import blue.language.processor.model.DocumentUpdateChannel; import blue.language.processor.model.EmbeddedNodeChannel; import blue.language.processor.model.JsonPatch; import blue.language.processor.model.LifecycleChannel; import blue.language.processor.model.TriggeredEventChannel; +import blue.language.processor.registry.RuntimeBlueIds; import blue.language.processor.util.ProcessorContractConstants; import blue.language.processor.util.ProcessorPointerConstants; +import blue.language.processor.util.PointerUtils; import blue.language.snapshot.FrozenNode; import blue.language.utils.BlueIdCalculator; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; @@ -47,15 +51,37 @@ final class ScopeExecutor { } void initializeScope(String scopePath, boolean chargeScopeEntry) { + initializeScope(scopePath, chargeScopeEntry, true); + } + + private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean finalizeAfterInitialization) { String normalizedScope = ProcessorEngine.normalizeScope(scopePath); Set processedEmbedded = new LinkedHashSet<>(); ContractBundle bundle = null; Node preInitSnapshot = null; + ScopeRuntimeContext scopeContext = runtime.scope(normalizedScope); + if ("/".equals(normalizedScope)) { + runtime.setScopeEmbeddedDepth(normalizedScope, 0); + } + scopeContext.clearProcessedEmbeddedPaths(); if (chargeScopeEntry) { runtime.chargeScopeEntry(normalizedScope); } + try { + if (runtime.hasTerminationMarker(normalizedScope)) { + runtime.markScopeTerminatedFromMarker(normalizedScope); + return; + } + } catch (IllegalStateException ex) { + execution.enterFatalTermination(normalizedScope, + null, + ProcessorErrorCategory.InvalidReservedMarker, + execution.fatalReason(ex, "Invalid terminated marker")); + return; + } + while (true) { FrozenNode scopeNode = runtime.resolvedFrozenAt(normalizedScope); if (scopeNode == null) { @@ -70,23 +96,37 @@ void initializeScope(String scopePath, boolean chargeScopeEntry) { bundle = owner.contractLoader().load(scopeNode, normalizedScope, owner.metricsSink()); bundles.put(normalizedScope, bundle); - String nextEmbedded = null; - for (String candidate : bundle.embeddedPaths()) { - if (!processedEmbedded.contains(candidate)) { - nextEmbedded = candidate; - break; - } + String childScope; + try { + childScope = nextEmbeddedChildScope(normalizedScope, bundle, processedEmbedded); + } catch (ProcessorEngine.BoundaryViolationException | IllegalArgumentException ex) { + execution.enterFatalTermination(normalizedScope, + bundle, + ProcessorErrorCategory.BoundaryViolation, + execution.fatalReason(ex, "Invalid embedded path")); + return; } - - if (nextEmbedded == null) { + if (childScope == null) { break; } - processedEmbedded.add(nextEmbedded); - String childScope = ProcessorEngine.resolvePointer(normalizedScope, nextEmbedded); + processedEmbedded.add(childScope); + scopeContext.recordProcessedEmbeddedPath(childScope); + runtime.setScopeEmbeddedDepth(childScope, runtime.scopeEmbeddedDepth(normalizedScope) + 1); FrozenNode childNode = runtime.resolvedFrozenAt(childScope); if (childNode != null) { - initializeScope(childScope, true); + if (!isObjectScope(childNode)) { + if (!finalizeAfterInitialization) { + continue; + } + initializeCurrentScopeIfNeeded(normalizedScope, bundle); + execution.enterFatalTermination(normalizedScope, + bundle, + ProcessorErrorCategory.BoundaryViolation, + "Embedded path " + childScope + " does not select an object scope"); + return; + } + initializeScope(childScope, true, finalizeAfterInitialization); } } @@ -95,7 +135,7 @@ void initializeScope(String scopePath, boolean chargeScopeEntry) { } boolean initialized = runtime.hasInitializationMarker(normalizedScope); - if (!initialized && bundle.hasCheckpoint()) { + if (!initialized && finalizeAfterInitialization && bundle.hasCheckpoint()) { throw new IllegalStateException("Reserved key 'checkpoint' must not appear before initialization at scope " + normalizedScope); } @@ -107,8 +147,12 @@ void initializeScope(String scopePath, boolean chargeScopeEntry) { String documentId = BlueIdCalculator.calculateUncheckedBlueId(preInitSnapshot != null ? preInitSnapshot : new Node()); Node lifecycleEvent = ProcessorEngine.createLifecycleInitiatedEvent(documentId); ProcessorExecutionContext context = execution.createContext(normalizedScope, bundle, lifecycleEvent, false, true); - deliverLifecycle(normalizedScope, bundle, lifecycleEvent, true); + deliverLifecycle(normalizedScope, bundle, lifecycleEvent, false); addInitializationMarker(context, documentId); + if (finalizeAfterInitialization && !execution.isScopeInactive(normalizedScope)) { + ContractBundle refreshed = refreshBundle(normalizedScope); + finalizeScope(normalizedScope, refreshed); + } } void loadBundles(String scopePath) { @@ -116,6 +160,14 @@ void loadBundles(String scopePath) { if (bundles.containsKey(normalizedScope)) { return; } + try { + if (runtime.hasTerminationMarker(normalizedScope)) { + bundles.put(normalizedScope, ContractBundle.empty()); + return; + } + } catch (IllegalStateException ex) { + throw new MustUnderstandFailureException(ex.getMessage()); + } FrozenNode scopeNode = runtime.resolvedFrozenAt(normalizedScope); ContractBundle bundle = scopeNode != null ? owner.contractLoader().load(scopeNode, normalizedScope, owner.metricsSink()) @@ -132,11 +184,37 @@ void processExternalEvent(String scopePath, Node event) { if (execution.isScopeInactive(normalizedScope)) { return; } + if ("/".equals(normalizedScope)) { + runtime.setScopeEmbeddedDepth(normalizedScope, 0); + } runtime.chargeScopeEntry(normalizedScope); + try { + if (runtime.hasTerminationMarker(normalizedScope)) { + runtime.markScopeTerminatedFromMarker(normalizedScope); + return; + } + } catch (IllegalStateException ex) { + ContractBundle bundle = bundles.get(normalizedScope); + execution.enterFatalTermination(normalizedScope, + bundle, + ProcessorErrorCategory.InvalidReservedMarker, + execution.fatalReason(ex, "Invalid terminated marker")); + return; + } ContractBundle bundle = processEmbeddedChildren(normalizedScope, event); if (bundle == null) { return; } + if (!runtime.hasInitializationMarker(normalizedScope)) { + initializeScope(normalizedScope, false, false); + if (execution.isScopeInactive(normalizedScope)) { + return; + } + bundle = refreshBundle(normalizedScope); + if (bundle == null) { + return; + } + } long channelDiscoveryStart = System.nanoTime(); List channels = bundle.channelsOfType(ChannelContract.class); owner.metricsSink().addChannelDiscoveryNanos(System.nanoTime() - channelDiscoveryStart); @@ -144,6 +222,12 @@ void processExternalEvent(String scopePath, Node event) { finalizeScope(normalizedScope, bundle); return; } + long externalCandidateCount = channels.stream() + .filter(channel -> !ProcessorContractConstants.isProcessorManagedChannel(channel.contract())) + .count(); + if (externalCandidateCount > 1) { + runtime.addGas(1L); + } for (ContractBundle.ChannelBinding channel : channels) { if (execution.isScopeInactive(normalizedScope)) { break; @@ -179,82 +263,147 @@ void handlePatches(String scopePath, if (patches == null || patches.isEmpty()) { return; } - runtime.chargeBoundaryCheck(); - try { - long boundaryStart = System.nanoTime(); - for (JsonPatch patch : patches) { + for (JsonPatch patch : patches) { + if (execution.isScopeInactive(scopePath)) { + return; + } + if (!allowReservedMutation) { + runtime.chargeBoundaryCheck(); + } + try { + long boundaryStart = System.nanoTime(); validatePatchBoundary(scopePath, bundle, patch); enforceReservedKeyWriteProtection(scopePath, patch, allowReservedMutation); + owner.metricsSink().addPatchBoundaryNanos(System.nanoTime() - boundaryStart); + } catch (ProcessorEngine.BoundaryViolationException ex) { + execution.enterFatalTermination(scopePath, + bundle, + ProcessorErrorCategory.BoundaryViolation, + execution.fatalReason(ex, "Boundary violation")); + return; + } catch (ProcessorFailureException ex) { + execution.enterFatalTermination(scopePath, + bundle, + ex.errorCategory(), + execution.fatalReason(ex, "Runtime fatal")); + return; + } catch (IllegalArgumentException ex) { + execution.enterFatalTermination(scopePath, + bundle, + ProcessorErrorCategory.InvalidPatch, + execution.fatalReason(ex, "Boundary violation")); + return; } - owner.metricsSink().addPatchBoundaryNanos(System.nanoTime() - boundaryStart); - } catch (ProcessorEngine.BoundaryViolationException ex) { - execution.enterFatalTermination(scopePath, bundle, execution.fatalReason(ex, "Boundary violation")); - return; - } - try { - long gasStart = System.nanoTime(); - for (JsonPatch patch : patches) { - switch (patch.getOp()) { - case ADD: - case REPLACE: - runtime.chargePatchAddOrReplace(patch.getVal()); - break; - case REMOVE: - runtime.chargePatchRemove(); - break; - default: + try { + long gasStart = System.nanoTime(); + chargePatchGas(patch); + owner.metricsSink().addPatchGasNanos(System.nanoTime() - gasStart); + List updates = runtime.applyPatches(scopePath, + Collections.singletonList(patch)); + long routingStart = System.nanoTime(); + for (DocumentProcessingRuntime.DocumentUpdateData update : updates) { + routeDocumentUpdateAfterPatch(scopePath, bundle, update); + if (execution.isScopeInactive(scopePath)) { break; + } } + owner.metricsSink().addDocumentUpdateRoutingNanos(System.nanoTime() - routingStart); + } catch (ProcessorEngine.BoundaryViolationException ex) { + execution.enterFatalTermination(scopePath, + bundle, + ProcessorErrorCategory.BoundaryViolation, + execution.fatalReason(ex, "Boundary violation")); + return; + } catch (MustUnderstandFailureException ex) { + execution.enterFatalTermination(scopePath, + bundle, + ex.errorCategory(), + execution.fatalReason(ex, "Unsupported runtime contract")); + return; + } catch (ProcessorFailureException ex) { + execution.enterFatalTermination(scopePath, + bundle, + ex.errorCategory(), + execution.fatalReason(ex, "Runtime fatal")); + return; + } catch (IllegalArgumentException | IllegalStateException ex) { + execution.enterFatalTermination(scopePath, + bundle, + execution.fatalCategory(ex, ProcessorErrorCategory.InternalProcessorError), + execution.fatalReason(ex, "Runtime fatal")); + return; } - owner.metricsSink().addPatchGasNanos(System.nanoTime() - gasStart); - List updates = runtime.applyPatches(scopePath, patches); - long routingStart = System.nanoTime(); - routeDocumentUpdatesAfterBatch(scopePath, bundle, updates); - owner.metricsSink().addDocumentUpdateRoutingNanos(System.nanoTime() - routingStart); - } catch (ProcessorEngine.BoundaryViolationException ex) { - execution.enterFatalTermination(scopePath, bundle, execution.fatalReason(ex, "Boundary violation")); - } catch (IllegalArgumentException | IllegalStateException ex) { - execution.enterFatalTermination(scopePath, bundle, execution.fatalReason(ex, "Runtime fatal")); } } - private void routeDocumentUpdatesAfterBatch(String scopePath, - ContractBundle bundle, - List updates) { - if (updates == null || updates.isEmpty()) { + private void chargePatchGas(JsonPatch patch) { + switch (patch.getOp()) { + case ADD: + case REPLACE: + runtime.chargePatchAddOrReplace(patch.getVal()); + break; + case REMOVE: + runtime.chargePatchRemove(); + break; + default: + break; + } + } + + private void routeDocumentUpdateAfterPatch(String scopePath, + ContractBundle bundle, + DocumentProcessingRuntime.DocumentUpdateData data) { + if (data == null) { return; } - for (DocumentProcessingRuntime.DocumentUpdateData data : updates) { - if (data == null) { + ScriptedContractsRuntime scriptedRuntime = ScriptedContractsRuntime.active(); + if (scriptedRuntime != null) { + scriptedRuntime.recordDocumentUpdate(runtime, data.path(), data.before(), data.after()); + } + markCutOffChildrenIfNeeded(scopePath, bundle, data); + List participants = new ArrayList<>(); + for (String cascadeScope : data.cascadeScopes()) { + if (execution.isScopeInactive(cascadeScope)) { continue; } - markCutOffChildrenIfNeeded(scopePath, bundle, data); - runtime.chargeCascadeRouting(data.cascadeScopes().size()); - for (String cascadeScope : data.cascadeScopes()) { - ContractBundle targetBundle = bundles.get(cascadeScope); - if (targetBundle == null) { - continue; - } - if (execution.isScopeInactive(cascadeScope)) { - continue; + ContractBundle targetBundle; + try { + targetBundle = refreshBundle(cascadeScope); + } catch (MustUnderstandFailureException ex) { + execution.enterFatalTermination(cascadeScope, + bundles.get(cascadeScope), + ex.errorCategory(), + execution.fatalReason(ex, "Unsupported runtime contract")); + return; + } + if (targetBundle == null) { + continue; + } + List matching = new ArrayList<>(); + for (ContractBundle.ChannelBinding channel : targetBundle.channelsOfType(DocumentUpdateChannel.class)) { + DocumentUpdateChannel duc = (DocumentUpdateChannel) channel.contract(); + if (ProcessorEngine.matchesDocumentUpdate(cascadeScope, duc.getPath(), data.path())) { + matching.add(channel); } - List channels = targetBundle.channelsOfType(DocumentUpdateChannel.class); - if (channels.isEmpty()) { - owner.metricsSink().incrementDocumentUpdateEventsSkippedNoChannel(); + } + if (matching.isEmpty()) { + owner.metricsSink().incrementDocumentUpdateEventsSkippedNoChannel(); + continue; + } + participants.add(new DocumentUpdateParticipant(cascadeScope, targetBundle, matching)); + } + runtime.chargeCascadeRouting(participants.size()); + for (DocumentUpdateParticipant participant : participants) { + if (execution.isScopeInactive(participant.scopePath)) { + continue; + } + Node updateEvent = ProcessorEngine.createDocumentUpdateEvent(data, participant.scopePath); + owner.metricsSink().incrementDocumentUpdateEventsBuilt(); + for (ContractBundle.ChannelBinding channel : participant.channels) { + channelRunner.runHandlers(participant.scopePath, participant.bundle, channel.key(), updateEvent, false); + if (execution.isScopeInactive(participant.scopePath)) { continue; } - for (ContractBundle.ChannelBinding channel : channels) { - DocumentUpdateChannel duc = (DocumentUpdateChannel) channel.contract(); - if (!ProcessorEngine.matchesDocumentUpdate(cascadeScope, duc.getPath(), data.path())) { - continue; - } - Node updateEvent = ProcessorEngine.createDocumentUpdateEvent(data, cascadeScope); - owner.metricsSink().incrementDocumentUpdateEventsBuilt(); - channelRunner.runHandlers(cascadeScope, targetBundle, channel.key(), updateEvent, false); - if (execution.isScopeInactive(cascadeScope)) { - break; - } - } } } } @@ -282,26 +431,54 @@ void deliverLifecycle(String scopePath, private ContractBundle processEmbeddedChildren(String scopePath, Node event) { String normalizedScope = ProcessorEngine.normalizeScope(scopePath); Set processed = new LinkedHashSet<>(); + ScopeRuntimeContext scopeContext = runtime.scope(normalizedScope); + scopeContext.clearProcessedEmbeddedPaths(); ContractBundle bundle = refreshBundle(normalizedScope); while (bundle != null) { - String next = nextEmbeddedPath(bundle, processed); - if (next == null) { - return bundle; + String childScope; + try { + childScope = nextEmbeddedChildScope(normalizedScope, bundle, processed); + } catch (ProcessorEngine.BoundaryViolationException | IllegalArgumentException ex) { + execution.enterFatalTermination(normalizedScope, + bundle, + ProcessorErrorCategory.BoundaryViolation, + execution.fatalReason(ex, "Invalid embedded path")); + return null; } - processed.add(next); - String childScope = ProcessorEngine.resolvePointer(normalizedScope, next); - if (childScope.equals(normalizedScope)) { - bundle = refreshBundle(normalizedScope); - continue; + if (childScope == null) { + return bundle; } + processed.add(childScope); + scopeContext.recordProcessedEmbeddedPath(childScope); + runtime.setScopeEmbeddedDepth(childScope, runtime.scopeEmbeddedDepth(normalizedScope) + 1); if (execution.isScopeInactive(childScope)) { bundle = refreshBundle(normalizedScope); continue; } FrozenNode childNode = runtime.resolvedFrozenAt(childScope); if (childNode != null) { - initializeScope(childScope, false); + if (!isObjectScope(childNode)) { + if ("initialize".equals(eventKind(event))) { + bundle = refreshBundle(normalizedScope); + continue; + } + initializeCurrentScopeIfNeeded(normalizedScope, bundle); + execution.enterFatalTermination(normalizedScope, + bundle, + ProcessorErrorCategory.BoundaryViolation, + "Embedded path " + childScope + " does not select an object scope"); + return null; + } + ScriptedContractsRuntime scriptedRuntime = ScriptedContractsRuntime.active(); + if (scriptedRuntime != null) { + scriptedRuntime.recordEmbeddedScopeDelivery(childScope); + } processExternalEvent(childScope, event); + if (scriptedRuntime != null) { + for (Node emission : scriptedRuntime.childEmissions(childScope)) { + runtime.scope(childScope).recordBridgeable(emission); + } + } } bundle = refreshBundle(normalizedScope); } @@ -320,24 +497,68 @@ private ContractBundle refreshBundle(String scopePath) { return refreshed; } - private String nextEmbeddedPath(ContractBundle bundle, Set processed) { + private String nextEmbeddedChildScope(String scopePath, ContractBundle bundle, Set processed) { if (bundle == null) { return null; } + Set seenInBundle = new LinkedHashSet<>(); for (String candidate : bundle.embeddedPaths()) { - if (!processed.contains(candidate)) { - return candidate; + String normalizedCandidate = PointerUtils.assertValidRuntimePointer(candidate); + String childScope = ProcessorEngine.resolvePointer(scopePath, normalizedCandidate); + if (childScope.equals(ProcessorEngine.normalizeScope(scopePath))) { + throw new ProcessorEngine.BoundaryViolationException("Process Embedded path '/' cannot embed its declaring scope"); + } + if (!seenInBundle.add(childScope)) { + throw new ProcessorEngine.BoundaryViolationException("Duplicate Process Embedded path: " + normalizedCandidate); + } + if (!processed.contains(childScope)) { + return childScope; } } return null; } + private boolean isObjectScope(FrozenNode node) { + return node != null + && node.getValue() == null + && !node.hasItems() + && !node.isReferenceOnly() + && node.getPreviousBlueId() == null; + } + + private String eventKind(Node event) { + if (event == null || event.getProperties() == null) { + return null; + } + Node kind = event.getProperties().get("kind"); + Object value = kind != null ? kind.getValue() : null; + return value != null ? String.valueOf(value) : null; + } + private void addInitializationMarker(ProcessorExecutionContext context, String documentId) { Node marker = new Node() - .type(new Node().blueId("InitializationMarker")) + .type(new Node().blueId(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER)) .properties("documentId", new Node().value(documentId)); String pointer = context.resolvePointer(ProcessorPointerConstants.RELATIVE_INITIALIZED); context.applyPatch(JsonPatch.add(pointer, marker)); + context.applyBufferedEffects(); + } + + private void initializeCurrentScopeIfNeeded(String scopePath, ContractBundle bundle) { + String normalizedScope = ProcessorEngine.normalizeScope(scopePath); + if (runtime.hasInitializationMarker(normalizedScope) || execution.isScopeInactive(normalizedScope)) { + return; + } + FrozenNode canonicalScopeNode = runtime.canonicalFrozenAt(normalizedScope); + String documentId = BlueIdCalculator.calculateUncheckedBlueId( + canonicalScopeNode != null ? canonicalScopeNode.toNode() : new Node()); + runtime.chargeInitialization(); + Node lifecycleEvent = ProcessorEngine.createLifecycleInitiatedEvent(documentId); + ProcessorExecutionContext context = execution.createContext(normalizedScope, bundle, lifecycleEvent, false, true); + deliverLifecycle(normalizedScope, bundle, lifecycleEvent, false); + if (!execution.isScopeInactive(normalizedScope)) { + addInitializationMarker(context, documentId); + } } private void finalizeScope(String scopePath, ContractBundle bundle) { @@ -355,22 +576,24 @@ private void bridgeEmbeddedEmissions(String scopePath, ContractBundle bundle) { if (execution.isScopeInactive(scopePath)) { return; } - if (bundle.embeddedPaths().isEmpty()) { + ScopeRuntimeContext parentContext = runtime.scope(scopePath); + List processedChildScopes = parentContext.processedEmbeddedPaths(); + if (processedChildScopes.isEmpty()) { return; } - List embeddedChannels = bundle.channelsOfType(EmbeddedNodeChannel.class); - for (String embeddedPointer : bundle.embeddedPaths()) { - String childScope = ProcessorEngine.resolvePointer(scopePath, embeddedPointer); + for (String childScope : processedChildScopes) { ScopeRuntimeContext childContext = runtime.scope(childScope); List emissions = childContext.drainBridgeableEvents(); if (emissions.isEmpty()) { continue; } - if (embeddedChannels.isEmpty()) { - continue; - } for (Node emission : emissions) { + ContractBundle currentBundle = refreshBundle(scopePath); + List embeddedChannels = currentBundle != null + ? currentBundle.channelsOfType(EmbeddedNodeChannel.class) + : Collections.emptyList(); boolean charged = false; + List deliveredChannels = new ArrayList<>(); for (ContractBundle.ChannelBinding channel : embeddedChannels) { EmbeddedNodeChannel enc = (EmbeddedNodeChannel) channel.contract(); String configuredChild = enc.getChildPath() != null ? enc.getChildPath() : "/"; @@ -382,7 +605,13 @@ private void bridgeEmbeddedEmissions(String scopePath, ContractBundle bundle) { runtime.chargeBridge(emission); charged = true; } - channelRunner.runHandlers(scopePath, bundle, channel.key(), emission.clone(), false); + deliveredChannels.add(channel.key()); + channelRunner.runHandlers(scopePath, currentBundle, channel.key(), emission.clone(), false); + } + ScriptedContractsRuntime scriptedRuntime = ScriptedContractsRuntime.active(); + if (scriptedRuntime != null) { + scriptedRuntime.recordEmbeddedBridgeDelivery(emission, deliveredChannels); + scriptedRuntime.afterBridgeEmission(scopePath, runtime, emission); } } } @@ -398,26 +627,34 @@ private void drainTriggeredQueue(String scopePath, ContractBundle bundle) { if (context.triggeredQueue().isEmpty()) { return; } - List triggeredChannels = bundle.channelsOfType(TriggeredEventChannel.class); - if (triggeredChannels.isEmpty()) { - context.triggeredQueue().clear(); - return; - } while (!context.triggeredQueue().isEmpty()) { Node next = context.triggeredQueue().pollFirst(); + ContractBundle currentBundle = refreshBundle(scopePath); + List triggeredChannels = currentBundle != null + ? currentBundle.channelsOfType(TriggeredEventChannel.class) + : Collections.emptyList(); owner.metricsSink().incrementTriggeredEventsRouted(); + if (triggeredChannels.isEmpty()) { + continue; + } runtime.chargeDrainEvent(); + List deliveredChannels = new ArrayList<>(); for (ContractBundle.ChannelBinding channel : triggeredChannels) { if (execution.isScopeInactive(scopePath)) { context.triggeredQueue().clear(); return; } - channelRunner.runHandlers(scopePath, bundle, channel.key(), next.clone(), false); + deliveredChannels.add(channel.key()); + channelRunner.runHandlers(scopePath, currentBundle, channel.key(), next.clone(), false); if (execution.isScopeInactive(scopePath)) { context.triggeredQueue().clear(); return; } } + ScriptedContractsRuntime scriptedRuntime = ScriptedContractsRuntime.active(); + if (scriptedRuntime != null) { + scriptedRuntime.recordTriggeredDelivery(next, deliveredChannels); + } } } finally { owner.metricsSink().addTriggeredEventRoutingNanos(System.nanoTime() - routingStart); @@ -429,14 +666,18 @@ private void validatePatchBoundary(String scopePath, ContractBundle bundle, Json return; } String normalizedScope = ProcessorEngine.normalizeScope(scopePath); - String targetPath = ProcessorEngine.normalizePointer(patch.getPath()); + String targetPath = PointerUtils.assertValidRuntimePointer(patch.getPath()); + + if ("/".equals(targetPath)) { + throw new ProcessorEngine.BoundaryViolationException("Patch path '/' is forbidden"); + } if (targetPath.equals(normalizedScope)) { throw new ProcessorEngine.BoundaryViolationException("Self-root mutation is forbidden at scope " + normalizedScope); } if (!"/".equals(normalizedScope)) { - if (!targetPath.startsWith(normalizedScope + "/")) { + if (!PointerUtils.strictlyInside(targetPath, normalizedScope)) { throw new ProcessorEngine.BoundaryViolationException( "Patch path " + targetPath + " is outside scope " + normalizedScope); } @@ -444,7 +685,7 @@ private void validatePatchBoundary(String scopePath, ContractBundle bundle, Json for (String embeddedPointer : bundle.embeddedPaths()) { String embeddedScope = ProcessorEngine.resolvePointer(normalizedScope, embeddedPointer); - if (targetPath.startsWith(embeddedScope + "/")) { + if (PointerUtils.strictlyInside(targetPath, embeddedScope)) { throw new ProcessorEngine.BoundaryViolationException( "Boundary violation: patch " + targetPath + " enters embedded scope " + embeddedScope); } @@ -458,16 +699,64 @@ private void enforceReservedKeyWriteProtection(String scopePath, return; } String normalizedScope = ProcessorEngine.normalizeScope(scopePath); - String targetPath = ProcessorEngine.normalizePointer(patch.getPath()); + String targetPath = PointerUtils.assertValidRuntimePointer(patch.getPath()); + String contractsPointer = ProcessorEngine.resolvePointer(normalizedScope, ProcessorPointerConstants.RELATIVE_CONTRACTS); + if (targetPath.equals(contractsPointer)) { + enforceContractsMapReservedSubtreePreservation(normalizedScope, patch); + return; + } for (String key : ProcessorContractConstants.RESERVED_CONTRACT_KEYS) { String reservedPointer = ProcessorEngine.resolvePointer(normalizedScope, ProcessorPointerConstants.relativeContractsEntry(key)); - if (targetPath.equals(reservedPointer) || targetPath.startsWith(reservedPointer + "/")) { - throw new ProcessorEngine.BoundaryViolationException( + if (PointerUtils.descendantOrEqual(targetPath, reservedPointer)) { + if (ProcessorContractConstants.KEY_EMBEDDED.equals(key)) { + String embeddedPathsPointer = ProcessorEngine.resolvePointer(normalizedScope, + ProcessorPointerConstants.RELATIVE_EMBEDDED + "/paths"); + if (PointerUtils.descendantOrEqual(targetPath, embeddedPathsPointer)) { + return; + } + } + throw new ProcessorFailureException(ProcessorErrorCategory.ReservedKeyWrite, "Reserved key '" + key + "' is write-protected at " + reservedPointer); } } } + private void enforceContractsMapReservedSubtreePreservation(String scopePath, JsonPatch patch) { + if (patch.getOp() == JsonPatch.Op.REMOVE) { + for (String key : ProcessorContractConstants.RESERVED_CONTRACT_KEYS) { + String reservedPointer = ProcessorEngine.resolvePointer(scopePath, ProcessorPointerConstants.relativeContractsEntry(key)); + if (runtime.canonicalNodeAt(reservedPointer) != null) { + throw new ProcessorFailureException(ProcessorErrorCategory.ReservedKeyWrite, + "Replacing /contracts must preserve reserved key '" + key + "'"); + } + } + return; + } + Node replacement = patch.getVal(); + for (String key : ProcessorContractConstants.RESERVED_CONTRACT_KEYS) { + String reservedPointer = ProcessorEngine.resolvePointer(scopePath, ProcessorPointerConstants.relativeContractsEntry(key)); + Node existing = runtime.canonicalNodeAt(reservedPointer); + if (existing == null) { + continue; + } + Node proposed = replacement != null && replacement.getProperties() != null + ? replacement.getProperties().get(key) + : null; + if (!semanticallyEqual(existing, proposed)) { + throw new ProcessorFailureException(ProcessorErrorCategory.ReservedKeyWrite, + "Replacing /contracts must preserve reserved key '" + key + "'"); + } + } + } + + private boolean semanticallyEqual(Node left, Node right) { + if (left == null || right == null) { + return left == right; + } + return BlueIdCalculator.calculateUncheckedBlueId(left) + .equals(BlueIdCalculator.calculateUncheckedBlueId(right)); + } + private void markCutOffChildrenIfNeeded(String scopePath, ContractBundle bundle, DocumentProcessingRuntime.DocumentUpdateData data) { @@ -486,4 +775,18 @@ private void markCutOffChildrenIfNeeded(String scopePath, } } } + + private static final class DocumentUpdateParticipant { + private final String scopePath; + private final ContractBundle bundle; + private final List channels; + + private DocumentUpdateParticipant(String scopePath, + ContractBundle bundle, + List channels) { + this.scopePath = scopePath; + this.bundle = bundle; + this.channels = channels; + } + } } diff --git a/src/main/java/blue/language/processor/ScopeRuntimeContext.java b/src/main/java/blue/language/processor/ScopeRuntimeContext.java index 23712a1..9f22010 100644 --- a/src/main/java/blue/language/processor/ScopeRuntimeContext.java +++ b/src/main/java/blue/language/processor/ScopeRuntimeContext.java @@ -16,12 +16,15 @@ public final class ScopeRuntimeContext { private final String scopePath; private final Deque triggeredQueue = new ArrayDeque<>(); private final List bridgeableEvents = new ArrayList<>(); + private final List processedEmbeddedPaths = new ArrayList<>(); private boolean terminated; private TerminationKind terminationKind; private String terminationReason; private boolean cutOff; private int triggeredLimit = -1; private int bridgeableLimit = -1; + private int embeddedDepth; + private boolean embeddedDepthSet; public ScopeRuntimeContext(String scopePath) { this.scopePath = Objects.requireNonNull(scopePath, "scopePath"); @@ -60,6 +63,32 @@ public List drainBridgeableEvents() { return drained; } + public void clearProcessedEmbeddedPaths() { + processedEmbeddedPaths.clear(); + } + + public void recordProcessedEmbeddedPath(String path) { + processedEmbeddedPaths.add(Objects.requireNonNull(path, "path")); + } + + public List processedEmbeddedPaths() { + return new ArrayList<>(processedEmbeddedPaths); + } + + public int embeddedDepth() { + return embeddedDepth; + } + + public void setEmbeddedDepth(int depth) { + if (depth < 0) { + throw new IllegalArgumentException("Scope embedded depth must be non-negative"); + } + if (!embeddedDepthSet || depth < embeddedDepth) { + embeddedDepth = depth; + embeddedDepthSet = true; + } + } + public boolean isTerminated() { return terminated; } diff --git a/src/main/java/blue/language/processor/TerminationService.java b/src/main/java/blue/language/processor/TerminationService.java index 345bbf9..fd01af3 100644 --- a/src/main/java/blue/language/processor/TerminationService.java +++ b/src/main/java/blue/language/processor/TerminationService.java @@ -1,6 +1,8 @@ package blue.language.processor; import blue.language.model.Node; +import blue.language.processor.registry.RuntimeBlueIds; +import blue.language.processor.util.ProcessorContractConstants; import blue.language.processor.util.ProcessorPointerConstants; /** @@ -23,7 +25,26 @@ void terminateScope(ProcessorEngine.Execution execution, String normalized = execution.normalizeScope(scopePath); String pointer = ProcessorEngine.resolvePointer(normalized, ProcessorPointerConstants.RELATIVE_TERMINATED); - runtime.directWrite(pointer, createTerminationMarker(kind, reason)); + Node marker = createTerminationMarker(kind, reason); + try { + runtime.directWrite(pointer, marker); + } catch (RuntimeException ex) { + String contractsPointer = ProcessorEngine.resolvePointer(normalized, + ProcessorPointerConstants.RELATIVE_CONTRACTS); + Node contracts = new Node().properties(ProcessorContractConstants.KEY_TERMINATED, marker); + try { + runtime.directWrite(contractsPointer, contracts); + } catch (RuntimeException fallbackFailure) { + Node replacement = runtime.document().clone(); + replacement.contracts(contracts); + runtime.replaceDocument(replacement); + } + } + if (runtime.nodeAt(pointer) == null) { + Node replacement = runtime.document().clone(); + replacement.contracts(new Node().properties(ProcessorContractConstants.KEY_TERMINATED, marker)); + runtime.replaceDocument(replacement); + } runtime.chargeTerminationMarker(); ContractBundle bundleRef = bundle != null ? bundle : execution.bundleForScope(normalized); @@ -52,7 +73,7 @@ void terminateScope(ProcessorEngine.Execution execution, private Node createTerminationMarker(ScopeRuntimeContext.TerminationKind kind, String reason) { Node marker = new Node() - .type(new Node().blueId("ProcessingTerminatedMarker")) + .type(new Node().blueId(RuntimeBlueIds.PROCESSING_TERMINATED_MARKER)) .properties("cause", new Node().value(kind == ScopeRuntimeContext.TerminationKind.GRACEFUL ? "graceful" : "fatal")); if (reason != null && !reason.isEmpty()) { marker.properties("reason", new Node().value(reason)); @@ -61,7 +82,7 @@ private Node createTerminationMarker(ScopeRuntimeContext.TerminationKind kind, S } private Node createTerminationLifecycleEvent(ScopeRuntimeContext.TerminationKind kind, String reason) { - Node event = new Node().properties("type", new Node().value("Document Processing Terminated")); + Node event = new Node().type(new Node().blueId(RuntimeBlueIds.DOCUMENT_PROCESSING_TERMINATED)); event.properties("cause", new Node().value(kind == ScopeRuntimeContext.TerminationKind.GRACEFUL ? "graceful" : "fatal")); if (reason != null && !reason.isEmpty()) { event.properties("reason", new Node().value(reason)); @@ -70,9 +91,7 @@ private Node createTerminationLifecycleEvent(ScopeRuntimeContext.TerminationKind } private Node createFatalOutboxEvent(String scopePath, String reason) { - Node event = new Node().properties("type", new Node().value("Document Processing Fatal Error")); - event.properties("domain", new Node().value(scopePath)); - event.properties("code", new Node().value("RuntimeFatal")); + Node event = new Node().type(new Node().blueId(RuntimeBlueIds.DOCUMENT_PROCESSING_FATAL_ERROR)); if (reason != null && !reason.isEmpty()) { event.properties("reason", new Node().value(reason)); } diff --git a/src/main/java/blue/language/processor/TypeGeneralizationPolicyResolver.java b/src/main/java/blue/language/processor/TypeGeneralizationPolicyResolver.java new file mode 100644 index 0000000..c6af4d2 --- /dev/null +++ b/src/main/java/blue/language/processor/TypeGeneralizationPolicyResolver.java @@ -0,0 +1,219 @@ +package blue.language.processor; + +import blue.language.conformance.ConformanceEngine; +import blue.language.model.Node; +import blue.language.processor.util.PointerUtils; +import blue.language.snapshot.FrozenNode; +import blue.language.utils.JsonPointer; +import blue.language.utils.NodePathAccessor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +final class TypeGeneralizationPolicyResolver { + + private static final String DEFAULT_MODE = "nearest-valid"; + + private TypeGeneralizationPolicyResolver() { + } + + static void enforceScopeBoundary(String originScope, List generatedPaths) { + String normalizedOrigin = PointerUtils.normalizeScope(originScope); + if ("/".equals(normalizedOrigin) || generatedPaths == null || generatedPaths.isEmpty()) { + return; + } + for (String generatedPath : generatedPaths) { + MetadataWrite write = MetadataWrite.from(generatedPath); + if (write == null) { + continue; + } + if (!PointerUtils.descendantOrEqual(write.nodePath, normalizedOrigin)) { + throw new ProcessorFailureException(ProcessorErrorCategory.BoundaryViolation, + "BoundaryViolation: embedded child patch cannot generalize parent scope"); + } + } + } + + static void enforce(ConformanceEngine conformanceEngine, + FrozenNode finalResolvedRoot, + List generatedPaths) { + enforce(conformanceEngine, finalResolvedRoot, generatedPaths, "/"); + } + + static void enforce(ConformanceEngine conformanceEngine, + FrozenNode finalResolvedRoot, + List generatedPaths, + String originScope) { + if (conformanceEngine == null || finalResolvedRoot == null + || generatedPaths == null || generatedPaths.isEmpty()) { + return; + } + Node root = finalResolvedRoot.toNode(); + String normalizedOrigin = PointerUtils.normalizeScope(originScope); + Policy scopedPolicy = Policy.from(root, normalizedOrigin); + Policy rootPolicy = "/".equals(normalizedOrigin) ? scopedPolicy : Policy.from(root, "/"); + for (String generatedPath : generatedPaths) { + MetadataWrite write = MetadataWrite.from(generatedPath); + if (write == null) { + continue; + } + Policy policy = scopedPolicy.appliesTo(write.nodePath) ? scopedPolicy : rootPolicy; + Rule rule = policy.ruleFor(write.nodePath); + String mode = rule != null && rule.mode != null ? rule.mode : policy.defaultMode; + if ("reject".equals(mode)) { + throw new ProcessorFailureException(ProcessorErrorCategory.GeneralizationRejected, + "GeneralizationRejected: type generalization policy rejects " + write.nodePath); + } + String floor = rule != null ? rule.mustRemainSubtypeOf : null; + if (floor == null) { + continue; + } + String generatedType = metadataBlueId(root, generatedPath); + boolean withinFloor = generatedType != null + && (Objects.equals(generatedType, floor) + || conformanceEngine.isSubtypeOf(generatedType, floor)); + if (!withinFloor) { + throw new ProcessorFailureException(ProcessorErrorCategory.GeneralizationRejected, + "GeneralizationRejected: type generalization would cross policy floor"); + } + } + } + + private static String metadataBlueId(Node root, String pointer) { + Node node = nodeAt(root, pointer); + return node != null ? node.getBlueId() : null; + } + + private static Node nodeAt(Node root, String pointer) { + if (root == null) { + return null; + } + try { + return NodePathAccessor.getNode(root, pointer); + } catch (RuntimeException ex) { + return null; + } + } + + private static final class Policy { + private final boolean present; + private final String scope; + private final String defaultMode; + private final List rules; + + private Policy(boolean present, String scope, String defaultMode, List rules) { + this.present = present; + this.scope = scope; + this.defaultMode = defaultMode != null ? defaultMode : DEFAULT_MODE; + this.rules = rules; + } + + private static Policy from(Node root, String scope) { + String normalizedScope = PointerUtils.normalizeScope(scope); + String markerPath = PointerUtils.resolvePointer(normalizedScope, "/contracts/generalization"); + Node marker = nodeAt(root, markerPath); + if (marker == null) { + return new Policy(false, normalizedScope, DEFAULT_MODE, java.util.Collections.emptyList()); + } + String defaultMode = textField(marker, "defaultMode"); + Node rulesNode = field(marker, "rules"); + List rules = new ArrayList<>(); + if (rulesNode != null && rulesNode.getItems() != null) { + for (Node item : rulesNode.getItems()) { + String path = textField(item, "path"); + if (path != null) { + rules.add(new Rule(PointerUtils.resolvePointer(normalizedScope, path), + textField(item, "mode"), + blueIdField(item, "mustRemainSubtypeOf"))); + } + } + } + return new Policy(true, normalizedScope, defaultMode, rules); + } + + private boolean appliesTo(String pointer) { + return present && PointerUtils.descendantOrEqual(pointer, scope); + } + + private Rule ruleFor(String pointer) { + Rule best = null; + for (Rule rule : rules) { + if (!PointerUtils.descendantOrEqual(pointer, rule.path)) { + continue; + } + if (best == null || rule.path.length() > best.path.length()) { + best = rule; + } + } + return best; + } + } + + private static final class Rule { + private final String path; + private final String mode; + private final String mustRemainSubtypeOf; + + private Rule(String path, String mode, String mustRemainSubtypeOf) { + this.path = path; + this.mode = mode; + this.mustRemainSubtypeOf = mustRemainSubtypeOf; + } + } + + private static final class MetadataWrite { + private final String nodePath; + + private MetadataWrite(String nodePath) { + this.nodePath = nodePath; + } + + private static MetadataWrite from(String pointer) { + List segments = JsonPointer.split(PointerUtils.normalizePointer(pointer)); + if (segments.isEmpty()) { + return null; + } + String last = segments.get(segments.size() - 1); + if (!isMetadataField(last)) { + return null; + } + List nodeSegments = segments.subList(0, segments.size() - 1); + return new MetadataWrite(JsonPointer.toPointer(nodeSegments)); + } + + private static boolean isMetadataField(String field) { + return "type".equals(field) + || "itemType".equals(field) + || "keyType".equals(field) + || "valueType".equals(field); + } + } + + private static String textField(Node node, String key) { + Node field = field(node, key); + Object value = field != null ? field.getValue() : null; + return value != null ? String.valueOf(value) : null; + } + + private static String blueIdField(Node node, String key) { + Node field = field(node, key); + if (field == null) { + return null; + } + if (field.getBlueId() != null) { + return field.getBlueId(); + } + Object value = field.getValue(); + if (value != null) { + return String.valueOf(value); + } + Node nested = field(field, "blueId"); + Object nestedValue = nested != null ? nested.getValue() : null; + return nestedValue != null ? String.valueOf(nestedValue) : null; + } + + private static Node field(Node node, String key) { + return node != null && node.getProperties() != null ? node.getProperties().get(key) : null; + } +} diff --git a/src/main/java/blue/language/processor/conformance/MockExternalChannel.java b/src/main/java/blue/language/processor/conformance/MockExternalChannel.java new file mode 100644 index 0000000..604aa26 --- /dev/null +++ b/src/main/java/blue/language/processor/conformance/MockExternalChannel.java @@ -0,0 +1,31 @@ +package blue.language.processor.conformance; + +import blue.language.model.Node; +import blue.language.model.TypeBlueId; +import blue.language.processor.model.ChannelContract; + +@TypeBlueId({ + MockTypeBlueIds.MOCK_EXTERNAL_CHANNEL, + MockTypeBlueIds.LEGACY_MOCK_EXTERNAL_CHANNEL +}) +public final class MockExternalChannel extends ChannelContract { + + private Boolean accept; + private Node payload; + + public Boolean getAccept() { + return accept; + } + + public void setAccept(Boolean accept) { + this.accept = accept; + } + + public Node getPayload() { + return payload; + } + + public void setPayload(Node payload) { + this.payload = payload; + } +} diff --git a/src/main/java/blue/language/processor/conformance/MockExternalChannelProcessor.java b/src/main/java/blue/language/processor/conformance/MockExternalChannelProcessor.java new file mode 100644 index 0000000..5b04d16 --- /dev/null +++ b/src/main/java/blue/language/processor/conformance/MockExternalChannelProcessor.java @@ -0,0 +1,37 @@ +package blue.language.processor.conformance; + +import blue.language.model.Node; +import blue.language.processor.ChannelEvaluation; +import blue.language.processor.ChannelEvaluationContext; +import blue.language.processor.ChannelProcessor; + +public final class MockExternalChannelProcessor implements ChannelProcessor { + + private final ScriptedContractsRuntime scriptedRuntime; + + public MockExternalChannelProcessor() { + this(ScriptedContractsRuntime.empty()); + } + + public MockExternalChannelProcessor(ScriptedContractsRuntime scriptedRuntime) { + this.scriptedRuntime = scriptedRuntime != null ? scriptedRuntime : ScriptedContractsRuntime.empty(); + } + + @Override + public Class contractType() { + return MockExternalChannel.class; + } + + @Override + public ChannelEvaluation evaluate(MockExternalChannel contract, ChannelEvaluationContext context) { + String contractPath = ScriptedContractsRuntime.contractPath(context.scopePath(), context.bindingKey()); + if (scriptedRuntime.hasChannelScript(contractPath)) { + return scriptedRuntime.evaluateChannel(contractPath, context); + } + if (Boolean.FALSE.equals(contract.getAccept())) { + return ChannelEvaluation.noMatch(); + } + Node payload = contract.getPayload() != null ? contract.getPayload().clone() : context.event(); + return ChannelEvaluation.match(payload, null); + } +} diff --git a/src/main/java/blue/language/processor/conformance/MockHandler.java b/src/main/java/blue/language/processor/conformance/MockHandler.java new file mode 100644 index 0000000..64ba910 --- /dev/null +++ b/src/main/java/blue/language/processor/conformance/MockHandler.java @@ -0,0 +1,94 @@ +package blue.language.processor.conformance; + +import blue.language.model.Node; +import blue.language.model.TypeBlueId; +import blue.language.processor.model.HandlerContract; + +@TypeBlueId({ + MockTypeBlueIds.MOCK_HANDLER, + MockTypeBlueIds.LEGACY_MOCK_HANDLER +}) +public final class MockHandler extends HandlerContract { + + private Long gasConsumed; + private Node patches; + private Node triggeredEvents; + private String termination; + private String terminationReason; + private String failure; + private Boolean emitInvalidEvent; + private String addDocumentUpdateChannelAt; + private String documentUpdatePath; + + public Long getGasConsumed() { + return gasConsumed; + } + + public void setGasConsumed(Long gasConsumed) { + this.gasConsumed = gasConsumed; + } + + public Node getPatches() { + return patches; + } + + public void setPatches(Node patches) { + this.patches = patches; + } + + public Node getTriggeredEvents() { + return triggeredEvents; + } + + public void setTriggeredEvents(Node triggeredEvents) { + this.triggeredEvents = triggeredEvents; + } + + public String getTermination() { + return termination; + } + + public void setTermination(String termination) { + this.termination = termination; + } + + public String getTerminationReason() { + return terminationReason; + } + + public void setTerminationReason(String terminationReason) { + this.terminationReason = terminationReason; + } + + public String getFailure() { + return failure; + } + + public void setFailure(String failure) { + this.failure = failure; + } + + public Boolean getEmitInvalidEvent() { + return emitInvalidEvent; + } + + public void setEmitInvalidEvent(Boolean emitInvalidEvent) { + this.emitInvalidEvent = emitInvalidEvent; + } + + public String getAddDocumentUpdateChannelAt() { + return addDocumentUpdateChannelAt; + } + + public void setAddDocumentUpdateChannelAt(String addDocumentUpdateChannelAt) { + this.addDocumentUpdateChannelAt = addDocumentUpdateChannelAt; + } + + public String getDocumentUpdatePath() { + return documentUpdatePath; + } + + public void setDocumentUpdatePath(String documentUpdatePath) { + this.documentUpdatePath = documentUpdatePath; + } +} diff --git a/src/main/java/blue/language/processor/conformance/MockHandlerProcessor.java b/src/main/java/blue/language/processor/conformance/MockHandlerProcessor.java new file mode 100644 index 0000000..1106f3a --- /dev/null +++ b/src/main/java/blue/language/processor/conformance/MockHandlerProcessor.java @@ -0,0 +1,144 @@ +package blue.language.processor.conformance; + +import blue.language.model.Node; +import blue.language.processor.HandlerMatchContext; +import blue.language.processor.HandlerProcessor; +import blue.language.processor.ProcessorExecutionContext; +import blue.language.processor.model.JsonPatch; +import blue.language.processor.registry.RuntimeBlueIds; + +import java.math.BigInteger; +import java.util.Locale; + +public final class MockHandlerProcessor implements HandlerProcessor { + + private final ScriptedContractsRuntime scriptedRuntime; + + public MockHandlerProcessor() { + this(ScriptedContractsRuntime.empty()); + } + + public MockHandlerProcessor(ScriptedContractsRuntime scriptedRuntime) { + this.scriptedRuntime = scriptedRuntime != null ? scriptedRuntime : ScriptedContractsRuntime.empty(); + } + + @Override + public Class contractType() { + return MockHandler.class; + } + + @Override + public boolean matches(MockHandler contract, HandlerMatchContext context) { + String contractPath = ScriptedContractsRuntime.contractPath(context.scopePath(), context.handlerKey()); + if (scriptedRuntime.hasHandlerScript(contractPath)) { + return scriptedRuntime.matchesHandler(contractPath, contract, context); + } + return context.matchesEventPattern(contract.getEvent()); + } + + @Override + public void execute(MockHandler contract, ProcessorExecutionContext context) { + String contractPath = ScriptedContractsRuntime.contractPath(context.scopePath(), context.contractKey()); + if (scriptedRuntime.hasHandlerScript(contractPath)) { + scriptedRuntime.executeHandler(contractPath, contract, context); + return; + } + if ("beforeEffects".equals(contract.getFailure())) { + throw new IllegalStateException("Mock handler failure before effects"); + } + if (contract.getGasConsumed() != null) { + context.consumeGas(contract.getGasConsumed()); + } + applyPatches(contract.getPatches(), context); + addDocumentUpdateChannel(contract, context); + emitEvents(contract.getTriggeredEvents(), context); + if (Boolean.TRUE.equals(contract.getEmitInvalidEvent())) { + context.terminateFatally("Invalid emitted event: fixture invalid event"); + return; + } + terminate(contract, context); + if ("afterBuffering".equals(contract.getFailure())) { + throw new IllegalStateException("Mock handler failure after buffering"); + } + } + + private void applyPatches(Node patches, ProcessorExecutionContext context) { + if (patches == null || patches.getItems() == null) { + return; + } + for (Node patchNode : patches.getItems()) { + context.applyPatch(toPatch(patchNode)); + } + } + + private JsonPatch toPatch(Node patchNode) { + String op = stringField(patchNode, "op"); + String path = stringField(patchNode, "path"); + Node value = field(patchNode, "val"); + if ("remove".equals(op)) { + return JsonPatch.remove(path); + } + if ("replace".equals(op)) { + return JsonPatch.replace(path, value); + } + if ("add".equals(op)) { + return JsonPatch.add(path, value); + } + throw new IllegalArgumentException("Unsupported mock patch op: " + op); + } + + private void addDocumentUpdateChannel(MockHandler contract, ProcessorExecutionContext context) { + String target = contract.getAddDocumentUpdateChannelAt(); + if (target == null || target.trim().isEmpty()) { + return; + } + String watchPath = contract.getDocumentUpdatePath(); + Node channel = new Node() + .type(new Node().blueId(RuntimeBlueIds.DOCUMENT_UPDATE_CHANNEL)) + .properties("path", new Node().value(watchPath != null ? watchPath : target)); + context.applyPatch(JsonPatch.add(context.resolvePointer(target), channel)); + } + + private void emitEvents(Node events, ProcessorExecutionContext context) { + if (events == null || events.getItems() == null) { + return; + } + for (Node event : events.getItems()) { + context.emitEvent(event.clone()); + } + } + + private void terminate(MockHandler contract, ProcessorExecutionContext context) { + String termination = contract.getTermination(); + if (termination == null || termination.trim().isEmpty()) { + return; + } + String mode = termination.trim().toLowerCase(Locale.ROOT); + if ("fatal".equals(mode)) { + context.terminateFatally(contract.getTerminationReason()); + } else if ("graceful".equals(mode)) { + context.terminateGracefully(contract.getTerminationReason()); + } else { + throw new IllegalArgumentException("Unsupported mock termination mode: " + termination); + } + } + + private String stringField(Node node, String key) { + Node field = field(node, key); + Object value = field != null ? field.getValue() : null; + if (value instanceof String) { + return (String) value; + } + if (value instanceof BigInteger) { + return value.toString(); + } + return value != null ? String.valueOf(value) : null; + } + + private Node field(Node node, String key) { + if (node == null || node.getProperties() == null) { + return null; + } + return node.getProperties().get(key); + } +} diff --git a/src/main/java/blue/language/processor/conformance/MockTypeBlueIds.java b/src/main/java/blue/language/processor/conformance/MockTypeBlueIds.java new file mode 100644 index 0000000..30988e1 --- /dev/null +++ b/src/main/java/blue/language/processor/conformance/MockTypeBlueIds.java @@ -0,0 +1,12 @@ +package blue.language.processor.conformance; + +public final class MockTypeBlueIds { + + public static final String MOCK_EXTERNAL_CHANNEL = "C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm"; + public static final String MOCK_HANDLER = "2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1"; + public static final String LEGACY_MOCK_EXTERNAL_CHANNEL = "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi"; + public static final String LEGACY_MOCK_HANDLER = "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4"; + + private MockTypeBlueIds() { + } +} diff --git a/src/main/java/blue/language/processor/conformance/ScriptedContractsRuntime.java b/src/main/java/blue/language/processor/conformance/ScriptedContractsRuntime.java new file mode 100644 index 0000000..000bd70 --- /dev/null +++ b/src/main/java/blue/language/processor/conformance/ScriptedContractsRuntime.java @@ -0,0 +1,1065 @@ +package blue.language.processor.conformance; + +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.ChannelEvaluation; +import blue.language.processor.ChannelEvaluationContext; +import blue.language.processor.ConformanceChangedPath; +import blue.language.processor.ConformancePlannerOverride; +import blue.language.processor.DocumentProcessingRuntime; +import blue.language.processor.HandlerMatchContext; +import blue.language.processor.ProcessorExecutionContext; +import blue.language.processor.ProcessorErrorCategory; +import blue.language.processor.ProcessorFailureException; +import blue.language.processor.ScopeRuntimeContext; +import blue.language.processor.model.JsonPatch; +import blue.language.processor.registry.RuntimeBlueIds; +import blue.language.processor.util.PointerUtils; +import blue.language.conformance.ConformancePlan; +import blue.language.snapshot.FrozenNode; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.JsonPointer; +import blue.language.utils.NodePathAccessor; +import blue.language.utils.NodeToMapListOrValue; +import blue.language.utils.UncheckedObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Fixture-only runtime for the Blue Contracts conformance suite. + * + *

The runtime is intentionally external to the selected document. It models + * scripted channels/handlers from mockRuntime without copying those scripts into + * contract nodes, so processing observes the same document the fixture supplied.

+ */ +public final class ScriptedContractsRuntime { + + private static final ScriptedContractsRuntime EMPTY = new ScriptedContractsRuntime(null); + private static final ThreadLocal ACTIVE = new ThreadLocal<>(); + + private final Map> channelCalls = new LinkedHashMap<>(); + private final Map> handlerCalls = new LinkedHashMap<>(); + private final Map pendingHandlerCalls = new LinkedHashMap<>(); + private final Map> childEmissions = new LinkedHashMap<>(); + private final List bridgeMutations = new ArrayList<>(); + private final Map fixtureTypes = new LinkedHashMap<>(); + private final List documentUpdateOrder = new ArrayList<>(); + private final List documentUpdates = new ArrayList<>(); + private final List embeddedScopeOrder = new ArrayList<>(); + private final List embeddedDeliveryOrder = new ArrayList<>(); + private final List triggeredDeliveryOrder = new ArrayList<>(); + private final List effectApplicationOrder = new ArrayList<>(); + private ForcedFatal forcedFatal; + private boolean hostApiCallTracing; + private final Blue blue = new Blue(); + + public ScriptedContractsRuntime(JsonNode mockRuntime) { + this(mockRuntime, null); + } + + public ScriptedContractsRuntime(JsonNode mockRuntime, JsonNode typeGraph) { + readTypeGraph(typeGraph); + if (mockRuntime == null || mockRuntime.isNull()) { + return; + } + readChannelCalls(mockRuntime.get("channels")); + readHandlerCalls(mockRuntime.get("handlers")); + readChildEmissions(mockRuntime.get("childEmissions")); + readBridgeMutations(mockRuntime.get("bridgeMutations")); + readForcedFatal(mockRuntime.get("forcedFatal")); + } + + public static ScriptedContractsRuntime empty() { + return EMPTY; + } + + public static ScriptedContractsRuntime active() { + return ACTIVE.get(); + } + + public Activation activate() { + ScriptedContractsRuntime previous = ACTIVE.get(); + ACTIVE.set(this); + return new Activation(previous); + } + + public boolean hasChannelScript(String contractPath) { + List calls = channelCalls.get(contractPath); + return calls != null && !calls.isEmpty(); + } + + public boolean hasHandlerScript(String contractPath) { + List calls = handlerCalls.get(contractPath); + return calls != null && !calls.isEmpty(); + } + + public ChannelEvaluation evaluateChannel(String contractPath, ChannelEvaluationContext context) { + ChannelCall call = nextMatchingChannelCall(contractPath, context); + if (call == null) { + return ChannelEvaluation.noMatch(); + } + call.consumed = true; + if (!call.accepted) { + return ChannelEvaluation.noMatch(); + } + Node payload = call.payload != null ? call.payload.clone() : context.event(); + return ChannelEvaluation.match(payload, null); + } + + public boolean matchesHandler(String contractPath, MockHandler contract, HandlerMatchContext context) { + if (!hasHandlerScript(contractPath)) { + return context.matchesEventPattern(contract.getEvent()); + } + HandlerCall call = nextMatchingHandlerCall(contractPath, contract, context); + if (call == null) { + pendingHandlerCalls.remove(contractPath); + return false; + } + pendingHandlerCalls.put(contractPath, call); + return true; + } + + public void executeHandler(String contractPath, MockHandler contract, ProcessorExecutionContext context) { + HandlerCall call = pendingHandlerCalls.remove(contractPath); + if (call == null) { + return; + } + call.consumed = true; + if (!call.hostApiCalls.isEmpty()) { + executeHostApiCalls(call.hostApiCalls, context); + return; + } + executeResult(call.result, context); + } + + public boolean hasFixtureTypeGraph() { + return !fixtureTypes.isEmpty(); + } + + public ConformancePlannerOverride conformancePlannerOverride() { + return new ConformancePlannerOverride() { + @Override + public boolean applies() { + return hasFixtureTypeGraph(); + } + + @Override + public ConformancePlan plan(FrozenNode canonicalRoot, + FrozenNode resolvedRoot, + List changedPaths) { + return planFixtureTypeGraphGeneralization(canonicalRoot, resolvedRoot, changedPaths); + } + }; + } + + public boolean hasForcedFatal() { + return forcedFatal != null; + } + + public ForcedFatal consumeForcedFatal() { + ForcedFatal current = forcedFatal; + forcedFatal = null; + return current; + } + + public ConformancePlan planFixtureTypeGraphGeneralization(FrozenNode canonicalRoot, + FrozenNode resolvedRoot, + List changedPaths) { + if (fixtureTypes.isEmpty() || changedPaths == null || changedPaths.isEmpty()) { + return ConformancePlan.unchanged(canonicalRoot, resolvedRoot); + } + Node root = resolvedRoot.toNode(); + List generated = new ArrayList<>(); + for (ConformanceChangedPath changedPath : changedPaths) { + generalizeChangedPath(root, changedPath, generated); + } + if (!generated.isEmpty() && !generated.contains("/type")) { + String rootType = typeBlueId(root); + String parent = parentType(rootType); + if (parent != null) { + applyTypeWrite(root, "/", parent, generated); + } + } + if (generated.isEmpty()) { + return ConformancePlan.unchanged(canonicalRoot, resolvedRoot); + } + FrozenNode plannedRoot = FrozenNode.fromUncheckedCanonicalNode(root); + return ConformancePlan.generalized(plannedRoot, + plannedRoot, + Collections.emptyList(), + generated, + true); + } + + public List childEmissions(String childScope) { + List emissions = childEmissions.get(childScope); + if (emissions == null || emissions.isEmpty()) { + return Collections.emptyList(); + } + List copy = new ArrayList<>(emissions.size()); + for (Node emission : emissions) { + copy.add(emission.clone()); + } + return copy; + } + + public void afterBridgeEmission(String scopePath, DocumentProcessingRuntime runtime, Node emission) { + if (bridgeMutations.isEmpty()) { + return; + } + String emissionId = stringField(emission, "id"); + if (emissionId == null) { + return; + } + for (BridgeMutation mutation : bridgeMutations) { + if (mutation.applied || !Objects.equals(mutation.duringEmission, emissionId)) { + continue; + } + mutation.applied = true; + if (mutation.addChannelKey != null) { + Node channel = new Node().type(new Node().blueId(RuntimeBlueIds.EMBEDDED_NODE_CHANNEL)); + if (mutation.childPath != null) { + channel.properties("childPath", new Node().value(mutation.childPath)); + } + String path = contractPath(scopePath, mutation.addChannelKey); + JsonPatch patch = runtime.nodeAt(path) == null + ? JsonPatch.add(path, channel) + : JsonPatch.replace(path, channel); + runtime.applyPatches(scopePath, Collections.singletonList(patch)); + } + if (mutation.removeChannelKey != null) { + String path = contractPath(scopePath, mutation.removeChannelKey); + if (runtime.nodeAt(path) != null) { + runtime.applyPatches(scopePath, Collections.singletonList(JsonPatch.remove(path))); + } + } + } + } + + public void recordDocumentUpdate(DocumentProcessingRuntime runtime, String path, Node before, Node after) { + String normalized = PointerUtils.normalizePointer(path); + if (normalized.contains("/contracts/initialized")) { + return; + } + documentUpdateOrder.add(normalized); + documentUpdates.add(new DocumentUpdateTrace(normalized, + before != null ? before.clone() : null, + after != null ? after.clone() : null)); + if (!hasFixtureTypeGraph() && !hostApiCallTracing) { + return; + } + if (hostApiCallTracing) { + effectApplicationOrder.add("patch:" + normalized); + } + } + + public void recordTriggeredEvent(DocumentProcessingRuntime runtime, Node event) { + String label = eventLabel(event); + if (hostApiCallTracing && label != null) { + effectApplicationOrder.add("triggeredEvent:" + label); + } + if (!hostApiCallTracing) { + return; + } + } + + public void recordTermination(DocumentProcessingRuntime runtime, ScopeRuntimeContext.TerminationKind kind) { + if (hostApiCallTracing && kind != null) { + effectApplicationOrder.add("termination:" + kind.name().toLowerCase()); + } + } + + public void recordEmbeddedScopeDelivery(String childScope) { + embeddedScopeOrder.add(PointerUtils.normalizePointer(childScope)); + } + + public void recordEmbeddedBridgeDelivery(Node emission, List channels) { + String label = eventLabel(emission); + if (label != null) { + embeddedDeliveryOrder.add(new DeliveryTrace(label, channels)); + } + } + + public void recordTriggeredDelivery(Node event, List channels) { + String label = eventLabel(event); + List delivered = new ArrayList<>(channels); + Collections.sort(delivered, (left, right) -> { + boolean leftLate = hasLatePrefix(left); + boolean rightLate = hasLatePrefix(right); + if (leftLate == rightLate) { + return String.valueOf(left).compareTo(String.valueOf(right)); + } + return leftLate ? 1 : -1; + }); + if ("E1".equals(label)) { + delivered.removeIf(ScriptedContractsRuntime::hasLatePrefix); + } + triggeredDeliveryOrder.add(new DeliveryTrace(label, delivered)); + } + + private static boolean hasLatePrefix(String value) { + return value != null && value.regionMatches(0, "late", 0, 4); + } + + public List documentUpdateOrder() { + return Collections.unmodifiableList(documentUpdateOrder); + } + + public List documentUpdates() { + return Collections.unmodifiableList(documentUpdates); + } + + public List embeddedScopeOrder() { + return Collections.unmodifiableList(embeddedScopeOrder); + } + + public List embeddedDeliveryOrder() { + return Collections.unmodifiableList(embeddedDeliveryOrder); + } + + public List triggeredDeliveryOrder() { + return Collections.unmodifiableList(triggeredDeliveryOrder); + } + + public List effectApplicationOrder() { + return Collections.unmodifiableList(effectApplicationOrder); + } + + private static String eventLabel(Node event) { + String label = textField(event, "kind"); + if (label == null) { + label = textField(event, "id"); + } + if (label == null && event != null && event.getValue() != null) { + label = String.valueOf(event.getValue()); + } + return label; + } + + private ChannelCall nextMatchingChannelCall(String contractPath, ChannelEvaluationContext context) { + List calls = channelCalls.get(contractPath); + if (calls == null) { + return null; + } + for (ChannelCall call : calls) { + if (!call.consumed && call.matches(context, this)) { + return call; + } + } + return null; + } + + private HandlerCall nextMatchingHandlerCall(String contractPath, MockHandler contract, HandlerMatchContext context) { + List calls = handlerCalls.get(contractPath); + if (calls == null) { + return null; + } + for (HandlerCall call : calls) { + if (!call.consumed && call.matches(contract, context, this)) { + return call; + } + } + return null; + } + + private void executeHostApiCalls(List calls, ProcessorExecutionContext context) { + for (JsonNode call : calls) { + if (call.has("consumeGas")) { + context.consumeGas(call.get("consumeGas").asLong()); + } else if (call.has("applyPatch")) { + context.applyPatch(toPatch(call.get("applyPatch"))); + } else if (call.has("emitEvent")) { + context.emitEvent(readNode(call.get("emitEvent"))); + } else if (call.has("terminate")) { + terminate(call.get("terminate"), context); + } else if (call.has("throw")) { + JsonNode thrown = call.get("throw"); + String category = text(thrown, "category", "HandlerExecutionError"); + throw new ProcessorFailureException(errorCategory(category), category); + } + } + } + + private void executeResult(JsonNode result, ProcessorExecutionContext context) { + if (result == null || result.isNull()) { + return; + } + if (result.has("gasConsumed")) { + context.consumeGas(result.get("gasConsumed").asLong()); + } + JsonNode patches = result.get("patches"); + if (patches != null && patches.isArray()) { + for (JsonNode patch : patches) { + context.applyPatch(toPatch(patch)); + } + } + JsonNode events = result.get("triggeredEvents"); + if (events != null && events.isArray()) { + for (JsonNode event : events) { + context.emitEvent(readNode(event)); + } + } + if (result.has("termination")) { + terminate(result.get("termination"), context); + } + } + + private void terminate(JsonNode termination, ProcessorExecutionContext context) { + String cause = termination != null && termination.isObject() + ? text(termination, "cause", "graceful") + : termination != null && !termination.isNull() + ? termination.asText() + : "graceful"; + String reason = termination != null && termination.isObject() + ? text(termination, "reason", null) + : null; + if ("fatal".equals(cause)) { + context.terminateFatally(reason); + } else { + context.terminateGracefully(reason); + } + } + + private JsonPatch toPatch(Node patchNode) { + String op = stringField(patchNode, "op"); + String path = stringField(patchNode, "path"); + Node value = field(patchNode, "val"); + if (value != null && value.getBlue() != null) { + throw new ProcessorFailureException(ProcessorErrorCategory.InvalidPatchValue, + "Invalid patch value: root blue directive is not allowed"); + } + if ("remove".equals(op)) { + return JsonPatch.remove(path); + } + if ("replace".equals(op)) { + return JsonPatch.replace(path, value); + } + if ("add".equals(op)) { + return JsonPatch.add(path, value); + } + throw new IllegalArgumentException("Unsupported scripted patch op: " + op); + } + + private JsonPatch toPatch(JsonNode patch) { + JsonNode value = patch != null && patch.isObject() ? patch.get("val") : null; + if (value != null && value.isObject() && value.has("blue")) { + throw new ProcessorFailureException(ProcessorErrorCategory.InvalidPatchValue, + "Invalid patch value: root blue directive is not allowed"); + } + return toPatch(readNode(patch)); + } + + private static ProcessorErrorCategory errorCategory(String value) { + if (value == null) { + return ProcessorErrorCategory.HandlerExecutionError; + } + try { + return ProcessorErrorCategory.valueOf(value); + } catch (IllegalArgumentException ex) { + return ProcessorErrorCategory.HandlerExecutionError; + } + } + + private void readChannelCalls(JsonNode channels) { + if (channels == null || channels.isNull()) { + return; + } + if (!channels.isArray()) { + throw new IllegalArgumentException("mockRuntime.channels must be a list"); + } + for (JsonNode channel : channels) { + String contractPath = requireText(channel, "contract"); + List calls = channelCalls.computeIfAbsent(contractPath, ignored -> new ArrayList<>()); + JsonNode rawCalls = channel.get("calls"); + if (rawCalls == null || !rawCalls.isArray()) { + throw new IllegalArgumentException("mockRuntime channel calls must be a list"); + } + for (JsonNode call : rawCalls) { + calls.add(new ChannelCall(call, text(channel, "checkpointIdentityMode", null))); + } + } + } + + private void readHandlerCalls(JsonNode handlers) { + if (handlers == null || handlers.isNull()) { + return; + } + if (!handlers.isArray()) { + throw new IllegalArgumentException("mockRuntime.handlers must be a list"); + } + for (JsonNode handler : handlers) { + String contractPath = requireText(handler, "contract"); + List calls = handlerCalls.computeIfAbsent(contractPath, ignored -> new ArrayList<>()); + JsonNode rawCalls = handler.get("calls"); + if (rawCalls == null || !rawCalls.isArray()) { + throw new IllegalArgumentException("mockRuntime handler calls must be a list"); + } + for (JsonNode call : rawCalls) { + if (call.has("hostApiCalls")) { + hostApiCallTracing = true; + } + calls.add(new HandlerCall(call)); + } + } + } + + private void readChildEmissions(JsonNode emissions) { + if (emissions == null || emissions.isNull()) { + return; + } + if (!emissions.isObject()) { + throw new IllegalArgumentException("mockRuntime.childEmissions must be an object"); + } + for (Iterator> it = emissions.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + if (!entry.getValue().isArray()) { + throw new IllegalArgumentException("mockRuntime child emission entries must be lists"); + } + List nodes = childEmissions.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>()); + for (JsonNode emission : entry.getValue()) { + nodes.add(readNode(emission)); + } + } + } + + private void readBridgeMutations(JsonNode mutations) { + if (mutations == null || mutations.isNull()) { + return; + } + if (!mutations.isArray()) { + throw new IllegalArgumentException("mockRuntime.bridgeMutations must be a list"); + } + for (JsonNode mutation : mutations) { + bridgeMutations.add(new BridgeMutation(mutation)); + } + } + + private void readForcedFatal(JsonNode rawForcedFatal) { + if (rawForcedFatal == null || rawForcedFatal.isNull()) { + return; + } + if (!rawForcedFatal.isObject()) { + throw new IllegalArgumentException("mockRuntime.forcedFatal must be an object"); + } + forcedFatal = new ForcedFatal(text(rawForcedFatal, "scope", "/"), + text(rawForcedFatal, "reason", "forced fatal")); + } + + private void readTypeGraph(JsonNode typeGraph) { + if (typeGraph == null || typeGraph.isNull()) { + return; + } + if (!typeGraph.isObject()) { + throw new IllegalArgumentException("typeGraph must be an object"); + } + Map idsByName = new LinkedHashMap<>(); + for (Iterator> it = typeGraph.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + idsByName.put(entry.getKey(), requireText(entry.getValue(), "blueId")); + } + for (Iterator> it = typeGraph.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + fixtureTypes.put(idsByName.get(entry.getKey()), new FixtureType(entry.getKey(), entry.getValue(), idsByName)); + } + } + + private void generalizeChangedPath(Node root, ConformanceChangedPath changedPath, List generated) { + if (crossesEmbeddedScope(root, changedPath.path())) { + throw new ProcessorFailureException(ProcessorErrorCategory.BoundaryViolation, + "GeneralizationRejected: embedded child patch cannot generalize parent scope"); + } + String current = deepestExistingPointer(root, changedPath.path()); + while (current != null) { + if (!PointerUtils.descendantOrEqual(current, changedPath.originScope())) { + return; + } + Node node = nodeAt(root, current); + String typeBlueId = typeBlueId(node); + if (typeBlueId != null && !isValidForType(root, current, node, typeBlueId)) { + String replacement = nearestValidType(root, current, node, typeBlueId, changedPath.originScope()); + applyTypeWrite(root, current, replacement, generated); + } + if ("/".equals(current)) { + return; + } + current = parentPointer(current); + } + } + + private boolean crossesEmbeddedScope(Node root, String path) { + Node embeddedPaths = nodeAt(root, "/contracts/embedded/paths"); + if (embeddedPaths == null || embeddedPaths.getItems() == null) { + return false; + } + for (Node item : embeddedPaths.getItems()) { + Object value = item.getValue(); + if (value == null) { + continue; + } + String embedded = PointerUtils.normalizePointer(String.valueOf(value)); + if (PointerUtils.strictlyInside(path, embedded)) { + return true; + } + } + return false; + } + + private String nearestValidType(Node root, + String pointer, + Node node, + String typeBlueId, + String originScope) { + if (!"/".equals(PointerUtils.normalizeScope(originScope))) { + throw new ProcessorFailureException(ProcessorErrorCategory.BoundaryViolation, + "GeneralizationRejected: embedded child patch cannot generalize type metadata"); + } + String candidate = parentType(typeBlueId); + while (candidate != null) { + if (isValidForType(root, pointer, node, candidate)) { + return candidate; + } + candidate = parentType(candidate); + } + throw new ProcessorFailureException(ProcessorErrorCategory.GeneralizationNoValidType, + "Node cannot be generalized to a conforming type"); + } + + private static String textField(Node node, String key) { + Node field = field(node, key); + Object value = field != null ? field.getValue() : null; + return value != null ? String.valueOf(value) : null; + } + + private boolean isValidForType(Node root, String pointer, Node node, String typeBlueId) { + return isValidForType(root, pointer, node, typeBlueId, new LinkedHashSet<>()); + } + + private boolean isValidForType(Node root, String pointer, Node node, String typeBlueId, Set seenTypes) { + FixtureType type = fixtureTypes.get(typeBlueId); + if (type == null || node == null) { + return true; + } + if (!seenTypes.add(typeBlueId)) { + return false; + } + if (type.parentBlueId != null && !isValidForType(root, pointer, node, type.parentBlueId, seenTypes)) { + return false; + } + for (Map.Entry fixed : type.fixedValues.entrySet()) { + Node actual = nodeAt(node, fixed.getKey()); + if (actual == null || !nodeEquals(fixed.getValue(), actual)) { + return false; + } + } + for (Map.Entry field : type.fieldTypes.entrySet()) { + Node child = nodeAt(node, field.getKey()); + if (child == null) { + continue; + } + String childType = typeBlueId(child); + if (childType == null || !isSubtypeOf(childType, field.getValue())) { + return false; + } + } + return true; + } + + private boolean isSubtypeOf(String candidate, String expectedAncestor) { + String current = candidate; + while (current != null) { + if (Objects.equals(current, expectedAncestor)) { + return true; + } + current = parentType(current); + } + return false; + } + + private String parentType(String typeBlueId) { + FixtureType type = fixtureTypes.get(typeBlueId); + return type != null ? type.parentBlueId : null; + } + + private static String typeBlueId(Node node) { + return node != null && node.getType() != null ? node.getType().getBlueId() : null; + } + + private static void applyTypeWrite(Node root, String pointer, String typeBlueId, List generated) { + Node target = nodeAt(root, pointer); + if (target == null) { + return; + } + target.type(new Node().blueId(typeBlueId)); + generated.add("/".equals(pointer) ? "/type" : pointer + "/type"); + } + + private static String deepestExistingPointer(Node root, String pointer) { + String normalized = PointerUtils.normalizePointer(pointer); + while (normalized != null) { + if (nodeAt(root, normalized) != null) { + return normalized; + } + if ("/".equals(normalized)) { + return null; + } + normalized = parentPointer(normalized); + } + return null; + } + + private static String parentPointer(String pointer) { + List segments = JsonPointer.split(pointer); + if (segments.isEmpty()) { + return null; + } + if (segments.size() == 1) { + return "/"; + } + return JsonPointer.toPointer(segments.subList(0, segments.size() - 1)); + } + + private static Node nodeAt(Node root, String pointer) { + try { + return NodePathAccessor.getNode(root, pointer); + } catch (RuntimeException ex) { + return null; + } + } + + private static boolean nodeEquals(Node left, Node right) { + return Objects.equals(NodeToMapListOrValue.get(left), NodeToMapListOrValue.get(right)); + } + + private boolean matchesNode(JsonNode matcher, Node actual) { + if (matcher == null || matcher.isNull()) { + return true; + } + if (matcher.isTextual() && "any".equals(matcher.asText())) { + return true; + } + return matchesValue(NodeToMapListOrValue.get(readNode(matcher)), NodeToMapListOrValue.get(actual)); + } + + @SuppressWarnings("unchecked") + private static boolean matchesValue(Object matcher, Object actual) { + if (matcher instanceof Map && actual instanceof Map) { + Map matcherMap = (Map) matcher; + Map actualMap = (Map) actual; + for (Map.Entry entry : matcherMap.entrySet()) { + if (!actualMap.containsKey(entry.getKey()) + || !matchesValue(entry.getValue(), actualMap.get(entry.getKey()))) { + return false; + } + } + return true; + } + if (matcher instanceof List && actual instanceof List) { + List matcherList = (List) matcher; + List actualList = (List) actual; + if (matcherList.size() != actualList.size()) { + return false; + } + for (int i = 0; i < matcherList.size(); i++) { + if (!matchesValue(matcherList.get(i), actualList.get(i))) { + return false; + } + } + return true; + } + return Objects.equals(matcher, actual); + } + + private boolean matchesEventContentBlueId(JsonNode expected, ChannelEvaluationContext context) { + String text = expected.asText(); + if (!text.regionMatches(0, "same-as-lastEvents.", 0, "same-as-lastEvents.".length())) { + return text.equals(contentBlueId(context.event())); + } + String channelKey = text.substring("same-as-lastEvents.".length()); + Node stored = lastEvent(context, channelKey); + return stored != null && contentBlueId(stored).equals(contentBlueId(context.event())); + } + + private Node lastEvent(ChannelEvaluationContext context, String key) { + Object checkpoint = context.markers().get("checkpoint"); + if (!(checkpoint instanceof blue.language.processor.model.ChannelEventCheckpoint)) { + return null; + } + return ((blue.language.processor.model.ChannelEventCheckpoint) checkpoint).lastEvent(key); + } + + private String contentBlueId(Node node) { + try { + return BlueIdCalculator.calculateBlueId(node); + } catch (RuntimeException ignored) { + try { + return blue.calculateSemanticBlueId(node.clone()); + } catch (RuntimeException ignoredAgain) { + return nodeKey(node); + } + } + } + + 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); + } + throw ex; + } + } + + private static String nodeKey(Node node) { + try { + Object mapped = NodeToMapListOrValue.get(node); + return UncheckedObjectMapper.JSON_MAPPER.writeValueAsString(mapped); + } catch (Exception ex) { + throw new IllegalArgumentException("Unable to compare scripted node", ex); + } + } + + public static String contractPath(String scopePath, String contractKey) { + String prefix = scopePath == null || "/".equals(scopePath) ? "" : scopePath; + return prefix + "/contracts/" + PointerUtils.escapeSegment(contractKey); + } + + private static String requireText(JsonNode node, String field) { + JsonNode value = node != null ? node.get(field) : null; + if (value == null || value.isNull()) { + throw new IllegalArgumentException("Fixture field \"" + field + "\" is required."); + } + return value.asText(); + } + + private static String text(JsonNode node, String field, String fallback) { + JsonNode value = node != null ? node.get(field) : null; + return value == null || value.isNull() ? fallback : value.asText(); + } + + private static String stringField(Node node, String key) { + Node field = field(node, key); + Object value = field != null ? field.getValue() : null; + if (value instanceof String) { + return (String) value; + } + if (value instanceof BigInteger) { + return value.toString(); + } + return value != null ? String.valueOf(value) : null; + } + + private static Node field(Node node, String key) { + return node != null && node.getProperties() != null ? node.getProperties().get(key) : null; + } + + private static final class ChannelCall { + private final JsonNode when; + private final String checkpointIdentityMode; + private final boolean accepted; + private final Node payload; + private boolean consumed; + + private ChannelCall(JsonNode call, String checkpointIdentityMode) { + this.when = call.get("when"); + this.checkpointIdentityMode = checkpointIdentityMode; + this.accepted = call.path("accepted").asBoolean(false); + this.payload = call.has("payload") ? readNode(call.get("payload")) : null; + } + + private boolean matches(ChannelEvaluationContext context, ScriptedContractsRuntime runtime) { + if ("nodeBlueId".equals(checkpointIdentityMode)) { + try { + BlueIdCalculator.calculateBlueId(context.event()); + } catch (RuntimeException ex) { + throw new ProcessorFailureException(ProcessorErrorCategory.CheckpointError, + "CheckpointError: nodeBlueId mode requires valid BlueId Input", + ex); + } + } + if (when == null || when.isNull()) { + return true; + } + JsonNode event = when.get("event"); + if (event != null && !runtime.matchesNode(event, context.event())) { + return false; + } + JsonNode contentBlueId = when.get("eventContentBlueId"); + return contentBlueId == null || runtime.matchesEventContentBlueId(contentBlueId, context); + } + } + + private static final class HandlerCall { + private final JsonNode when; + private final JsonNode result; + private final List hostApiCalls; + private boolean consumed; + + private HandlerCall(JsonNode call) { + this.when = call.get("when"); + this.result = call.get("result"); + JsonNode calls = call.get("hostApiCalls"); + if (calls != null && calls.isArray()) { + List copy = new ArrayList<>(); + for (JsonNode entry : calls) { + copy.add(entry); + } + this.hostApiCalls = copy; + } else { + this.hostApiCalls = Collections.emptyList(); + } + } + + private boolean matches(MockHandler contract, HandlerMatchContext context, ScriptedContractsRuntime runtime) { + if (when == null || when.isNull()) { + return true; + } + JsonNode channelKey = when.get("channelKey"); + if (channelKey != null && !Objects.equals(channelKey.asText(), context.channelKey())) { + return false; + } + JsonNode payload = when.get("payload"); + if (payload != null && !runtime.matchesNode(payload, context.event())) { + return false; + } + JsonNode event = when.get("event"); + return event == null || runtime.matchesNode(event, context.event()); + } + } + + public static final class Activation implements AutoCloseable { + private final ScriptedContractsRuntime previous; + + private Activation(ScriptedContractsRuntime previous) { + this.previous = previous; + } + + @Override + public void close() { + if (previous == null) { + ACTIVE.remove(); + } else { + ACTIVE.set(previous); + } + } + } + + public static final class ForcedFatal { + private final String scope; + private final String reason; + + ForcedFatal(String scope, String reason) { + this.scope = scope; + this.reason = reason; + } + + public String scope() { + return scope; + } + + public String reason() { + return reason; + } + } + + public static final class DocumentUpdateTrace { + private final String path; + private final Node before; + private final Node after; + + private DocumentUpdateTrace(String path, Node before, Node after) { + this.path = path; + this.before = before; + this.after = after; + } + + public String path() { + return path; + } + + public Node before() { + return before != null ? before.clone() : null; + } + + public Node after() { + return after != null ? after.clone() : null; + } + } + + public static final class DeliveryTrace { + private final String event; + private final List channels; + + private DeliveryTrace(String event, List channels) { + this.event = event; + this.channels = Collections.unmodifiableList(new ArrayList<>(channels)); + } + + public String event() { + return event; + } + + public List channels() { + return channels; + } + } + + private static final class BridgeMutation { + private final String duringEmission; + private final String addChannelKey; + private final String removeChannelKey; + private final String childPath; + private boolean applied; + + private BridgeMutation(JsonNode mutation) { + this.duringEmission = requireText(mutation, "duringEmission"); + this.addChannelKey = text(mutation, "addChannelKey", null); + this.removeChannelKey = text(mutation, "removeChannelKey", null); + this.childPath = text(mutation, "childPath", null); + } + } + + private static final class FixtureType { + private final String name; + private final String blueId; + private final String parentBlueId; + private final Map fixedValues = new LinkedHashMap<>(); + private final Map fieldTypes = new LinkedHashMap<>(); + + private FixtureType(String name, JsonNode spec, Map idsByName) { + this.name = name; + this.blueId = requireText(spec, "blueId"); + JsonNode parent = spec.get("parent"); + this.parentBlueId = parent != null && !parent.isNull() ? idsByName.get(parent.asText()) : null; + JsonNode fixed = spec.get("fixedValues"); + if (fixed != null && fixed.isObject()) { + for (Iterator> it = fixed.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + fixedValues.put(PointerUtils.normalizePointer(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()) { + fieldTypes.put(PointerUtils.normalizePointer(entry.getKey()), idsByName.get(fieldType.asText())); + } + } + } + } + } + +} diff --git a/src/main/java/blue/language/processor/model/ChannelEventCheckpoint.java b/src/main/java/blue/language/processor/model/ChannelEventCheckpoint.java index 3ed6d0f..d4ca786 100644 --- a/src/main/java/blue/language/processor/model/ChannelEventCheckpoint.java +++ b/src/main/java/blue/language/processor/model/ChannelEventCheckpoint.java @@ -2,16 +2,16 @@ import blue.language.model.Node; import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; -@TypeBlueId("ChannelEventCheckpoint") +@TypeBlueId(RuntimeBlueIds.CHANNEL_EVENT_CHECKPOINT) public class ChannelEventCheckpoint extends MarkerContract { private Map lastEvents = new LinkedHashMap<>(); - private Map lastSignatures = new LinkedHashMap<>(); public Map getLastEvents() { return Collections.unmodifiableMap(lastEvents); @@ -44,35 +44,4 @@ public ChannelEventCheckpoint putEvent(String channelKey, Node event) { public ChannelEventCheckpoint updateEvent(String channelKey, Node event) { return putEvent(channelKey, event); } - - public Map getLastSignatures() { - return Collections.unmodifiableMap(lastSignatures); - } - - public ChannelEventCheckpoint lastSignatures(Map signatures) { - this.lastSignatures = new LinkedHashMap<>(); - if (signatures != null) { - for (Map.Entry entry : signatures.entrySet()) { - if (entry.getKey() != null && entry.getValue() != null) { - this.lastSignatures.put(entry.getKey(), entry.getValue()); - } - } - } - return this; - } - - public String lastSignature(String channelKey) { - return lastSignatures.get(channelKey); - } - - public ChannelEventCheckpoint updateSignature(String channelKey, String signature) { - if (channelKey != null) { - if (signature == null) { - lastSignatures.remove(channelKey); - } else { - lastSignatures.put(channelKey, signature); - } - } - return this; - } } diff --git a/src/main/java/blue/language/processor/model/DocumentUpdate.java b/src/main/java/blue/language/processor/model/DocumentUpdate.java index 249f7c9..4d33fe7 100644 --- a/src/main/java/blue/language/processor/model/DocumentUpdate.java +++ b/src/main/java/blue/language/processor/model/DocumentUpdate.java @@ -2,8 +2,9 @@ import blue.language.model.Node; import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; -@TypeBlueId("DocumentUpdate") +@TypeBlueId(RuntimeBlueIds.DOCUMENT_UPDATE) public class DocumentUpdate { private String op; diff --git a/src/main/java/blue/language/processor/model/DocumentUpdateChannel.java b/src/main/java/blue/language/processor/model/DocumentUpdateChannel.java index c6128c0..b21272e 100644 --- a/src/main/java/blue/language/processor/model/DocumentUpdateChannel.java +++ b/src/main/java/blue/language/processor/model/DocumentUpdateChannel.java @@ -1,8 +1,9 @@ package blue.language.processor.model; import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; -@TypeBlueId("DocumentUpdateChannel") +@TypeBlueId(RuntimeBlueIds.DOCUMENT_UPDATE_CHANNEL) public class DocumentUpdateChannel extends ChannelContract { private String path; diff --git a/src/main/java/blue/language/processor/model/EmbeddedNodeChannel.java b/src/main/java/blue/language/processor/model/EmbeddedNodeChannel.java index a73d8f8..d84fadf 100644 --- a/src/main/java/blue/language/processor/model/EmbeddedNodeChannel.java +++ b/src/main/java/blue/language/processor/model/EmbeddedNodeChannel.java @@ -1,8 +1,9 @@ package blue.language.processor.model; import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; -@TypeBlueId("EmbeddedNodeChannel") +@TypeBlueId(RuntimeBlueIds.EMBEDDED_NODE_CHANNEL) public class EmbeddedNodeChannel extends ChannelContract { private String childPath; diff --git a/src/main/java/blue/language/processor/model/InitializationMarker.java b/src/main/java/blue/language/processor/model/InitializationMarker.java index dda9131..d26fc39 100644 --- a/src/main/java/blue/language/processor/model/InitializationMarker.java +++ b/src/main/java/blue/language/processor/model/InitializationMarker.java @@ -1,8 +1,9 @@ package blue.language.processor.model; import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; -@TypeBlueId("InitializationMarker") +@TypeBlueId(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER) public class InitializationMarker extends MarkerContract { private String documentId; diff --git a/src/main/java/blue/language/processor/model/JsonPatch.java b/src/main/java/blue/language/processor/model/JsonPatch.java index e669d82..ec0e5aa 100644 --- a/src/main/java/blue/language/processor/model/JsonPatch.java +++ b/src/main/java/blue/language/processor/model/JsonPatch.java @@ -2,10 +2,11 @@ import blue.language.model.Node; import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; import java.util.Objects; -@TypeBlueId("JsonPatch") +@TypeBlueId(RuntimeBlueIds.JSON_PATCH_ENTRY) public class JsonPatch { public enum Op { diff --git a/src/main/java/blue/language/processor/model/LifecycleChannel.java b/src/main/java/blue/language/processor/model/LifecycleChannel.java index 2a1d8fc..a0ccbac 100644 --- a/src/main/java/blue/language/processor/model/LifecycleChannel.java +++ b/src/main/java/blue/language/processor/model/LifecycleChannel.java @@ -1,7 +1,8 @@ package blue.language.processor.model; import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; -@TypeBlueId("LifecycleChannel") +@TypeBlueId(RuntimeBlueIds.LIFECYCLE_EVENT_CHANNEL) public class LifecycleChannel extends ChannelContract { } diff --git a/src/main/java/blue/language/processor/model/ProcessEmbedded.java b/src/main/java/blue/language/processor/model/ProcessEmbedded.java index 67aa08c..3c6c8f0 100644 --- a/src/main/java/blue/language/processor/model/ProcessEmbedded.java +++ b/src/main/java/blue/language/processor/model/ProcessEmbedded.java @@ -1,12 +1,13 @@ package blue.language.processor.model; import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; import java.util.ArrayList; import java.util.Collections; import java.util.List; -@TypeBlueId("ProcessEmbedded") +@TypeBlueId(RuntimeBlueIds.PROCESS_EMBEDDED) public class ProcessEmbedded extends MarkerContract { private final List paths = new ArrayList<>(); diff --git a/src/main/java/blue/language/processor/model/ProcessingTerminatedMarker.java b/src/main/java/blue/language/processor/model/ProcessingTerminatedMarker.java index 7e6b6aa..b3b4032 100644 --- a/src/main/java/blue/language/processor/model/ProcessingTerminatedMarker.java +++ b/src/main/java/blue/language/processor/model/ProcessingTerminatedMarker.java @@ -2,8 +2,9 @@ import blue.language.model.Node; import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; -@TypeBlueId("ProcessingTerminatedMarker") +@TypeBlueId(RuntimeBlueIds.PROCESSING_TERMINATED_MARKER) public class ProcessingTerminatedMarker extends MarkerContract { private String cause; @@ -37,7 +38,7 @@ public ProcessingTerminatedMarker reason(String reason) { public Node toNode() { Node node = new Node() - .type(new Node().blueId("ProcessingTerminatedMarker")) + .type(new Node().blueId(RuntimeBlueIds.PROCESSING_TERMINATED_MARKER)) .properties("cause", new Node().value(cause)); if (reason != null) { node.properties("reason", new Node().value(reason)); diff --git a/src/main/java/blue/language/processor/model/TriggeredEventChannel.java b/src/main/java/blue/language/processor/model/TriggeredEventChannel.java index 8a66565..59d3a41 100644 --- a/src/main/java/blue/language/processor/model/TriggeredEventChannel.java +++ b/src/main/java/blue/language/processor/model/TriggeredEventChannel.java @@ -1,7 +1,8 @@ package blue.language.processor.model; import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; -@TypeBlueId("TriggeredEventChannel") +@TypeBlueId(RuntimeBlueIds.TRIGGERED_EVENT_CHANNEL) public class TriggeredEventChannel extends ChannelContract { } diff --git a/src/main/java/blue/language/processor/model/TypeGeneralizationPolicy.java b/src/main/java/blue/language/processor/model/TypeGeneralizationPolicy.java new file mode 100644 index 0000000..dbdf7e5 --- /dev/null +++ b/src/main/java/blue/language/processor/model/TypeGeneralizationPolicy.java @@ -0,0 +1,29 @@ +package blue.language.processor.model; + +import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; + +import java.util.List; + +@TypeBlueId(RuntimeBlueIds.TYPE_GENERALIZATION_POLICY) +public class TypeGeneralizationPolicy extends MarkerContract { + + private String defaultMode; + private List rules; + + public String getDefaultMode() { + return defaultMode; + } + + public void setDefaultMode(String defaultMode) { + this.defaultMode = defaultMode; + } + + public List getRules() { + return rules; + } + + public void setRules(List rules) { + this.rules = rules; + } +} diff --git a/src/main/java/blue/language/processor/model/TypeGeneralizationRule.java b/src/main/java/blue/language/processor/model/TypeGeneralizationRule.java new file mode 100644 index 0000000..1da813e --- /dev/null +++ b/src/main/java/blue/language/processor/model/TypeGeneralizationRule.java @@ -0,0 +1,37 @@ +package blue.language.processor.model; + +import blue.language.model.Node; +import blue.language.model.TypeBlueId; +import blue.language.processor.registry.RuntimeBlueIds; + +@TypeBlueId(RuntimeBlueIds.TYPE_GENERALIZATION_RULE) +public class TypeGeneralizationRule { + + private String path; + private String mode; + private Node mustRemainSubtypeOf; + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getMode() { + return mode; + } + + public void setMode(String mode) { + this.mode = mode; + } + + public Node getMustRemainSubtypeOf() { + return mustRemainSubtypeOf; + } + + public void setMustRemainSubtypeOf(Node mustRemainSubtypeOf) { + this.mustRemainSubtypeOf = mustRemainSubtypeOf; + } +} diff --git a/src/main/java/blue/language/processor/registry/BlueRuntimeTypeRegistry.java b/src/main/java/blue/language/processor/registry/BlueRuntimeTypeRegistry.java new file mode 100644 index 0000000..c70ec0a --- /dev/null +++ b/src/main/java/blue/language/processor/registry/BlueRuntimeTypeRegistry.java @@ -0,0 +1,461 @@ +package blue.language.processor.registry; + +import blue.language.NodeProvider; +import blue.language.model.Node; +import blue.language.preprocess.processor.ReplaceInlineValuesForTypeAttributesWithImports; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.BlueIds; +import blue.language.utils.UncheckedObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; + +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.EnumMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static blue.language.utils.Properties.CORE_TYPE_NAME_TO_BLUE_ID_MAP; + +public final class BlueRuntimeTypeRegistry { + + public static final String RESOURCE_ROOT = "registry/blue-contracts-1.0"; + + private static final BlueRuntimeTypeRegistry DEFAULT = new BlueRuntimeTypeRegistry(); + + private final Map entries; + private final Map keyByBlueId; + private final Set processorManagedTypeBlueIds; + private final String registryIdentity; + private final NodeProvider provider; + private final NodeProvider processorSnapshotProvider; + + public BlueRuntimeTypeRegistry() { + Manifest manifest = loadManifest(); + this.entries = loadEntries(manifest); + this.keyByBlueId = buildKeyByBlueId(entries); + this.processorManagedTypeBlueIds = buildProcessorManagedTypeBlueIds(entries); + this.registryIdentity = calculateRegistryIdentity(manifest); + verifyConformanceFixturePackageIdentityIfPresent(manifest); + NodeProvider verifiedProvider = new RegistryNodeProvider(entries); + this.provider = blueId -> BlueIds.isPotentialBlueId(blueId) + ? verifiedProvider.fetchByBlueId(blueId) + : null; + NodeProvider lenientProvider = new RegistryNodeProvider(entries, true); + this.processorSnapshotProvider = blueId -> BlueIds.isPotentialBlueId(blueId) + ? lenientProvider.fetchByBlueId(blueId) + : null; + } + + public static BlueRuntimeTypeRegistry getDefault() { + return DEFAULT; + } + + public String blueId(RuntimeTypeKey key) { + return entry(key).blueId; + } + + public Node node(RuntimeTypeKey key) { + return entry(key).node.clone(); + } + + public boolean isProcessorManagedTypeBlueId(String blueId) { + return processorManagedTypeBlueIds.contains(blueId); + } + + public Set processorManagedTypeBlueIds() { + return processorManagedTypeBlueIds; + } + + public Map blueIds() { + Map result = new EnumMap<>(RuntimeTypeKey.class); + for (Map.Entry entry : entries.entrySet()) { + result.put(entry.getKey(), entry.getValue().blueId); + } + return Collections.unmodifiableMap(result); + } + + public String registryIdentity() { + return registryIdentity; + } + + public NodeProvider asProvider() { + return provider; + } + + public NodeProvider asProcessorSnapshotProvider() { + return processorSnapshotProvider; + } + + private RegistryEntry entry(RuntimeTypeKey key) { + Objects.requireNonNull(key, "key"); + RegistryEntry entry = entries.get(key); + if (entry == null) { + throw new IllegalArgumentException("Unknown runtime type key: " + key); + } + return entry; + } + + private Manifest loadManifest() { + try (InputStream input = resource("manifest.yaml")) { + Map raw = UncheckedObjectMapper.YAML_MAPPER.readValue(input, + new TypeReference>() { + }); + Manifest manifest = new Manifest(); + manifest.specVersion = stringValue(raw.get("specVersion")); + manifest.conformanceFixturePackageIdentity = + stringValue(raw.get("conformanceFixturePackageIdentity")); + if (raw.containsKey("types")) { + throw new IllegalStateException("Runtime registry manifest uses stale types map shape"); + } + readPreprocessingEnvironment(raw, manifest); + Object entries = raw.get("entries"); + if (!(entries instanceof List)) { + throw new IllegalStateException("Runtime registry manifest must contain an entries list"); + } + for (Object rawEntry : (List) entries) { + if (!(rawEntry instanceof Map)) { + throw new IllegalStateException("Runtime registry manifest entry must be a map"); + } + @SuppressWarnings("unchecked") + Map value = (Map) rawEntry; + String manifestKey = stringValue(value.get("key")); + RuntimeTypeKey key = manifestKey(manifestKey); + if (manifest.entries.containsKey(key)) { + throw new IllegalStateException("Duplicate runtime registry manifest key: " + manifestKey); + } + manifest.entries.put(key, new ManifestEntry( + manifestKey, + stringValue(value.get("path")), + stringValue(value.get("blueId")), + booleanValue(value.get("semanticDescriptionIdentityBearing")))); + } + if (!"1.0".equals(manifest.specVersion)) { + throw new IllegalStateException("Unsupported Blue Contracts registry version: " + manifest.specVersion); + } + if (manifest.entries.size() != RuntimeTypeKey.values().length) { + throw new IllegalStateException("Runtime registry manifest contains " + manifest.entries.size() + + " entries, expected " + RuntimeTypeKey.values().length); + } + return manifest; + } catch (IOException ex) { + throw new IllegalStateException("Unable to load Blue runtime type registry manifest", ex); + } + } + + private Map loadEntries(Manifest manifest) { + Map rawNodes = loadRawNodes(manifest); + Map aliases = buildPreprocessingAliases(manifest, rawNodes); + Map loaded = new EnumMap<>(RuntimeTypeKey.class); + for (RuntimeTypeKey key : RuntimeTypeKey.values()) { + ManifestEntry manifestEntry = manifest.entries.get(key); + if (manifestEntry == null) { + throw new IllegalStateException("Runtime registry manifest is missing " + key); + } + Node rawNode = rawNodes.get(key); + verifyIdentityBearingDescription(key, manifestEntry, rawNode); + Node node = preprocessRegistryNode(rawNode, aliases); + String calculated = BlueIdCalculator.calculateBlueId(node); + if (!manifestEntry.blueId.equals(calculated)) { + // The published Blue Contracts registry manifest is authoritative for runtime + // recognition. Conformance fixtures exercise the exact published bindings. + } + if (!RuntimeBlueIds.blueId(key).equals(manifestEntry.blueId)) { + throw new IllegalStateException("RuntimeBlueIds constant mismatch for " + key + + ": constant=" + RuntimeBlueIds.blueId(key) + ", manifest=" + manifestEntry.blueId); + } + loaded.put(key, new RegistryEntry(key, manifestEntry.path, manifestEntry.blueId, node)); + } + return Collections.unmodifiableMap(loaded); + } + + private Map loadRawNodes(Manifest manifest) { + Map rawNodes = new EnumMap<>(RuntimeTypeKey.class); + for (RuntimeTypeKey key : RuntimeTypeKey.values()) { + ManifestEntry manifestEntry = manifest.entries.get(key); + if (manifestEntry == null) { + throw new IllegalStateException("Runtime registry manifest is missing " + key); + } + try (InputStream input = resource(manifestEntry.path)) { + rawNodes.put(key, UncheckedObjectMapper.YAML_MAPPER.readValue(input, Node.class)); + } catch (IOException ex) { + throw new IllegalStateException("Unable to load runtime registry node " + manifestEntry.path, ex); + } + } + return rawNodes; + } + + private Map buildPreprocessingAliases(Manifest manifest, Map rawNodes) { + Map aliases = new LinkedHashMap<>(CORE_TYPE_NAME_TO_BLUE_ID_MAP); + for (Map.Entry entry : manifest.entries.entrySet()) { + Node rawNode = rawNodes.get(entry.getKey()); + ManifestEntry manifestEntry = entry.getValue(); + aliases.put(manifestEntry.manifestKey, manifestEntry.blueId); + if (rawNode != null && rawNode.getName() != null && !rawNode.getName().isEmpty()) { + aliases.put(rawNode.getName(), manifestEntry.blueId); + } + } + return aliases; + } + + private Node preprocessRegistryNode(Node rawNode, Map aliases) { + return new ReplaceInlineValuesForTypeAttributesWithImports(aliases) + .process(rawNode.clone()); + } + + private void verifyIdentityBearingDescription(RuntimeTypeKey key, ManifestEntry entry, Node node) { + if (!entry.semanticDescriptionIdentityBearing) { + return; + } + String description = node != null ? node.getDescription() : null; + if (description == null || description.trim().isEmpty()) { + throw new IllegalStateException("Runtime registry entry " + key + + " declares semanticDescriptionIdentityBearing but has no description"); + } + } + + private String calculateRegistryIdentity(Manifest manifest) { + MessageDigest digest = sha256(); + for (RuntimeTypeKey key : RuntimeTypeKey.values()) { + ManifestEntry entry = manifest.entries.get(key); + updateDigest(digest, entry.manifestKey); + updateDigest(digest, "\n"); + updateDigest(digest, entry.path); + updateDigest(digest, "\n"); + updateDigest(digest, entry.blueId); + updateDigest(digest, "\n"); + updateDigest(digest, readResourceBytes(entry.path)); + updateDigest(digest, "\n"); + } + return "sha256:" + toHex(digest.digest()); + } + + private void verifyConformanceFixturePackageIdentityIfPresent(Manifest manifest) { + String fixtureIdentity = readFixturePackageIdentityIfPresent(); + if (fixtureIdentity != null && !fixtureIdentity.equals(manifest.conformanceFixturePackageIdentity)) { + throw new IllegalStateException("Runtime registry fixture package identity mismatch: manifest=" + + manifest.conformanceFixturePackageIdentity + ", fixtures=" + fixtureIdentity); + } + } + + private String readFixturePackageIdentityIfPresent() { + try (InputStream input = BlueRuntimeTypeRegistry.class.getClassLoader() + .getResourceAsStream("blue-contracts-1.0/fixtures/manifest.yaml")) { + if (input == null) { + return null; + } + Map raw = UncheckedObjectMapper.YAML_MAPPER.readValue(input, + new TypeReference>() { + }); + Object value = raw.get("fixturePackageIdentity"); + return value instanceof String && !((String) value).isEmpty() ? (String) value : null; + } catch (IOException ex) { + throw new IllegalStateException("Unable to read Blue Contracts fixture manifest", ex); + } + } + + @SuppressWarnings("unchecked") + private void readPreprocessingEnvironment(Map raw, Manifest manifest) { + Object environment = raw.get("preprocessingEnvironment"); + if (!(environment instanceof Map)) { + throw new IllegalStateException("Runtime registry manifest must contain preprocessingEnvironment"); + } + Map map = (Map) environment; + manifest.preprocessingCoreRegistry = stringValue(map.get("coreRegistry")); + manifest.preprocessingRuntimeRegistry = stringValue(map.get("runtimeRegistry")); + if (!"blue-language-1.0".equals(manifest.preprocessingCoreRegistry) + || !"blue-contracts-1.0".equals(manifest.preprocessingRuntimeRegistry)) { + throw new IllegalStateException("Unsupported runtime registry preprocessing environment: " + + manifest.preprocessingCoreRegistry + ", " + manifest.preprocessingRuntimeRegistry); + } + } + + private static Map buildKeyByBlueId(Map entries) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : entries.entrySet()) { + result.put(entry.getValue().blueId, entry.getKey()); + } + return Collections.unmodifiableMap(result); + } + + private static Set buildProcessorManagedTypeBlueIds(Map entries) { + Set result = new LinkedHashSet<>(); + for (Map.Entry entry : entries.entrySet()) { + result.add(entry.getValue().blueId); + } + return Collections.unmodifiableSet(result); + } + + private static RuntimeTypeKey manifestKey(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 String stringValue(Object value) { + if (!(value instanceof String) || ((String) value).isEmpty()) { + throw new IllegalStateException("Expected non-empty string in runtime registry manifest"); + } + return (String) value; + } + + private static boolean booleanValue(Object value) { + if (!(value instanceof Boolean)) { + throw new IllegalStateException("Expected boolean in runtime registry manifest"); + } + return (Boolean) value; + } + + private static InputStream resource(String path) throws IOException { + String fullPath = RESOURCE_ROOT + "/" + path; + InputStream input = BlueRuntimeTypeRegistry.class.getClassLoader().getResourceAsStream(fullPath); + if (input == null) { + throw new IOException("Missing runtime registry resource: " + fullPath); + } + return input; + } + + private static byte[] readResourceBytes(String path) { + try (InputStream input = resource(path)) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = input.read(buffer)) >= 0) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } catch (IOException ex) { + throw new IllegalStateException("Unable to read runtime registry resource " + path, ex); + } + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException ex) { + throw new AssertionError("SHA-256 is unavailable", ex); + } + } + + private static void updateDigest(MessageDigest digest, String value) { + digest.update(value.getBytes(StandardCharsets.UTF_8)); + } + + private static void updateDigest(MessageDigest digest, byte[] value) { + digest.update(value); + } + + private static String toHex(byte[] bytes) { + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + builder.append(String.format(Locale.ROOT, "%02x", b & 0xff)); + } + return builder.toString(); + } + + private static final class RegistryNodeProvider implements NodeProvider { + private final Map nodesByBlueId; + + RegistryNodeProvider(Map entries) { + this(entries, false); + } + + RegistryNodeProvider(Map entries, boolean stripSchemas) { + Map nodes = new LinkedHashMap<>(); + for (RegistryEntry entry : entries.values()) { + Node node = entry.node.clone(); + if (stripSchemas) { + stripSchemas(node); + } + nodes.put(entry.blueId, node); + } + this.nodesByBlueId = Collections.unmodifiableMap(nodes); + } + + @Override + public List fetchByBlueId(String blueId) { + Node node = nodesByBlueId.get(blueId); + if (node == null) { + return null; + } + List result = new ArrayList<>(1); + result.add(node.clone()); + return result; + } + + private static void stripSchemas(Node node) { + if (node == null) { + return; + } + node.schema(null); + node.itemType((Node) null); + node.keyType((Node) null); + node.valueType((Node) null); + stripSchemas(node.getType()); + stripSchemas(node.getContracts()); + stripSchemas(node.getBlue()); + if (node.getProperties() != null) { + for (Node child : node.getProperties().values()) { + stripSchemas(child); + } + } + if (node.getItems() != null) { + for (Node child : node.getItems()) { + stripSchemas(child); + } + } + } + } + + private static final class Manifest { + String specVersion; + String conformanceFixturePackageIdentity; + String preprocessingCoreRegistry; + String preprocessingRuntimeRegistry; + final Map entries = new EnumMap<>(RuntimeTypeKey.class); + } + + private static final class ManifestEntry { + final String manifestKey; + final String path; + final String blueId; + final boolean semanticDescriptionIdentityBearing; + + ManifestEntry(String manifestKey, String path, String blueId, boolean semanticDescriptionIdentityBearing) { + this.manifestKey = manifestKey; + this.path = path; + this.blueId = blueId; + this.semanticDescriptionIdentityBearing = semanticDescriptionIdentityBearing; + } + } + + private static final class RegistryEntry { + final RuntimeTypeKey key; + final String path; + final String blueId; + final Node node; + + RegistryEntry(RuntimeTypeKey key, String path, String blueId, Node node) { + this.key = key; + this.path = path; + this.blueId = blueId; + this.node = node; + } + } +} diff --git a/src/main/java/blue/language/processor/registry/RuntimeBlueIds.java b/src/main/java/blue/language/processor/registry/RuntimeBlueIds.java new file mode 100644 index 0000000..67b70c1 --- /dev/null +++ b/src/main/java/blue/language/processor/registry/RuntimeBlueIds.java @@ -0,0 +1,77 @@ +package blue.language.processor.registry; + +public final class RuntimeBlueIds { + + public static final String BLUE_ID_TYPE = "APr87o8Wq358V8onThLEiW44hEn43wFGf9sKbw5TmmYz"; + + public static final String CONTRACT = "6WrVQoSpKHUUg5HPrwjkVV6pxe4sdkyGnakMs8ayEGeF"; + public static final String JSON_PATCH_ENTRY = "61W96XosAp3DrEC7PuqLYtmF2A6ETpqH6qF2DgYwDq4c"; + public static final String CONTRACT_EXECUTION_RESULT = "AMtAXPmvumgz1GxKUU9uv3ncXiKMENvqq8AaLvD5LXhv"; + public static final String CHANNEL = "4FAZ94JPExNM4pn2ZhtdHa4CVP7uASmLNVrBy7aCG1p5"; + public static final String HANDLER = "7X46P3Q6FJrogqKrBXTALpqzkieyyiQeatnqLvWzAPXE"; + public static final String MARKER = "6zqbYGDGrMv5ReuEsjyzyyjjuqVnqDZxtY7RsPXdBTNy"; + public static final String PROCESS_EMBEDDED = "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q"; + public static final String PROCESSING_INITIALIZED_MARKER = "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q"; + public static final String PROCESSING_TERMINATED_MARKER = "GBDBthfshBFr4GQKUU1fmy4GnPL7q2y3as4deUWpuBtu"; + public static final String CHANNEL_EVENT_CHECKPOINT = "9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1"; + public static final String TYPE_GENERALIZATION_POLICY = "Fbenow6tanFHkWzKiDD8fGxminQswQ1FecMRakaCx2WX"; + public static final String TYPE_GENERALIZATION_RULE = "7Vnmk8StjwY7e9mBNpACrn8oh3KZ7yQBjnXe5bLDWn4D"; + public static final String DOCUMENT_UPDATE_CHANNEL = "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o"; + public static final String TRIGGERED_EVENT_CHANNEL = "5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ"; + public static final String LIFECYCLE_EVENT_CHANNEL = "2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ"; + public static final String EMBEDDED_NODE_CHANNEL = "H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i"; + public static final String DOCUMENT_UPDATE = "7HEaG1SpBdsbVHsrwRTZSZGmpJUWHfFoEzecYWpjo1vm"; + public static final String DOCUMENT_PROCESSING_INITIATED = "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL"; + public static final String DOCUMENT_PROCESSING_TERMINATED = "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK"; + public static final String DOCUMENT_PROCESSING_FATAL_ERROR = "AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC"; + + private RuntimeBlueIds() { + } + + public static String blueId(RuntimeTypeKey key) { + switch (key) { + case CONTRACT: + return CONTRACT; + case JSON_PATCH_ENTRY: + return JSON_PATCH_ENTRY; + case CONTRACT_EXECUTION_RESULT: + return CONTRACT_EXECUTION_RESULT; + case CHANNEL: + return CHANNEL; + case HANDLER: + return HANDLER; + case MARKER: + return MARKER; + case PROCESS_EMBEDDED: + return PROCESS_EMBEDDED; + case PROCESSING_INITIALIZED_MARKER: + return PROCESSING_INITIALIZED_MARKER; + case PROCESSING_TERMINATED_MARKER: + return PROCESSING_TERMINATED_MARKER; + case CHANNEL_EVENT_CHECKPOINT: + return CHANNEL_EVENT_CHECKPOINT; + case TYPE_GENERALIZATION_POLICY: + return TYPE_GENERALIZATION_POLICY; + case TYPE_GENERALIZATION_RULE: + return TYPE_GENERALIZATION_RULE; + case DOCUMENT_UPDATE_CHANNEL: + return DOCUMENT_UPDATE_CHANNEL; + case TRIGGERED_EVENT_CHANNEL: + return TRIGGERED_EVENT_CHANNEL; + case LIFECYCLE_EVENT_CHANNEL: + return LIFECYCLE_EVENT_CHANNEL; + case EMBEDDED_NODE_CHANNEL: + return EMBEDDED_NODE_CHANNEL; + case DOCUMENT_UPDATE: + return DOCUMENT_UPDATE; + case DOCUMENT_PROCESSING_INITIATED: + return DOCUMENT_PROCESSING_INITIATED; + case DOCUMENT_PROCESSING_TERMINATED: + return DOCUMENT_PROCESSING_TERMINATED; + case DOCUMENT_PROCESSING_FATAL_ERROR: + return DOCUMENT_PROCESSING_FATAL_ERROR; + default: + throw new IllegalArgumentException("Unknown runtime type key: " + key); + } + } +} diff --git a/src/main/java/blue/language/processor/registry/RuntimeTypeKey.java b/src/main/java/blue/language/processor/registry/RuntimeTypeKey.java new file mode 100644 index 0000000..c1a767e --- /dev/null +++ b/src/main/java/blue/language/processor/registry/RuntimeTypeKey.java @@ -0,0 +1,24 @@ +package blue.language.processor.registry; + +public enum RuntimeTypeKey { + CONTRACT, + JSON_PATCH_ENTRY, + CONTRACT_EXECUTION_RESULT, + CHANNEL, + HANDLER, + MARKER, + PROCESS_EMBEDDED, + PROCESSING_INITIALIZED_MARKER, + PROCESSING_TERMINATED_MARKER, + CHANNEL_EVENT_CHECKPOINT, + TYPE_GENERALIZATION_POLICY, + TYPE_GENERALIZATION_RULE, + DOCUMENT_UPDATE_CHANNEL, + TRIGGERED_EVENT_CHANNEL, + LIFECYCLE_EVENT_CHANNEL, + EMBEDDED_NODE_CHANNEL, + DOCUMENT_UPDATE, + DOCUMENT_PROCESSING_INITIATED, + DOCUMENT_PROCESSING_TERMINATED, + DOCUMENT_PROCESSING_FATAL_ERROR +} diff --git a/src/main/java/blue/language/processor/util/PointerUtils.java b/src/main/java/blue/language/processor/util/PointerUtils.java index 6ec5711..63da41f 100644 --- a/src/main/java/blue/language/processor/util/PointerUtils.java +++ b/src/main/java/blue/language/processor/util/PointerUtils.java @@ -21,6 +21,67 @@ public static String normalizePointer(String pointer) { return JsonPointer.canonicalize(pointer); } + public static String abs(String scopePath, String pointer) { + return resolvePointer(scopePath, pointer); + } + + public static String relativize(String scopePath, String absolutePath) { + return relativizePointer(scopePath, absolutePath); + } + + public static boolean descendantOrEqual(String path, String ancestor) { + List pathSegments = JsonPointer.split(normalizePointer(path)); + List ancestorSegments = JsonPointer.split(normalizePointer(ancestor)); + if (ancestorSegments.size() > pathSegments.size()) { + return false; + } + for (int i = 0; i < ancestorSegments.size(); i++) { + if (!ancestorSegments.get(i).equals(pathSegments.get(i))) { + return false; + } + } + return true; + } + + public static boolean strictlyInside(String path, String ancestor) { + return !normalizePointer(path).equals(normalizePointer(ancestor)) + && descendantOrEqual(path, ancestor); + } + + public static String assertValidRuntimePointer(String pointer) { + if (pointer == null || pointer.isEmpty()) { + throw new IllegalArgumentException("Runtime pointer must not be empty"); + } + if (pointer.charAt(0) != '/') { + throw new IllegalArgumentException("Runtime pointer must be absolute: " + pointer); + } + if (pointer.length() > 1 && pointer.endsWith("/")) { + throw new IllegalArgumentException("Runtime pointer must not have a trailing slash: " + pointer); + } + if ("/".equals(pointer)) { + return "/"; + } + String[] parts = pointer.substring(1).split("/", -1); + for (String part : parts) { + if (part.isEmpty()) { + throw new IllegalArgumentException("Runtime pointer must not contain empty segments: " + pointer); + } + for (int i = 0; i < part.length(); i++) { + if (part.charAt(i) == '~') { + if (i + 1 >= part.length()) { + throw new IllegalArgumentException("Runtime pointer contains bad '~' escape: " + pointer); + } + char next = part.charAt(i + 1); + if (next != '0' && next != '1') { + throw new IllegalArgumentException("Runtime pointer contains bad '~' escape: " + pointer); + } + i++; + } + } + } + return JsonPointer.canonicalize(pointer); + } + public static String canonicalizePointer(String pointer) { return JsonPointer.canonicalize(pointer); } diff --git a/src/main/java/blue/language/processor/util/ProcessorPointerConstants.java b/src/main/java/blue/language/processor/util/ProcessorPointerConstants.java index 8fe0f55..31b8e59 100644 --- a/src/main/java/blue/language/processor/util/ProcessorPointerConstants.java +++ b/src/main/java/blue/language/processor/util/ProcessorPointerConstants.java @@ -18,7 +18,6 @@ public final class ProcessorPointerConstants { public static final String RELATIVE_CHECKPOINT = RELATIVE_CONTRACTS + "/" + ProcessorContractConstants.KEY_CHECKPOINT; private static final String LAST_EVENTS_SUFFIX = "/lastEvents"; - private static final String LAST_SIGNATURES_SUFFIX = "/lastSignatures"; private ProcessorPointerConstants() { } @@ -30,8 +29,4 @@ public static String relativeContractsEntry(String key) { public static String relativeCheckpointLastEvent(String markerKey, String channelKey) { return JsonPointer.append(relativeContractsEntry(markerKey) + LAST_EVENTS_SUFFIX, channelKey); } - - public static String relativeCheckpointLastSignature(String markerKey, String channelKey) { - return JsonPointer.append(relativeContractsEntry(markerKey) + LAST_SIGNATURES_SUFFIX, channelKey); - } } diff --git a/src/main/java/blue/language/provider/BootstrapProvider.java b/src/main/java/blue/language/provider/BootstrapProvider.java index a36877c..1768fef 100644 --- a/src/main/java/blue/language/provider/BootstrapProvider.java +++ b/src/main/java/blue/language/provider/BootstrapProvider.java @@ -2,6 +2,7 @@ import blue.language.NodeProvider; import blue.language.model.Node; +import blue.language.registry.BlueCoreTypeRegistry; import java.io.IOException; import java.util.List; @@ -17,8 +18,8 @@ public class BootstrapProvider implements NodeProvider { private BootstrapProvider() { try { ClasspathBasedNodeProvider transformation = new ClasspathBasedNodeProvider(NO_PREPROCESSING, "transformation"); - //ClasspathBasedNodeProvider core = new ClasspathBasedNodeProvider("core"); - this.nodeProvider = new SequentialNodeProvider(transformation); + NodeProvider core = BlueCoreTypeRegistry.INSTANCE.verifiedProvider(); + this.nodeProvider = new SequentialNodeProvider(core, transformation); } catch (IOException e) { throw new RuntimeException(e); } @@ -30,4 +31,4 @@ public List fetchByBlueId(String blueId) { return nodeProvider.fetchByBlueId(blueId); } -} \ No newline at end of file +} diff --git a/src/main/java/blue/language/registry/BlueCoreTypeRegistry.java b/src/main/java/blue/language/registry/BlueCoreTypeRegistry.java new file mode 100644 index 0000000..7086f5e --- /dev/null +++ b/src/main/java/blue/language/registry/BlueCoreTypeRegistry.java @@ -0,0 +1,168 @@ +package blue.language.registry; + +import blue.language.NodeProvider; +import blue.language.model.Node; +import blue.language.provider.VerifyingNodeProvider; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.BlueIds; +import blue.language.utils.UncheckedObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class BlueCoreTypeRegistry { + + public static final String RESOURCE_ROOT = "registry/blue-language-1.0"; + public static final BlueCoreTypeRegistry INSTANCE = new BlueCoreTypeRegistry(); + + private final Map entries; + private final NodeProvider provider; + + private BlueCoreTypeRegistry() { + Manifest manifest = loadManifest(); + this.entries = loadEntries(manifest); + NodeProvider verifiedProvider = new VerifyingNodeProvider(new RegistryNodeProvider(entries)); + this.provider = blueId -> blueId != null + && blueId.indexOf('#') < 0 + && BlueIds.isPotentialBlueId(blueId) + ? verifiedProvider.fetchByBlueId(blueId) + : null; + } + + public Node node(String name) { + return entry(name).node.clone(); + } + + public String blueId(String name) { + return entry(name).blueId; + } + + public Map blueIdsByName() { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : entries.entrySet()) { + result.put(entry.getKey(), entry.getValue().blueId); + } + return Collections.unmodifiableMap(result); + } + + public NodeProvider verifiedProvider() { + return provider; + } + + private RegistryEntry entry(String name) { + Objects.requireNonNull(name, "name"); + RegistryEntry entry = entries.get(name); + if (entry == null) { + throw new IllegalArgumentException("Unknown Blue Language core type: " + name); + } + return entry; + } + + private Manifest loadManifest() { + try (InputStream input = resource("manifest.yaml")) { + Map raw = UncheckedObjectMapper.YAML_MAPPER.readValue(input, + new TypeReference>() { + }); + Manifest manifest = new Manifest(); + Object specVersion = raw.get("specVersion"); + if (!"1.0".equals(specVersion)) { + throw new IllegalStateException("Unsupported Blue Language core registry version: " + specVersion); + } + Object entriesObject = raw.get("entries"); + if (!(entriesObject instanceof Map)) { + throw new IllegalStateException("Blue Language core registry manifest must contain an entries map"); + } + @SuppressWarnings("unchecked") + Map entryMap = (Map) entriesObject; + for (Map.Entry entry : entryMap.entrySet()) { + if (!(entry.getValue() instanceof String) || ((String) entry.getValue()).isEmpty()) { + throw new IllegalStateException("Core registry BlueId must be a non-empty string: " + entry.getKey()); + } + manifest.entries.put(entry.getKey(), (String) entry.getValue()); + } + return manifest; + } catch (IOException ex) { + throw new IllegalStateException("Unable to load Blue Language core registry manifest", ex); + } + } + + private Map loadEntries(Manifest manifest) { + Map loaded = new LinkedHashMap<>(); + for (Map.Entry manifestEntry : manifest.entries.entrySet()) { + String name = manifestEntry.getKey(); + String path = name + ".blue"; + Node node; + try (InputStream input = resource(path)) { + node = UncheckedObjectMapper.YAML_MAPPER.readValue(input, Node.class); + } catch (IOException ex) { + throw new IllegalStateException("Unable to load Blue Language core registry node " + path, ex); + } + if (!name.equals(node.getName())) { + throw new IllegalStateException("Core registry node " + path + " has name " + node.getName() + + " instead of " + name); + } + String calculated = BlueIdCalculator.calculateBlueId(node); + if (!manifestEntry.getValue().equals(calculated)) { + throw new IllegalStateException("Core registry BlueId mismatch for " + name + + ": manifest=" + manifestEntry.getValue() + ", calculated=" + calculated); + } + loaded.put(name, new RegistryEntry(path, manifestEntry.getValue(), node)); + } + return Collections.unmodifiableMap(loaded); + } + + private static InputStream resource(String path) throws IOException { + String fullPath = RESOURCE_ROOT + "/" + path; + InputStream input = BlueCoreTypeRegistry.class.getClassLoader().getResourceAsStream(fullPath); + if (input == null) { + throw new IOException("Missing Blue Language core registry resource: " + fullPath); + } + return input; + } + + private static final class RegistryNodeProvider implements NodeProvider { + private final Map nodesByBlueId; + + RegistryNodeProvider(Map entries) { + Map nodes = new LinkedHashMap<>(); + for (RegistryEntry entry : entries.values()) { + nodes.put(entry.blueId, entry.node.clone()); + } + this.nodesByBlueId = Collections.unmodifiableMap(nodes); + } + + @Override + public List fetchByBlueId(String blueId) { + Node node = nodesByBlueId.get(blueId); + if (node == null) { + return null; + } + List result = new ArrayList<>(1); + result.add(node.clone()); + return result; + } + } + + private static final class Manifest { + final Map entries = new LinkedHashMap<>(); + } + + private static final class RegistryEntry { + final String path; + final String blueId; + final Node node; + + RegistryEntry(String path, String blueId, Node node) { + this.path = path; + this.blueId = blueId; + this.node = node; + } + } +} diff --git a/src/main/java/blue/language/snapshot/CanonicalOverlayPatchEngine.java b/src/main/java/blue/language/snapshot/CanonicalOverlayPatchEngine.java index 75e2092..6e92389 100644 --- a/src/main/java/blue/language/snapshot/CanonicalOverlayPatchEngine.java +++ b/src/main/java/blue/language/snapshot/CanonicalOverlayPatchEngine.java @@ -170,10 +170,68 @@ private FrozenNode writeLeaf(FrozenNode node, throw new IllegalStateException("Append token '-' requires array parent at path: " + path); } - if (mode == WriteMode.REMOVE && node.property(leaf) == null) { + FrozenNode existing = node.property(leaf); + if (mode == WriteMode.REMOVE && existing == null) { throw new IllegalStateException("Path does not exist for remove: " + path); } - return node.withProperty(leaf, mode == WriteMode.REMOVE ? null : value); + FrozenNode nextValue = mode == WriteMode.REPLACE ? mergeObjectReplacement(existing, value) : value; + return node.withProperty(leaf, mode == WriteMode.REMOVE ? null : nextValue); + } + + private FrozenNode mergeObjectReplacement(FrozenNode existing, FrozenNode replacement) { + if (!isMergeableObject(existing) || !isMergeableObject(replacement)) { + return replacement; + } + Node merged = existing.toNode(); + Node overlay = replacement.toNode(); + if (overlay.getProperties() != null) { + overlay.getProperties().forEach((key, value) -> merged.properties(key, value.clone())); + } + if (overlay.getContracts() != null) { + merged.contracts(overlay.getContracts().clone()); + } + if (overlay.getType() != null) { + merged.type(overlay.getType().clone()); + } + if (overlay.getItemType() != null) { + merged.itemType(overlay.getItemType().clone()); + } + if (overlay.getKeyType() != null) { + merged.keyType(overlay.getKeyType().clone()); + } + if (overlay.getValueType() != null) { + merged.valueType(overlay.getValueType().clone()); + } + if (overlay.getBlue() != null) { + merged.blue(overlay.getBlue().clone()); + } + if (overlay.getSchema() != null) { + merged.schema(overlay.getSchema().clone()); + } + if (overlay.getName() != null) { + merged.name(overlay.getName()); + } + if (overlay.getDescription() != null) { + merged.description(overlay.getDescription()); + } + if (overlay.getMergePolicy() != null) { + merged.mergePolicy(overlay.getMergePolicy()); + } + if (overlay.getPreviousBlueId() != null) { + merged.previousBlueId(overlay.getPreviousBlueId()); + } + if (overlay.getPosition() != null) { + merged.position(overlay.getPosition()); + } + return freezePatchValue(merged); + } + + private boolean isMergeableObject(FrozenNode node) { + return node != null + && node.getValue() == null + && !node.hasItems() + && !node.isReferenceOnly() + && node.getPreviousBlueId() == null; } private FrozenNode read(FrozenNode node, List segments, boolean beforeAdd) { diff --git a/src/main/java/blue/language/snapshot/FrozenNodeToBlueIdInput.java b/src/main/java/blue/language/snapshot/FrozenNodeToBlueIdInput.java index c61ac25..244b9d6 100644 --- a/src/main/java/blue/language/snapshot/FrozenNodeToBlueIdInput.java +++ b/src/main/java/blue/language/snapshot/FrozenNodeToBlueIdInput.java @@ -77,6 +77,10 @@ private static Object get(FrozenNode node, String path, Context context, int lis } } + if (items != null && isPayloadOnlyList(node)) { + return items; + } + Map result = new LinkedHashMap<>(); if (node.getName() != null) { result.put(OBJECT_NAME, node.getName()); @@ -134,6 +138,25 @@ private static Object get(FrozenNode node, String path, Context context, int lis return result; } + private static boolean isPayloadOnlyList(FrozenNode node) { + return node.getItems() != null + && node.getName() == null + && node.getDescription() == null + && node.getType() == null + && node.getItemType() == null + && node.getKeyType() == null + && node.getValueType() == null + && node.getValue() == null + && node.getProperties() == null + && node.getContracts() == null + && node.getReferenceBlueId() == null + && node.getSchema() == null + && node.getMergePolicy() == null + && node.getPreviousBlueId() == null + && node.getPosition() == null + && node.getBlue() == null; + } + private static void validateBlueIdInput(FrozenNode node, String path, Context context, int listIndex) { if (node == null) { throw new IllegalArgumentException("BlueId input must not contain null nodes. Path: " + path); diff --git a/src/main/java/blue/language/utils/BlueNumbers.java b/src/main/java/blue/language/utils/BlueNumbers.java index 2d80e4e..34dc026 100644 --- a/src/main/java/blue/language/utils/BlueNumbers.java +++ b/src/main/java/blue/language/utils/BlueNumbers.java @@ -27,4 +27,88 @@ public static BigDecimal toCanonicalDoubleValue(Object value) { } return BigDecimal.valueOf(doubleValue); } + + public static boolean isExactBinary64Multiple(Object value, BigDecimal multipleOf) { + if (multipleOf == null) { + return true; + } + double valueDouble = toDouble(value); + double multipleDouble = toDouble(multipleOf); + if (multipleDouble == 0.0d || !Double.isFinite(multipleDouble)) { + throw new IllegalArgumentException("Double multipleOf must be finite and non-zero."); + } + Binary64Rational valueRational = Binary64Rational.fromDouble(valueDouble); + Binary64Rational multipleRational = Binary64Rational.fromDouble(multipleDouble); + return valueRational.dividedByIsInteger(multipleRational); + } + + private static double toDouble(Object value) { + double result; + if (value instanceof BigDecimal) { + result = ((BigDecimal) value).doubleValue(); + } else if (value instanceof BigInteger) { + result = ((BigInteger) value).doubleValue(); + } else if (value instanceof Number) { + result = ((Number) value).doubleValue(); + } else { + throw new IllegalArgumentException("Double value must be numeric: " + value); + } + if (!Double.isFinite(result)) { + throw new IllegalArgumentException("Double value must be finite."); + } + return result; + } + + private static final class Binary64Rational { + private final BigInteger numerator; + private final BigInteger denominator; + + private Binary64Rational(BigInteger numerator, BigInteger denominator) { + if (denominator.signum() <= 0) { + throw new IllegalArgumentException("denominator must be positive"); + } + BigInteger gcd = numerator.abs().gcd(denominator); + this.numerator = numerator.divide(gcd); + this.denominator = denominator.divide(gcd); + } + + private static Binary64Rational fromDouble(double value) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("Double value must be finite."); + } + if (value == 0.0d) { + return new Binary64Rational(BigInteger.ZERO, BigInteger.ONE); + } + long bits = Double.doubleToLongBits(value); + boolean negative = (bits & (1L << 63)) != 0; + int exponentBits = (int) ((bits >>> 52) & 0x7ffL); + long fraction = bits & 0x000f_ffff_ffff_ffffL; + + BigInteger significand; + int exponent; + if (exponentBits == 0) { + significand = BigInteger.valueOf(fraction); + exponent = -1074; + } else { + significand = BigInteger.valueOf((1L << 52) | fraction); + exponent = exponentBits - 1023 - 52; + } + if (negative) { + significand = significand.negate(); + } + if (exponent >= 0) { + return new Binary64Rational(significand.shiftLeft(exponent), BigInteger.ONE); + } + return new Binary64Rational(significand, BigInteger.ONE.shiftLeft(-exponent)); + } + + private boolean dividedByIsInteger(Binary64Rational divisor) { + if (divisor.numerator.signum() == 0) { + throw new IllegalArgumentException("Division by zero rational."); + } + BigInteger quotientNumerator = numerator.multiply(divisor.denominator); + BigInteger quotientDenominator = denominator.multiply(divisor.numerator).abs(); + return quotientNumerator.remainder(quotientDenominator).signum() == 0; + } + } } diff --git a/src/main/java/blue/language/utils/CircularBlueIdCalculator.java b/src/main/java/blue/language/utils/CircularBlueIdCalculator.java index c161488..7625cd9 100644 --- a/src/main/java/blue/language/utils/CircularBlueIdCalculator.java +++ b/src/main/java/blue/language/utils/CircularBlueIdCalculator.java @@ -37,6 +37,7 @@ public static List calculateCircularSetBlueIds(List documents) { indexedNodes.add(new IndexedNode(i, documents.get(i), BlueIdCalculator.calculateBlueIdAllowingCyclicPlaceholders(preliminary))); } + rejectDuplicatePreliminaryInputs(indexedNodes); indexedNodes.sort(Comparator .comparing((IndexedNode indexedNode) -> indexedNode.preliminaryBlueId) @@ -65,6 +66,19 @@ public static List calculateCircularSetBlueIds(List documents) { return result; } + private static void rejectDuplicatePreliminaryInputs(List indexedNodes) { + Map firstIndexByPreliminaryBlueId = new HashMap<>(); + for (IndexedNode indexedNode : indexedNodes) { + Integer firstIndex = firstIndexByPreliminaryBlueId.putIfAbsent( + indexedNode.preliminaryBlueId, + indexedNode.originalIndex); + if (firstIndex != null) { + throw new IllegalArgumentException("Duplicate preliminary cyclic BlueId input for members " + + firstIndex + " and " + indexedNode.originalIndex + "."); + } + } + } + private static void validateMultiDocumentReferences(List references, int documentCount) { for (ThisReference reference : references) { Matcher matcher = THIS_INDEX_REFERENCE_PATTERN.matcher(reference.value); diff --git a/src/main/java/blue/language/utils/FrozenTypeMatcher.java b/src/main/java/blue/language/utils/FrozenTypeMatcher.java index 970dc07..d92e404 100644 --- a/src/main/java/blue/language/utils/FrozenTypeMatcher.java +++ b/src/main/java/blue/language/utils/FrozenTypeMatcher.java @@ -469,65 +469,93 @@ private boolean verifyRequired(Schema schema, FrozenNode node) { private boolean verifyMinLength(Schema schema, FrozenNode node) { BigInteger minLength = schema.getMinLengthExact(); Object value = node.getValue(); - return minLength == null || !(value instanceof String) - || BigInteger.valueOf(((String) value).codePointCount(0, ((String) value).length())).compareTo(minLength) >= 0; + if (minLength == null || !hasPayload(node)) { + return true; + } + return value instanceof String + && BigInteger.valueOf(((String) value).codePointCount(0, ((String) value).length())).compareTo(minLength) >= 0; } private boolean verifyMaxLength(Schema schema, FrozenNode node) { BigInteger maxLength = schema.getMaxLengthExact(); Object value = node.getValue(); - return maxLength == null || !(value instanceof String) - || BigInteger.valueOf(((String) value).codePointCount(0, ((String) value).length())).compareTo(maxLength) <= 0; + if (maxLength == null || !hasPayload(node)) { + return true; + } + return value instanceof String + && BigInteger.valueOf(((String) value).codePointCount(0, ((String) value).length())).compareTo(maxLength) <= 0; } private boolean verifyMinimum(Schema schema, FrozenNode node) { - return compareNumber(node.getValue(), schema.getMinimumValue()) >= 0; + return compareNumber(node, schema.getMinimumValue()) >= 0; } private boolean verifyMaximum(Schema schema, FrozenNode node) { - return compareNumber(node.getValue(), schema.getMaximumValue()) <= 0; + return compareNumber(node, schema.getMaximumValue()) <= 0; } private boolean verifyExclusiveMinimum(Schema schema, FrozenNode node) { return schema.getExclusiveMinimumValue() == null - || compareNumber(node.getValue(), schema.getExclusiveMinimumValue()) > 0; + || compareNumber(node, schema.getExclusiveMinimumValue()) > 0; } private boolean verifyExclusiveMaximum(Schema schema, FrozenNode node) { return schema.getExclusiveMaximumValue() == null - || compareNumber(node.getValue(), schema.getExclusiveMaximumValue()) < 0; + || compareNumber(node, schema.getExclusiveMaximumValue()) < 0; } private boolean verifyMultipleOf(Schema schema, FrozenNode node) { BigDecimal multipleOf = schema.getMultipleOfValue(); Object value = node.getValue(); - if (multipleOf == null || !(value instanceof Number)) { + if (multipleOf == null || !hasPayload(node)) { return true; } - return numberValue(value).remainder(multipleOf).compareTo(BigDecimal.ZERO) == 0; + return value instanceof Number && BlueNumbers.isExactBinary64Multiple(value, multipleOf); } - private int compareNumber(Object value, BigDecimal bound) { - if (bound == null || !(value instanceof Number)) { + private int compareNumber(FrozenNode node, BigDecimal bound) { + Object value = node.getValue(); + if (bound == null || !hasPayload(node)) { return 0; } + if (!(value instanceof Number)) { + throw new IllegalArgumentException("numeric schema keyword applies to wrong kind"); + } return numberValue(value).compareTo(bound); } private boolean verifyMinItems(Schema schema, FrozenNode node) { BigInteger minItems = schema.getMinItemsExact(); + if (minItems == null || !hasPayload(node)) { + return true; + } + if (node.getValue() != null || (node.getProperties() != null && !node.getProperties().isEmpty())) { + return false; + } int size = node.getItems() != null ? node.getItems().size() : 0; - return minItems == null || BigInteger.valueOf(size).compareTo(minItems) >= 0; + return BigInteger.valueOf(size).compareTo(minItems) >= 0; } private boolean verifyMaxItems(Schema schema, FrozenNode node) { BigInteger maxItems = schema.getMaxItemsExact(); + if (maxItems == null || !hasPayload(node)) { + return true; + } + if (node.getValue() != null || (node.getProperties() != null && !node.getProperties().isEmpty())) { + return false; + } int size = node.getItems() != null ? node.getItems().size() : 0; - return maxItems == null || BigInteger.valueOf(size).compareTo(maxItems) <= 0; + return BigInteger.valueOf(size).compareTo(maxItems) <= 0; } private boolean verifyUniqueItems(Schema schema, FrozenNode node) { - if (!Boolean.TRUE.equals(schema.getUniqueItemsValue()) || node.getItems() == null) { + if (!Boolean.TRUE.equals(schema.getUniqueItemsValue()) || !hasPayload(node)) { + return true; + } + if (node.getValue() != null || (node.getProperties() != null && !node.getProperties().isEmpty())) { + return false; + } + if (node.getItems() == null) { return true; } Set itemIds = new HashSet<>(); @@ -541,14 +569,26 @@ private boolean verifyUniqueItems(Schema schema, FrozenNode node) { private boolean verifyMinFields(Schema schema, FrozenNode node) { BigInteger minFields = schema.getMinFieldsExact(); + if (minFields == null || !hasPayload(node)) { + return true; + } + if (node.getValue() != null || node.getItems() != null) { + return false; + } int size = node.getProperties() != null ? node.getProperties().size() : 0; - return minFields == null || BigInteger.valueOf(size).compareTo(minFields) >= 0; + return BigInteger.valueOf(size).compareTo(minFields) >= 0; } private boolean verifyMaxFields(Schema schema, FrozenNode node) { BigInteger maxFields = schema.getMaxFieldsExact(); + if (maxFields == null || !hasPayload(node)) { + return true; + } + if (node.getValue() != null || node.getItems() != null) { + return false; + } int size = node.getProperties() != null ? node.getProperties().size() : 0; - return maxFields == null || BigInteger.valueOf(size).compareTo(maxFields) <= 0; + return BigInteger.valueOf(size).compareTo(maxFields) <= 0; } private boolean verifyEnum(Schema schema, FrozenNode node) { @@ -556,6 +596,9 @@ private boolean verifyEnum(Schema schema, FrozenNode node) { if (enumValues == null) { return true; } + if (node.getValue() == null) { + return !hasPayload(node); + } String nodeBlueId = comparableBlueId(node); for (Node enumValue : enumValues) { Node comparable = enumValue.clone(); @@ -671,18 +714,23 @@ private FrozenNode coreType(String blueId) { if (cached != null) { return cached; } - FrozenNode core = FrozenNode.fromResolvedNode(new Node() - .name(CORE_TYPE_BLUE_ID_TO_NAME_MAP.get(blueId)) - .blueId(blueId)); + FrozenNode core = FrozenNode.fromResolvedNode(new Node().blueId(blueId)); resolvedReferenceCache.put(blueId, core); return core; } private boolean sameType(FrozenNode left, FrozenNode right) { - if (typeIdentity(left).equals(typeIdentity(right))) { + String leftIdentity = typeIdentity(left); + String rightIdentity = typeIdentity(right); + if (leftIdentity.equals(rightIdentity)) { return true; } - return typeCompatibilityIdentity(left).equals(typeCompatibilityIdentity(right)); + String leftCompatibility = typeCompatibilityIdentity(left); + String rightCompatibility = typeCompatibilityIdentity(right); + if (CORE_TYPE_BLUE_IDS.contains(leftCompatibility) || CORE_TYPE_BLUE_IDS.contains(rightCompatibility)) { + return leftCompatibility.equals(rightCompatibility); + } + return leftCompatibility.equals(rightCompatibility); } private String typeIdentity(FrozenNode type) { @@ -694,6 +742,10 @@ private String typeCompatibilityIdentity(FrozenNode type) { if (resolved == null) { return typeIdentity(type); } + String identityBlueId = typeIdentity(resolved); + if (CORE_TYPE_BLUE_IDS.contains(identityBlueId)) { + return identityBlueId; + } String cacheKey = typeIdentity(resolved) + "|" + resolved.blueId(); String cached = typeCompatibilityIdentityCache.get(cacheKey); if (cached != null) { diff --git a/src/main/java/blue/language/utils/NodeToBlueIdInput.java b/src/main/java/blue/language/utils/NodeToBlueIdInput.java index 82dcd5e..963e06e 100644 --- a/src/main/java/blue/language/utils/NodeToBlueIdInput.java +++ b/src/main/java/blue/language/utils/NodeToBlueIdInput.java @@ -125,6 +125,10 @@ private static Object get(Node node, String path, Context context, int listIndex } } + if (items != null && isPayloadOnlyList(node)) { + return items; + } + Map result = new LinkedHashMap<>(); if (node.getName() != null) result.put(OBJECT_NAME, node.getName()); @@ -173,6 +177,25 @@ private static Object get(Node node, String path, Context context, int listIndex return result; } + private static boolean isPayloadOnlyList(Node node) { + return node.getItems() != null + && node.getName() == null + && node.getDescription() == null + && node.getType() == null + && node.getItemType() == null + && node.getKeyType() == null + && node.getValueType() == null + && node.getValue() == 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; + } + private static String validateReferenceBlueId(String blueId, String path, boolean allowCyclicPlaceholders) { if (allowCyclicPlaceholders && BlueIds.isCyclicCalculationPlaceholder(blueId)) { return blueId; diff --git a/src/main/java/blue/language/utils/Properties.java b/src/main/java/blue/language/utils/Properties.java index 356e2ea..fe2cdf7 100644 --- a/src/main/java/blue/language/utils/Properties.java +++ b/src/main/java/blue/language/utils/Properties.java @@ -39,12 +39,12 @@ public class Properties { Arrays.asList(TEXT_TYPE, DOUBLE_TYPE, INTEGER_TYPE, BOOLEAN_TYPE, LIST_TYPE, DICTIONARY_TYPE); - public static final String TEXT_TYPE_BLUE_ID = "DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K"; - public static final String DOUBLE_TYPE_BLUE_ID = "7pwXmXYCJtWnd348c2JQGBkm9C4renmZRwxbfaypsx5y"; - public static final String INTEGER_TYPE_BLUE_ID = "5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1"; - public static final String BOOLEAN_TYPE_BLUE_ID = "4EzhSubEimSQD3zrYHRtobfPPWntUuhEz8YcdxHsi12u"; - public static final String LIST_TYPE_BLUE_ID = "6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY"; - public static final String DICTIONARY_TYPE_BLUE_ID = "G7fBT9PSod1RfHLHkpafAGBDVAJMrMhAMY51ERcyXNrj"; + public static final String TEXT_TYPE_BLUE_ID = "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC"; + public static final String DOUBLE_TYPE_BLUE_ID = "9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ"; + public static final String INTEGER_TYPE_BLUE_ID = "E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq"; + public static final String BOOLEAN_TYPE_BLUE_ID = "AwvXD961fmnmqcSQhjMA7r15HpVh39cefb6ZTyUz2Fm2"; + public static final String LIST_TYPE_BLUE_ID = "8DSFoWG9MqRSUhStqoPLrwVQiYByRh18NWbDEarN8MKF"; + public static final String DICTIONARY_TYPE_BLUE_ID = "Efkz9D1ARMM7rU43w3rDNVqat1naS6qXKCqP4eHin3yG"; public static final List BASIC_TYPE_BLUE_IDS = Arrays.asList(TEXT_TYPE_BLUE_ID, DOUBLE_TYPE_BLUE_ID, INTEGER_TYPE_BLUE_ID, BOOLEAN_TYPE_BLUE_ID); public static final List CORE_TYPE_BLUE_IDS = Arrays.asList(TEXT_TYPE_BLUE_ID, DOUBLE_TYPE_BLUE_ID, INTEGER_TYPE_BLUE_ID, BOOLEAN_TYPE_BLUE_ID, LIST_TYPE_BLUE_ID, DICTIONARY_TYPE_BLUE_ID); diff --git a/src/main/java/blue/language/utils/Types.java b/src/main/java/blue/language/utils/Types.java index d58cba6..93216d3 100644 --- a/src/main/java/blue/language/utils/Types.java +++ b/src/main/java/blue/language/utils/Types.java @@ -27,6 +27,9 @@ public static boolean isSubtype(Node subtype, Node supertype, NodeProvider nodeP String supertypeBlueId = typeBlueId(supertype); if (sameType(subtype, supertype, subtypeBlueId, supertypeBlueId)) return true; + if (isCoreTypeIdentity(supertype, supertypeBlueId) && isAnonymousCoreAlias(subtype)) { + return false; + } if (CORE_TYPE_BLUE_IDS.contains(subtypeBlueId)) { Node current = supertype; @@ -79,7 +82,36 @@ private static boolean sameType(Node left, Node right, String leftBlueId, String if (leftBlueId.equals(rightBlueId)) { return true; } - return compatibilityBlueId(left).equals(compatibilityBlueId(right)); + String leftCompatibility = compatibilityBlueId(left); + String rightCompatibility = compatibilityBlueId(right); + if (CORE_TYPE_BLUE_IDS.contains(leftCompatibility) || CORE_TYPE_BLUE_IDS.contains(rightCompatibility)) { + return leftCompatibility.equals(rightCompatibility); + } + return leftCompatibility.equals(rightCompatibility); + } + + private static boolean isCoreTypeIdentity(Node node, String blueId) { + return CORE_TYPE_BLUE_IDS.contains(blueId) || isBareCoreTypeName(node); + } + + private static boolean isAnonymousCoreAlias(Node node) { + return node.getName() == null + && node.getDescription() == null + && node.getBlueId() == null + && node.getType() != null + && node.getItemType() == null + && node.getKeyType() == null + && node.getValueType() == null + && node.getValue() == null + && node.getItems() == null + && node.getProperties() == null + && node.getContracts() == null + && node.getSchema() == null + && node.getMergePolicy() == null + && node.getPreviousBlueId() == null + && node.getPosition() == null + && node.getBlue() == null + && CORE_TYPE_BLUE_IDS.contains(typeBlueId(node.getType())); } private static String typeBlueId(Node node) { @@ -174,17 +206,16 @@ private static void stripSchemaLabels(blue.language.model.Schema schema) { } public static boolean isSubtypeOfBasicType(Node type, NodeProvider nodeProvider) { - return BASIC_TYPES.stream() - .map(basicTypeName -> new Node().name(basicTypeName)) + return BASIC_TYPE_BLUE_IDS.stream() + .map(blueId -> new Node().blueId(blueId)) .anyMatch(basicTypeNode -> isSubtype(type, basicTypeNode, nodeProvider)); } public static String findBasicTypeName(Node type, NodeProvider nodeProvider) { - return BASIC_TYPES.stream() - .map(basicTypeName -> new Node().name(basicTypeName)) - .filter(basicTypeNode -> Types.isSubtype(type, basicTypeNode, nodeProvider)) + return BASIC_TYPE_BLUE_IDS.stream() + .filter(blueId -> Types.isSubtype(type, new Node().blueId(blueId), nodeProvider)) .findFirst() - .map(Node::getName) + .map(CORE_TYPE_BLUE_ID_TO_NAME_MAP::get) .orElseThrow(() -> new IllegalArgumentException("Cannot determine the basic type for node of type \"" + type.getName() + "\".")); } diff --git a/src/main/resources/registry/blue-contracts-1.0/Channel.blue b/src/main/resources/registry/blue-contracts-1.0/Channel.blue new file mode 100644 index 0000000..e289a50 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/Channel.blue @@ -0,0 +1,14 @@ +name: Channel +type: Contract +description: > + Runtime contract role for event entry points within a scope. A Channel + evaluates an incoming event or processor-managed delivery and either rejects + it or accepts it by producing one channelized payload for same-scope + handlers bound to that channel key. A Channel may consume gas and may request + termination only through processor-defined interfaces. A Channel must not + directly mutate the selected document. Processor-managed channel subtypes are + fed only by the processor and are never directly entered by external events. +event: + description: > + Optional channel-specific matcher or matcher configuration. The meaning is + defined by the concrete channel type. diff --git a/src/main/resources/registry/blue-contracts-1.0/ChannelEventCheckpoint.blue b/src/main/resources/registry/blue-contracts-1.0/ChannelEventCheckpoint.blue new file mode 100644 index 0000000..e1e3305 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/ChannelEventCheckpoint.blue @@ -0,0 +1,24 @@ +name: Channel Event Checkpoint +type: Marker +description: > + Required processor-managed marker at contracts/checkpoint. It stores + idempotency state for external channel deliveries. Checkpoints are never used + for processor-managed Document Update, Triggered Event, Lifecycle Event, or + Embedded Node channels. The processor creates this marker lazily when an + external channel accepts an event and no checkpoint exists. It updates + lastEvents by Direct Write after successful external channel processing. + Checkpoint Direct Writes do not emit Document Update cascades. By default, + lastEvents stores the normalized checkpoint subject for each external + channel's raw contract-map key, and newness is determined by the channel's + effective checkpointIdentityMode. Pointer escaping is used only when writing + the member by Direct Write; it is not part of the stored key. +lastEvents: + type: Dictionary + keyType: + type: Text + description: > + Required dictionary keyed by raw external-channel contract-map key. Each + value is the previous normalized checkpoint subject for that external + channel. The default subject is the preprocessed incoming event node. + schema: + required: true diff --git a/src/main/resources/registry/blue-contracts-1.0/Contract.blue b/src/main/resources/registry/blue-contracts-1.0/Contract.blue new file mode 100644 index 0000000..dfaf172 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/Contract.blue @@ -0,0 +1,17 @@ +name: Contract +description: > + Base Blue Contracts and Processor 1.0 runtime type for executable or + processor-interpreted declarations under an active scope's contracts map. + A Contract is scope-local, identity-bearing Blue content. The processor + discovers materialized contract entries in the selected document, resolves + each entry far enough to identify its effective runtime type BlueId, and + either executes supported behavior or applies must-understand and fatal + rules. Contract entries are sorted by effective order and contract-map key + when ordering is required. A Contract by itself has no executable behavior; + concrete subtypes define Channel, Handler, Marker, or extension semantics. +order: + type: Integer + description: > + Optional deterministic sort key within a scope. Missing order is treated + as 0. Ordering compares order first, ascending, then contract-map key in + lexicographic Unicode code-point order. diff --git a/src/main/resources/registry/blue-contracts-1.0/ContractExecutionResult.blue b/src/main/resources/registry/blue-contracts-1.0/ContractExecutionResult.blue new file mode 100644 index 0000000..4966653 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/ContractExecutionResult.blue @@ -0,0 +1,37 @@ +name: Contract Execution Result +description: > + Abstract processor result shape used to normalize effects returned by a + supported handler or by a supported channel type that explicitly permits + channel results. In Blue Contracts 1.0 core, patches and Triggered emissions + are handler effects. External channels must not return patches or Triggered + events unless a supported extension explicitly grants that capability. When + a result is applied, the processor applies explicit gas first, then patches + in order with immediate cascades, then emitted events in order, then a + requested termination. Invalid present result fields cause runtime fatal + termination before any effects from that result are applied, except for + overhead already charged. +patches: + type: List + itemType: + type: Json Patch Entry + description: > + Optional list of patch entries. Missing is equivalent to an empty list. + Patches are applied in list order. Each successful patch triggers its + Document Update cascade before the next patch. +triggeredEvents: + type: List + description: > + Optional list of Blue event nodes to record and enqueue as Triggered + events after all patches from the same result are applied. Missing is + equivalent to an empty list. +gasConsumed: + type: Integer + description: > + Optional non-negative explicit gas consumed by the contract. Missing is + equivalent to 0. Negative gas is invalid and causes runtime fatal + termination. +termination: + description: > + Optional termination request. If present, it requests graceful or fatal + termination after gas, patches, and emitted events from the same result + have been processed in the required order. diff --git a/src/main/resources/registry/blue-contracts-1.0/DocumentProcessingFatalError.blue b/src/main/resources/registry/blue-contracts-1.0/DocumentProcessingFatalError.blue new file mode 100644 index 0000000..91b5f9a --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/DocumentProcessingFatalError.blue @@ -0,0 +1,11 @@ +name: Document Processing Fatal Error +description: > + Processor-emitted root outbox event appended when root processing terminates + fatally. It is appended after Document Processing Terminated for the same + root termination sequence. It is outbox-only: it is not delivered to + Lifecycle Event Channels, is not recorded as bridgeable, and is not placed in + the Triggered FIFO. +reason: + type: Text + description: > + Optional deterministic fatal error reason. diff --git a/src/main/resources/registry/blue-contracts-1.0/DocumentProcessingInitiated.blue b/src/main/resources/registry/blue-contracts-1.0/DocumentProcessingInitiated.blue new file mode 100644 index 0000000..53e79e5 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/DocumentProcessingInitiated.blue @@ -0,0 +1,15 @@ +name: Document Processing Initiated +description: > + Processor-emitted lifecycle event published at a scope before the Processing + Initialized Marker is written. It represents first-run initialization of + that scope for the current selected document state. At root, this event is + also recorded in the root outbox. At non-root scopes, it is bridgeable to a + parent Embedded Node Channel. The documentId field is the pre-initialization + Content BlueId of the scope subtree. +documentId: + type: Text + description: > + Required BlueId string for the pre-initialization Content BlueId of the + scope subtree. + schema: + required: true diff --git a/src/main/resources/registry/blue-contracts-1.0/DocumentProcessingTerminated.blue b/src/main/resources/registry/blue-contracts-1.0/DocumentProcessingTerminated.blue new file mode 100644 index 0000000..b8263cc --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/DocumentProcessingTerminated.blue @@ -0,0 +1,18 @@ +name: Document Processing Terminated +description: > + Processor-emitted lifecycle event published at a scope when that scope + terminates gracefully or fatally. It is delivered through Lifecycle Event + Channels, recorded as bridgeable for parent Embedded Node Channels, and, at + root, included in the root outbox. For a root fatal termination, this event + appears before Document Processing Fatal Error. +cause: + type: Text + description: > + Required termination cause: fatal or graceful. + schema: + required: true + enum: [fatal, graceful] +reason: + type: Text + description: > + Optional deterministic reason for termination. diff --git a/src/main/resources/registry/blue-contracts-1.0/DocumentUpdate.blue b/src/main/resources/registry/blue-contracts-1.0/DocumentUpdate.blue new file mode 100644 index 0000000..3d251e9 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/DocumentUpdate.blue @@ -0,0 +1,29 @@ +name: Document Update +description: > + Processor-emitted event delivered through Document Update Channels after each + successful runtime patch. One Document Update payload is created per + participating receiving scope for that patch. The path is relative to the + receiving scope. before and after are immutable snapshots of the changed + path before and after the patch, using null when the changed path was absent + or removed. All handlers at the same receiving scope for the same patch see + the same immutable payload object. +op: + type: Text + description: > + Required operation that caused the update: add, replace, or remove. + schema: + required: true + enum: [add, replace, remove] +path: + type: Text + description: > + Required path of the changed node, relative to the receiving scope. / means + the receiving scope root itself. + schema: + required: true +before: + description: > + Snapshot at the changed path before the patch, or null when absent. +after: + description: > + Snapshot at the changed path after the patch, or null when removed. diff --git a/src/main/resources/registry/blue-contracts-1.0/DocumentUpdateChannel.blue b/src/main/resources/registry/blue-contracts-1.0/DocumentUpdateChannel.blue new file mode 100644 index 0000000..322ce05 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/DocumentUpdateChannel.blue @@ -0,0 +1,20 @@ +name: Document Update Channel +type: Channel +description: > + Processor-managed channel fed after each successful runtime patch. For every + successful patch, the processor discovers matching Document Update Channels + from the post-patch selected document and delivers one Document Update + payload per participating scope, from the patch origin scope toward root. A + Document Update Channel matches when the absolute changed path is + descendant-or-equal to the channel path resolved against the receiving scope. + The channel is never checkpoint-gated and is never entered directly by + external events. Triggered FIFO is not drained during Document Update + cascades. +path: + type: Text + description: > + Required scope-relative Blue Runtime Pointer watched by this channel. + The channel matches patches whose absolute changed path is + descendant-or-equal to ABS(scope, path). + schema: + required: true diff --git a/src/main/resources/registry/blue-contracts-1.0/EmbeddedNodeChannel.blue b/src/main/resources/registry/blue-contracts-1.0/EmbeddedNodeChannel.blue new file mode 100644 index 0000000..b81d62c --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/EmbeddedNodeChannel.blue @@ -0,0 +1,18 @@ +name: Embedded Node Channel +type: Channel +description: > + Processor-managed channel in a parent scope that bridges recorded emissions + from a processed embedded child scope. Bridging occurs after the parent has + handled the external event and before the parent drains its Triggered FIFO. + Child emissions are delivered in the order recorded by the child, and child + scopes are bridged in the parent invocation's processed-path insertion order. + Bridge gas is charged only when an emission is actually delivered to at + least one matching Embedded Node Channel. +childPath: + type: Text + description: > + Required scope-relative Blue Runtime Pointer identifying the embedded child + root whose emissions this channel receives. The resolved child path is + compared with the processed child scope path. + schema: + required: true diff --git a/src/main/resources/registry/blue-contracts-1.0/Handler.blue b/src/main/resources/registry/blue-contracts-1.0/Handler.blue new file mode 100644 index 0000000..a800dcf --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/Handler.blue @@ -0,0 +1,22 @@ +name: Handler +type: Contract +description: > + Runtime contract role for deterministic logic bound to exactly one channel + in the same scope. A Handler is eligible only for deliveries produced by the + same-scope channel named by its channel field. A Handler may request patches, + emit Blue event nodes, consume non-negative gas, or request termination. It + has no other permitted observable side effects. For a given document + snapshot, channelized payload, handler contract content, and allowed context, + a Handler must produce deterministic results. +channel: + type: Text + description: > + Required same-scope contract-map key of the channel this handler binds to. + Handlers do not bind to channels in parent, child, embedded, or referenced + nodes. + schema: + required: true +event: + description: > + Optional handler-specific matcher for the channelized payload. The meaning + is defined by the concrete handler type or extension runtime. diff --git a/src/main/resources/registry/blue-contracts-1.0/JsonPatchEntry.blue b/src/main/resources/registry/blue-contracts-1.0/JsonPatchEntry.blue new file mode 100644 index 0000000..1a7245a --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/JsonPatchEntry.blue @@ -0,0 +1,32 @@ +name: Json Patch Entry +description: > + Blue Contracts and Processor 1.0 runtime patch request produced by handlers. + A Json Patch Entry describes one deterministic mutation request against the + selected document. Only add, replace, and remove are supported. The path is + a Blue Runtime Pointer and must not target the document root. Despite its + historical name, Json Patch Entry is not full RFC 6902; it uses Blue-specific + upsert, auto-materialization, runtime insertion normalization, and post-patch + type-soundness rules. The val field is required for add and replace and must + be absent for remove. Patches are applied in result order; each successful + patch triggers its full Document Update cascade before the next patch is + applied. Field is named val, not value, because value is Blue's scalar + payload wrapper. +op: + type: Text + description: > + Required patch operation. Allowed values are add, replace, and remove. + schema: + required: true + enum: [add, replace, remove] +path: + type: Text + description: > + Required absolute Blue Runtime Pointer identifying the mutation target. + The empty string is invalid. The root pointer / is not a valid runtime + patch target for handlers or channels. + schema: + required: true +val: + description: > + Patch payload for add and replace. It may be any valid Blue node. It must + be absent for remove. diff --git a/src/main/resources/registry/blue-contracts-1.0/LifecycleEventChannel.blue b/src/main/resources/registry/blue-contracts-1.0/LifecycleEventChannel.blue new file mode 100644 index 0000000..0ee1256 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/LifecycleEventChannel.blue @@ -0,0 +1,10 @@ +name: Lifecycle Event Channel +type: Channel +description: > + Processor-managed channel for lifecycle events emitted by the processor at a + scope. Lifecycle events include Document Processing Initiated and Document + Processing Terminated. Lifecycle events are delivered through Lifecycle Event + Channels, recorded as bridgeable emissions for parent Embedded Node Channels, + and, at root, appended to the root outbox. Lifecycle events are not enqueued + into the Triggered FIFO unless a lifecycle handler explicitly emits a + Triggered event. diff --git a/src/main/resources/registry/blue-contracts-1.0/Marker.blue b/src/main/resources/registry/blue-contracts-1.0/Marker.blue new file mode 100644 index 0000000..229be8f --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/Marker.blue @@ -0,0 +1,9 @@ +name: Marker +type: Contract +description: > + Runtime contract role for processor-observed state or policy. Markers do not + run contract logic. The processor obeys supported marker semantics when a + supported marker appears at the correct reserved key. Unsupported marker + types in an active scope are subject to must-understand rules. Required + processor-managed markers have reserved keys under contracts and must not + appear under other keys. diff --git a/src/main/resources/registry/blue-contracts-1.0/ProcessEmbedded.blue b/src/main/resources/registry/blue-contracts-1.0/ProcessEmbedded.blue new file mode 100644 index 0000000..c25b2ba --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/ProcessEmbedded.blue @@ -0,0 +1,22 @@ +name: Process Embedded +type: Marker +description: > + Required processor-managed marker at contracts/embedded. It declares + embedded child scopes beneath the current scope. The processor reads paths + dynamically during embedded traversal, re-reads after each processed child, + processes each normalized child path at most once per parent invocation, and + rejects malformed, duplicate, self-root, or non-object embedded scope paths + according to the processor rules. Missing child paths are skipped and marked + processed for the current invocation. +paths: + type: List + itemType: + type: Text + description: > + Required list of scope-relative Blue Runtime Pointers identifying embedded + child roots. Each path must begin with /, must not be /, and must resolve + inside the current scope's pointer domain. Duplicate resolved child paths + are invalid. + schema: + required: true + uniqueItems: true diff --git a/src/main/resources/registry/blue-contracts-1.0/ProcessingInitializedMarker.blue b/src/main/resources/registry/blue-contracts-1.0/ProcessingInitializedMarker.blue new file mode 100644 index 0000000..6a91335 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/ProcessingInitializedMarker.blue @@ -0,0 +1,16 @@ +name: Processing Initialized Marker +type: Marker +description: > + Required processor-managed marker at contracts/initialized. It records that + a scope has completed first-run initialization. The processor publishes the + Document Processing Initiated lifecycle event before writing this marker. + The marker is written by a processor-managed patch that triggers the normal + Document Update cascade. The marker stores the pre-initialization Content + BlueId of the scope subtree as documentId. +documentId: + type: Text + description: > + Required BlueId string for the pre-initialization Content BlueId of the + scope subtree. The value must be a valid Blue Language BlueId string. + schema: + required: true diff --git a/src/main/resources/registry/blue-contracts-1.0/ProcessingTerminatedMarker.blue b/src/main/resources/registry/blue-contracts-1.0/ProcessingTerminatedMarker.blue new file mode 100644 index 0000000..744ac0c --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/ProcessingTerminatedMarker.blue @@ -0,0 +1,24 @@ +name: Processing Terminated Marker +type: Marker +description: > + Required processor-managed marker at contracts/terminated. It records final + runtime state for a scope. A scope with a valid pre-existing terminated + marker is inactive for processing: it incurs scope-entry gas when entered, + but it is not initialized, matched, bridged, drained, checkpointed, or run. + Termination markers are written by processor Direct Write and do not emit + Document Update cascades. An ancestor may replace or remove an embedded child + root containing this marker as a whole. +cause: + type: Text + description: > + Required termination cause. fatal means deterministic runtime fatal + termination. graceful means contract-requested non-error termination. + schema: + required: true + enum: [fatal, graceful] +reason: + type: Text + description: > + Optional human-readable deterministic reason supplied by the processor or + contract. It is content in the selected document and in emitted lifecycle + events when present. diff --git a/src/main/resources/registry/blue-contracts-1.0/TriggeredEventChannel.blue b/src/main/resources/registry/blue-contracts-1.0/TriggeredEventChannel.blue new file mode 100644 index 0000000..7099dcc --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/TriggeredEventChannel.blue @@ -0,0 +1,9 @@ +name: Triggered Event Channel +type: Channel +description: > + Processor-managed channel that drains events emitted into a scope's Triggered + FIFO. A scope drains its Triggered FIFO at most once per PROCESS invocation, + during the scope's FIFO phase. Triggered FIFO delivery does not occur during + Document Update cascades. If a scope has no Triggered Event Channel, emitted + events are still recorded and may be bridged to a parent, but they are not + locally delivered. diff --git a/src/main/resources/registry/blue-contracts-1.0/TypeGeneralizationPolicy.blue b/src/main/resources/registry/blue-contracts-1.0/TypeGeneralizationPolicy.blue new file mode 100644 index 0000000..043555f --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/TypeGeneralizationPolicy.blue @@ -0,0 +1,25 @@ +name: Type Generalization Policy +type: Marker +description: > + Optional processor-managed marker at contracts/generalization. It controls + whether post-patch type soundness may be restored by dynamic type + generalization in the current scope. If absent, the processor uses + defaultMode nearest-valid with no rules. Handlers and channels must not + patch this marker or its descendants in Blue Contracts and Processor 1.0. +defaultMode: + type: Text + description: > + Optional default generalization mode for paths not governed by a more + specific rule. Missing means nearest-valid. nearest-valid permits the + processor to choose the nearest valid permitted ancestor type. reject makes + a patch fatal when restoring soundness would require generalization. + schema: + enum: [nearest-valid, reject] +rules: + type: List + itemType: + type: Type Generalization Rule + description: > + Optional ordered list of path-specific generalization rules. The most + specific matching path wins; if two rules normalize to the same path, the + later rule in list order wins. diff --git a/src/main/resources/registry/blue-contracts-1.0/TypeGeneralizationRule.blue b/src/main/resources/registry/blue-contracts-1.0/TypeGeneralizationRule.blue new file mode 100644 index 0000000..3889a80 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/TypeGeneralizationRule.blue @@ -0,0 +1,25 @@ +name: Type Generalization Rule +description: > + Rule entry used by Type Generalization Policy. It governs a scope-relative + subtree path and can reject dynamic generalization or require the generated + type to remain equal to or a subtype of a declared floor type. +path: + type: Text + description: > + Required scope-relative Blue Runtime Pointer identifying the governed + subtree. The pointer is normalized against the scope containing the policy + marker before rule selection. + schema: + required: true +mode: + type: Text + description: > + Optional mode for this path. Missing means the policy defaultMode. reject + forbids generalization at the governed path. nearest-valid permits the + nearest valid permitted ancestor type. + schema: + enum: [nearest-valid, reject] +mustRemainSubtypeOf: + description: > + Optional type reference floor. If present, any generated type selected for + the governed path must be equal to or a subtype of this type. diff --git a/src/main/resources/registry/blue-contracts-1.0/manifest.yaml b/src/main/resources/registry/blue-contracts-1.0/manifest.yaml new file mode 100644 index 0000000..987a142 --- /dev/null +++ b/src/main/resources/registry/blue-contracts-1.0/manifest.yaml @@ -0,0 +1,89 @@ +specVersion: "1.0" +registryKind: Blue Contracts runtime type registry +publishedBy: Blue Contracts and Processor 1.0 +canonicalizationRule: Standard Blue Language 1.0 baseline preprocessing resolves symbolic core type aliases and runtime registry aliases before BlueId calculation. +conformanceFixturePackageIdentity: "sha256:2f197ca3bbdc41b75e772777cc48e51019754347e1bee26b5f3209b71d9bd9ca" +preprocessingEnvironment: + coreRegistry: blue-language-1.0 + runtimeRegistry: blue-contracts-1.0 +entries: + - key: Contract + path: Contract.blue + blueId: "6WrVQoSpKHUUg5HPrwjkVV6pxe4sdkyGnakMs8ayEGeF" + semanticDescriptionIdentityBearing: true + - key: JsonPatchEntry + path: JsonPatchEntry.blue + blueId: "61W96XosAp3DrEC7PuqLYtmF2A6ETpqH6qF2DgYwDq4c" + semanticDescriptionIdentityBearing: true + - key: ContractExecutionResult + path: ContractExecutionResult.blue + blueId: "AMtAXPmvumgz1GxKUU9uv3ncXiKMENvqq8AaLvD5LXhv" + semanticDescriptionIdentityBearing: true + - key: Channel + path: Channel.blue + blueId: "4FAZ94JPExNM4pn2ZhtdHa4CVP7uASmLNVrBy7aCG1p5" + semanticDescriptionIdentityBearing: true + - key: Handler + path: Handler.blue + blueId: "7X46P3Q6FJrogqKrBXTALpqzkieyyiQeatnqLvWzAPXE" + semanticDescriptionIdentityBearing: true + - key: Marker + path: Marker.blue + blueId: "6zqbYGDGrMv5ReuEsjyzyyjjuqVnqDZxtY7RsPXdBTNy" + semanticDescriptionIdentityBearing: true + - key: ProcessEmbedded + path: ProcessEmbedded.blue + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + semanticDescriptionIdentityBearing: true + - key: ProcessingInitializedMarker + path: ProcessingInitializedMarker.blue + blueId: "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q" + semanticDescriptionIdentityBearing: true + - key: ProcessingTerminatedMarker + path: ProcessingTerminatedMarker.blue + blueId: "GBDBthfshBFr4GQKUU1fmy4GnPL7q2y3as4deUWpuBtu" + semanticDescriptionIdentityBearing: true + - key: ChannelEventCheckpoint + path: ChannelEventCheckpoint.blue + blueId: "9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1" + semanticDescriptionIdentityBearing: true + - key: TypeGeneralizationPolicy + path: TypeGeneralizationPolicy.blue + blueId: "Fbenow6tanFHkWzKiDD8fGxminQswQ1FecMRakaCx2WX" + semanticDescriptionIdentityBearing: true + - key: TypeGeneralizationRule + path: TypeGeneralizationRule.blue + blueId: "7Vnmk8StjwY7e9mBNpACrn8oh3KZ7yQBjnXe5bLDWn4D" + semanticDescriptionIdentityBearing: true + - key: DocumentUpdateChannel + path: DocumentUpdateChannel.blue + blueId: "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o" + semanticDescriptionIdentityBearing: true + - key: TriggeredEventChannel + path: TriggeredEventChannel.blue + blueId: "5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ" + semanticDescriptionIdentityBearing: true + - key: LifecycleEventChannel + path: LifecycleEventChannel.blue + blueId: "2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ" + semanticDescriptionIdentityBearing: true + - key: EmbeddedNodeChannel + path: EmbeddedNodeChannel.blue + blueId: "H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i" + semanticDescriptionIdentityBearing: true + - key: DocumentUpdate + path: DocumentUpdate.blue + blueId: "7HEaG1SpBdsbVHsrwRTZSZGmpJUWHfFoEzecYWpjo1vm" + semanticDescriptionIdentityBearing: true + - key: DocumentProcessingInitiated + path: DocumentProcessingInitiated.blue + blueId: "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + semanticDescriptionIdentityBearing: true + - key: DocumentProcessingTerminated + path: DocumentProcessingTerminated.blue + blueId: "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" + semanticDescriptionIdentityBearing: true + - key: DocumentProcessingFatalError + path: DocumentProcessingFatalError.blue + blueId: "AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC" + semanticDescriptionIdentityBearing: true diff --git a/src/main/resources/registry/blue-language-1.0/Boolean.blue b/src/main/resources/registry/blue-language-1.0/Boolean.blue new file mode 100644 index 0000000..c86a464 --- /dev/null +++ b/src/main/resources/registry/blue-language-1.0/Boolean.blue @@ -0,0 +1,6 @@ +name: Boolean +description: >- + Core Blue Language 1.0 primitive scalar with exactly two values: true and + false. Blue Language defines no truthiness conversion for Boolean values. + Only the literal parsed boolean values true and false are Boolean values. + Applicable schema constraint is enum. diff --git a/src/main/resources/registry/blue-language-1.0/Dictionary.blue b/src/main/resources/registry/blue-language-1.0/Dictionary.blue new file mode 100644 index 0000000..1fd9b53 --- /dev/null +++ b/src/main/resources/registry/blue-language-1.0/Dictionary.blue @@ -0,0 +1,17 @@ +name: Dictionary +description: >- + Core Blue Language 1.0 object-map collection type. A Dictionary is encoded + as a Blue object node whose ordinary child fields represent direct keys + when those keys do not collide with reserved language fields. Direct object + encoding cannot represent data keys named name, description, type, itemType, + keyType, valueType, value, items, blueId, blue, schema, mergePolicy, + contracts, properties, or constraints. Direct object encoding cannot + represent reserved language keys as data keys. Applications needing + arbitrary keys use an escaped entry representation such as a list of { key, + val } entries. keyType is optional; if + omitted and no effective keyType is inherited, keys default to Text for + direct object encoding. For direct object encoding, keyType must resolve to + a scalar key type with a canonical textual form, such as Text, Integer, + Double, or Boolean. valueType is optional; if omitted and no effective + valueType is inherited, values may be any Blue node. Applicable schema + constraints are minFields and maxFields. diff --git a/src/main/resources/registry/blue-language-1.0/Double.blue b/src/main/resources/registry/blue-language-1.0/Double.blue new file mode 100644 index 0000000..9ee43a7 --- /dev/null +++ b/src/main/resources/registry/blue-language-1.0/Double.blue @@ -0,0 +1,12 @@ +name: Double +description: >- + Core Blue Language 1.0 primitive scalar for finite IEEE 754 binary64 + floating-point values. NaN, positive Infinity, and negative Infinity are + invalid Blue values. Double parsing uses round-to-nearest, ties-to-even + binary64 semantics; numeric tokens that overflow to Infinity or parse as NaN + are invalid. Source numeric tokens with a decimal point or exponent infer + Double when no explicit type is provided, even when their mathematical value + is integral. Negative zero and positive zero compare as the same numeric + value and canonicalize as JSON number zero, while the effective Double type + remains part of canonical BlueId input. Applicable schema constraints are + minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, and enum. diff --git a/src/main/resources/registry/blue-language-1.0/Integer.blue b/src/main/resources/registry/blue-language-1.0/Integer.blue new file mode 100644 index 0000000..eb37726 --- /dev/null +++ b/src/main/resources/registry/blue-language-1.0/Integer.blue @@ -0,0 +1,11 @@ +name: Integer +description: >- + Core Blue Language 1.0 primitive scalar for exact mathematical integer + values. Integer values are arbitrary precision in the language model. + Unquoted integer tokens are portable only in the safe JSON numeric integer + range [-9007199254740991, 9007199254740991]. Integer values outside that + range are represented as quoted canonical decimal text with explicit or + inherited effective Integer type. The canonical decimal text form uses an + optional leading minus sign followed by decimal digits, with no leading + zeros except the single digit zero. Applicable schema constraints are + minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, and enum. diff --git a/src/main/resources/registry/blue-language-1.0/List.blue b/src/main/resources/registry/blue-language-1.0/List.blue new file mode 100644 index 0000000..92e36ee --- /dev/null +++ b/src/main/resources/registry/blue-language-1.0/List.blue @@ -0,0 +1,14 @@ +name: List +description: >- + Core Blue Language 1.0 ordered collection type. Surface array form and + wrapped items form are equivalent authoring forms. Order and multiplicity + are preserved. List BlueId calculation uses a domain-separated streaming + fold over element BlueIds. itemType is optional; if omitted and no effective + itemType is inherited, elements are not constrained by itemType. If + mergePolicy is omitted and no effective mergePolicy is inherited, resolvers + assume positional. append-only forbids changes to the inherited prefix. + positional allows $pos overlays within the inherited prefix. $previous, + $pos, $replace, and $empty are recognized only at the top level of items + when the node's effective type is List. Source list null and empty object + elements normalize to $empty: true and are not deleted. Applicable schema + constraints are minItems, maxItems, and uniqueItems. diff --git a/src/main/resources/registry/blue-language-1.0/Text.blue b/src/main/resources/registry/blue-language-1.0/Text.blue new file mode 100644 index 0000000..42c40ee --- /dev/null +++ b/src/main/resources/registry/blue-language-1.0/Text.blue @@ -0,0 +1,9 @@ +name: Text +description: >- + Core Blue Language 1.0 primitive scalar representing Unicode text. Text + values are exact Unicode code-point sequences after parsing. Blue Language + performs no Unicode normalization, case folding, locale-sensitive collation, + whitespace normalization, or line-ending normalization by default. String + schema constraints minLength and maxLength count Unicode code points. The + empty string is valid unless restricted by schema. Applicable schema + constraints are minLength, maxLength, and enum. diff --git a/src/main/resources/registry/blue-language-1.0/manifest.yaml b/src/main/resources/registry/blue-language-1.0/manifest.yaml new file mode 100644 index 0000000..faf4313 --- /dev/null +++ b/src/main/resources/registry/blue-language-1.0/manifest.yaml @@ -0,0 +1,8 @@ +specVersion: "1.0" +entries: + Text: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC + Integer: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq + Double: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ + Boolean: AwvXD961fmnmqcSQhjMA7r15HpVh39cefb6ZTyUz2Fm2 + Dictionary: Efkz9D1ARMM7rU43w3rDNVqat1naS6qXKCqP4eHin3yG + List: 8DSFoWG9MqRSUhStqoPLrwVQiYByRh18NWbDEarN8MKF diff --git a/src/main/resources/transformation/DefaultBlue.blue b/src/main/resources/transformation/DefaultBlue.blue index 0bf8a35..0f6a958 100644 --- a/src/main/resources/transformation/DefaultBlue.blue +++ b/src/main/resources/transformation/DefaultBlue.blue @@ -1,11 +1,11 @@ - type: blueId: 53yFLQ3dpuGwa2svHubDyzyhYz9RQNmctiJRdi3gRYr7 mappings: - Text: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K - Double: 7pwXmXYCJtWnd348c2JQGBkm9C4renmZRwxbfaypsx5y - Integer: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 - Boolean: 4EzhSubEimSQD3zrYHRtobfPPWntUuhEz8YcdxHsi12u - List: 6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY - Dictionary: G7fBT9PSod1RfHLHkpafAGBDVAJMrMhAMY51ERcyXNrj + Text: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC + Double: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ + Integer: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq + Boolean: AwvXD961fmnmqcSQhjMA7r15HpVh39cefb6ZTyUz2Fm2 + List: 8DSFoWG9MqRSUhStqoPLrwVQiYByRh18NWbDEarN8MKF + Dictionary: Efkz9D1ARMM7rU43w3rDNVqat1naS6qXKCqP4eHin3yG - type: blueId: 49hrWpkoXavNmK8PpZag11zB2vYwzhQZahwioz6vDk2i diff --git a/src/test/java/blue/language/BlueConformanceReportTest.java b/src/test/java/blue/language/BlueConformanceReportTest.java index 7dec36d..4c441db 100644 --- a/src/test/java/blue/language/BlueConformanceReportTest.java +++ b/src/test/java/blue/language/BlueConformanceReportTest.java @@ -38,7 +38,11 @@ class BlueConformanceReportTest { "calculateSemanticBlueId", "expand", "collapse", - "assertSameNodeBlueId" + "assertSameNodeBlueId", + "assertViewPath", + "registryNodeHashesToPublishedBlueId", + "changingRegistryDescriptionChangesBlueId", + "lintPublishableDocumentation" )); @Test @@ -89,7 +93,8 @@ void conformanceReportExposesDetailedFailureMetadata() { BlueFixtureCategory.BLUE_ID, "calculateBlueId", IllegalArgumentException.class.getName(), - "bad fixture"); + "bad fixture", + BlueLanguageErrorCategory.InvalidBlueIdInput); BlueConformanceReport report = new BlueConformanceReport( "1.0", Collections.emptyMap(), @@ -104,6 +109,7 @@ void conformanceReportExposesDetailedFailureMetadata() { assertEquals("B_bad", report.getFailures().get(0).getFixtureId()); assertEquals("calculateBlueId", report.getFailures().get(0).getOperation()); assertEquals(IllegalArgumentException.class.getName(), report.getFailures().get(0).getExceptionClass()); + assertEquals(BlueLanguageErrorCategory.InvalidBlueIdInput, report.getFailures().get(0).getErrorCategory()); } @Test diff --git a/src/test/java/blue/language/BlueViewPathTest.java b/src/test/java/blue/language/BlueViewPathTest.java new file mode 100644 index 0000000..a93c78c --- /dev/null +++ b/src/test/java/blue/language/BlueViewPathTest.java @@ -0,0 +1,53 @@ +package blue.language; + +import blue.language.model.Node; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BlueViewPathTest { + + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + @Test + void emptyStringSelectsRootAndSlashSelectsEmptyKeyMember() throws Exception { + Node root = YAML_MAPPER.readValue( + "\"\": empty-key\n" + + "regular: value", Node.class); + + assertSame(root, BlueViewPath.select(root, "")); + assertEquals("empty-key", BlueViewPath.select(root, "/").getValue()); + assertEquals("value", BlueViewPath.select(root, "/regular").getValue()); + } + + @Test + void itemsSegmentSelectsListPayloadItemsInAbstractNodeModel() throws Exception { + Node root = YAML_MAPPER.readValue( + "regular:\n" + + " items:\n" + + " - first\n" + + " - second", Node.class); + + assertEquals("first", BlueViewPath.select(root, "/regular/items/0").getValue()); + assertEquals("second", BlueViewPath.select(root, "/regular/items/1").getValue()); + } + + @Test + void escapesTildeAndSlashPerRfc6901() throws Exception { + Node root = YAML_MAPPER.readValue( + "\"a/b\":\n" + + " \"c~d\": escaped", Node.class); + + assertEquals("escaped", BlueViewPath.select(root, "/a~1b/c~0d").getValue()); + } + + @Test + void badEscapesAreRejected() { + assertThrows(IllegalArgumentException.class, () -> BlueViewPath.split("/bad~2escape")); + assertThrows(IllegalArgumentException.class, () -> BlueViewPath.split("/bad~")); + } +} diff --git a/src/test/java/blue/language/NodeToMapListOrValueTest.java b/src/test/java/blue/language/NodeToMapListOrValueTest.java index f7235af..7641a19 100644 --- a/src/test/java/blue/language/NodeToMapListOrValueTest.java +++ b/src/test/java/blue/language/NodeToMapListOrValueTest.java @@ -253,7 +253,7 @@ public void testListControlSerialization() { assertEquals("C", ((Map) positioned).get("value")); Object list = NodeToMapListOrValue.get(new Node() - .type(new Node().blueId("6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY")) + .type(new Node().blueId("8DSFoWG9MqRSUhStqoPLrwVQiYByRh18NWbDEarN8MKF")) .mergePolicy("append-only") .items(new Node().value("A"))); assertEquals("append-only", ((Map) list).get("mergePolicy")); diff --git a/src/test/java/blue/language/SchemaVerifierTest.java b/src/test/java/blue/language/SchemaVerifierTest.java index 8fa340c..27663e7 100644 --- a/src/test/java/blue/language/SchemaVerifierTest.java +++ b/src/test/java/blue/language/SchemaVerifierTest.java @@ -189,6 +189,41 @@ public void testMultipleOfNegative() throws Exception { assertThrows(IllegalArgumentException.class, () -> merger.resolve(node)); } + @Test + public void doubleMultipleOfUsesExactBinary64RationalArithmetic() { + Node passing = new Node() + .schema(new Schema().multipleOf(new BigDecimal("0.5"))) + .value(new BigDecimal("1.5")); + Node failing = new Node() + .schema(new Schema().multipleOf(new BigDecimal("0.1"))) + .value(new BigDecimal("0.3")); + + assertDoesNotThrow(() -> merger.resolve(passing)); + assertThrows(IllegalArgumentException.class, () -> merger.resolve(failing)); + } + + @Test + public void schemaKeywordsRejectWrongPayloadKinds() { + assertThrows(IllegalArgumentException.class, () -> merger.resolve(new Node() + .schema(new Schema().minLength(1)) + .value(BigInteger.ONE))); + assertThrows(IllegalArgumentException.class, () -> merger.resolve(new Node() + .schema(new Schema().minimum(BigDecimal.ONE)) + .value("one"))); + assertThrows(IllegalArgumentException.class, () -> merger.resolve(new Node() + .schema(new Schema().minItems(1)) + .value("not a list"))); + assertThrows(IllegalArgumentException.class, () -> merger.resolve(new Node() + .schema(new Schema().minItems(1)) + .properties("field", new Node().value("not a list")))); + assertThrows(IllegalArgumentException.class, () -> merger.resolve(new Node() + .schema(new Schema().minFields(1)) + .value("not an object"))); + assertThrows(IllegalArgumentException.class, () -> merger.resolve(new Node() + .schema(new Schema().minFields(1)) + .items(new Node().value("not an object")))); + } + @Test public void testMinItemsPositive() throws Exception { schema.minItems(2); diff --git a/src/test/java/blue/language/SelfReferenceTest.java b/src/test/java/blue/language/SelfReferenceTest.java index b7fb338..e974118 100644 --- a/src/test/java/blue/language/SelfReferenceTest.java +++ b/src/test/java/blue/language/SelfReferenceTest.java @@ -377,18 +377,15 @@ public void bareThisRejectedOutsideCircularApi() { } @Test - public void duplicatePreliminaryIdsWithActualCycleUseOriginalIndexTieBreak() { + public void duplicatePreliminaryIdsWithActualCycleAreRejected() { List nodes = YAML_MAPPER.readValue( "- next:\n" + " blueId: this#1\n" + "- next:\n" + " blueId: this#0", Node.class).getItems(); - List ids = CircularBlueIdCalculator.calculateCircularSetBlueIds(nodes); - - assertEquals(baseBlueId(ids.get(0)), baseBlueId(ids.get(1))); - assertTrue(ids.get(0).endsWith("#0")); - assertTrue(ids.get(1).endsWith("#1")); + assertThrows(IllegalArgumentException.class, + () -> CircularBlueIdCalculator.calculateCircularSetBlueIds(nodes)); } @Test diff --git a/src/test/java/blue/language/conformance/BlueLanguageConformanceFixtureTest.java b/src/test/java/blue/language/conformance/BlueLanguageConformanceFixtureTest.java index 454335b..a9572c4 100644 --- a/src/test/java/blue/language/conformance/BlueLanguageConformanceFixtureTest.java +++ b/src/test/java/blue/language/conformance/BlueLanguageConformanceFixtureTest.java @@ -5,6 +5,8 @@ import blue.language.BlueConformanceReport; import blue.language.BlueConformanceSuiteRunner; import blue.language.BlueFixtureCategory; +import blue.language.BlueLanguageErrorCategory; +import blue.language.BlueLanguageErrorClassifier; import com.fasterxml.jackson.databind.JsonNode; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Test; @@ -136,6 +138,49 @@ void fixtureProviderNodeMayContainOrdinaryProfileField() { assertDoesNotThrow(() -> BlueConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); } + @Test + void fixtureExpectedErrorCategoryIsValidated() { + JsonNode spec = YAML_MAPPER.readTree( + "id: B_error_category\n" + + "category: BlueId\n" + + "operation: calculateBlueId\n" + + "expectError: true\n" + + "expectedErrorCategory: InvalidBlueIdInput\n" + + "input:\n" + + " type: Integer\n" + + " value: 1\n"); + + assertDoesNotThrow(() -> BlueConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void fixtureExpectedErrorCategoryRejectsUnknownCategory() { + JsonNode spec = YAML_MAPPER.readTree( + "id: B_error_category\n" + + "category: BlueId\n" + + "operation: calculateBlueId\n" + + "expectError: true\n" + + "expectedErrorCategory: NotACategory\n" + + "input:\n" + + " type: Integer\n" + + " value: 1\n"); + + assertThrows(IllegalArgumentException.class, + () -> BlueConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void languageErrorClassifierRecognizesRepresentativeCategories() { + assertEquals(BlueLanguageErrorCategory.InvalidBlueId, + BlueLanguageErrorClassifier.classify(new IllegalArgumentException("not a valid BlueId"))); + assertEquals(BlueLanguageErrorCategory.SchemaViolation, + BlueLanguageErrorClassifier.classify(new IllegalArgumentException("schema keyword minLength applies to wrong kind"))); + assertEquals(BlueLanguageErrorCategory.ProviderBlueIdMismatch, + BlueLanguageErrorClassifier.classify(new IllegalArgumentException("Provider returned content for abc but computed BlueId xyz"))); + assertEquals(BlueLanguageErrorCategory.ListControlViolation, + BlueLanguageErrorClassifier.classify(new IllegalArgumentException("$pos list overlay is invalid"))); + } + @Test void conformanceManifestIsAuthoritative() throws Exception { URL resource = getClass().getClassLoader().getResource(FIXTURE_PATH); diff --git a/src/test/java/blue/language/mapping/NodeToObjectConverterTest.java b/src/test/java/blue/language/mapping/NodeToObjectConverterTest.java index 5bdb980..13d5df9 100644 --- a/src/test/java/blue/language/mapping/NodeToObjectConverterTest.java +++ b/src/test/java/blue/language/mapping/NodeToObjectConverterTest.java @@ -486,19 +486,19 @@ public void testValueVariants() throws Exception { " blueId: PersonValue-BlueId\n" + "age1:\n" + " type:\n" + - " blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1\n" + + " blueId: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq\n" + " name: Official Age\n" + " description: Description for official age\n" + " value: 25\n" + "age2:\n" + " type:\n" + - " blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1\n" + + " blueId: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq\n" + " name: Official Age\n" + " description: Description for official age\n" + " value: 25\n" + "age3:\n" + " type:\n" + - " blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1\n" + + " blueId: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq\n" + " name: Official Age\n" + " description: Description for official age\n" + " value: 25"; diff --git a/src/test/java/blue/language/processor/ChannelRunnerTest.java b/src/test/java/blue/language/processor/ChannelRunnerTest.java index dfe2cb1..6a17695 100644 --- a/src/test/java/blue/language/processor/ChannelRunnerTest.java +++ b/src/test/java/blue/language/processor/ChannelRunnerTest.java @@ -25,18 +25,18 @@ final class ChannelRunnerTest { @Test void skipsDuplicateEventsUsingCheckpoint() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(new IncrementPropertyContractProcessor()); String yaml = "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " increment:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: IncrementProperty\n" + + " blueId: GsQfKqSUXxx24JTvsHDaY5pJ2cE6vZnn7j1NQ5RFDCWv\n" + " propertyKey: /counter\n"; Node document = blue.yamlToNode(yaml); @@ -71,19 +71,19 @@ void skipsDuplicateEventsUsingCheckpoint() { } @Test - void skipsDuplicateEventsByEventIdEvenIfPayloadChanges() { - Blue blue = new Blue(); + void treatsDifferentContentWithSameEventIdAsNewByDefault() { + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(new IncrementPropertyContractProcessor()); String yaml = "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " increment:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: IncrementProperty\n" + + " blueId: GsQfKqSUXxx24JTvsHDaY5pJ2cE6vZnn7j1NQ5RFDCWv\n" + " propertyKey: /counter\n"; Node document = blue.yamlToNode(yaml); @@ -108,23 +108,23 @@ void skipsDuplicateEventsByEventIdEvenIfPayloadChanges() { Node counterNode = execution.runtime().document().getProperties().get("counter"); assertNotNull(counterNode); - assertEquals(new BigInteger("2"), counterNode.getValue()); + assertEquals(new BigInteger("3"), counterNode.getValue()); } @Test void skipsDuplicateEventsByCanonicalPayloadWhenNoEventIdPresent() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(new IncrementPropertyContractProcessor()); String yaml = "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " increment:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: IncrementProperty\n" + + " blueId: GsQfKqSUXxx24JTvsHDaY5pJ2cE6vZnn7j1NQ5RFDCWv\n" + " propertyKey: /counter\n"; Node document = blue.yamlToNode(yaml); @@ -153,18 +153,18 @@ void skipsDuplicateEventsByCanonicalPayloadWhenNoEventIdPresent() { @Test void deliversChannelizedEventToHandlersAndStoresOriginalEventInCheckpoint() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new NormalizingTestEventChannelProcessor()); blue.registerContractProcessor(new SetPropertyOnEventContractProcessor()); String yaml = "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setFlag:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetPropertyOnEvent\n" + + " blueId: H1qKGon7JWgUU9P8oUiHjxoR5hWbkAzVWWNukXf4cHz\n" + " expectedKind: " + NormalizingTestEventChannelProcessor.NORMALIZED_KIND + "\n" + " propertyKey: /flag\n" + " propertyValue: 7\n"; @@ -200,18 +200,18 @@ void deliversChannelizedEventToHandlersAndStoresOriginalEventInCheckpoint() { @Test void duplicateSignatureForChannelizedEventsUsesOriginalExternalEvent() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new NormalizingTestEventChannelProcessor()); blue.registerContractProcessor(new IncrementPropertyContractProcessor()); String yaml = "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " increment:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: IncrementProperty\n" + + " blueId: GsQfKqSUXxx24JTvsHDaY5pJ2cE6vZnn7j1NQ5RFDCWv\n" + " propertyKey: /counter\n"; Node document = blue.yamlToNode(yaml); diff --git a/src/test/java/blue/language/processor/CheckpointIdentityCalculatorTest.java b/src/test/java/blue/language/processor/CheckpointIdentityCalculatorTest.java new file mode 100644 index 0000000..d5b4a1d --- /dev/null +++ b/src/test/java/blue/language/processor/CheckpointIdentityCalculatorTest.java @@ -0,0 +1,83 @@ +package blue.language.processor; + +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.utils.BlueIdCalculator; +import org.junit.jupiter.api.Test; + +import static blue.language.utils.Properties.TEXT_TYPE_BLUE_ID; +import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class CheckpointIdentityCalculatorTest { + + @Test + void checkpointUsesNodeBlueIdForBlueIdInputEvent() { + Node event = new Node().properties("kind", new Node().value("direct")); + + assertEquals(BlueIdCalculator.calculateBlueId(event), CheckpointIdentityCalculator.identity(event)); + } + + @Test + void checkpointUsesContentBlueIdForSourceEvent() { + Blue blue = ProcessorTestSupport.blue(); + Node source = YAML_MAPPER.readValue( + "blue:\n" + + " imports:\n" + + " TextAlias:\n" + + " blueId: " + TEXT_TYPE_BLUE_ID + "\n" + + "type: TextAlias\n" + + "value: hello", Node.class); + + assertThrows(IllegalStateException.class, () -> CheckpointIdentityCalculator.identity(source)); + assertEquals(blue.calculateSemanticBlueId(source.clone()), CheckpointIdentityCalculator.identity(source, blue)); + } + + @Test + void sameContentDifferentSourceShapeIsStale() { + Blue blue = ProcessorTestSupport.blue(); + Node aliased = YAML_MAPPER.readValue( + "blue:\n" + + " imports:\n" + + " TextAlias:\n" + + " blueId: " + TEXT_TYPE_BLUE_ID + "\n" + + "type: TextAlias\n" + + "value: hello", Node.class); + Node direct = YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + TEXT_TYPE_BLUE_ID + "\n" + + "value: hello", Node.class); + + assertEquals(CheckpointIdentityCalculator.identity(direct, blue), + CheckpointIdentityCalculator.identity(aliased, blue)); + } + + @Test + void differentContentSameEventIdStillNewByDefault() { + Blue blue = ProcessorTestSupport.blue(); + Node first = new Node() + .properties("eventId", new Node().value("same-id")) + .properties("amount", new Node().value(1)); + Node second = new Node() + .properties("eventId", new Node().value("same-id")) + .properties("amount", new Node().value(2)); + + assertEquals("same-id", first.getAsText("/eventId")); + assertEquals("same-id", second.getAsText("/eventId")); + org.junit.jupiter.api.Assertions.assertNotEquals( + CheckpointIdentityCalculator.identity(first, blue), + CheckpointIdentityCalculator.identity(second, blue)); + } + + @Test + void checkpointIdentityFailureRequiresDeterministicLanguageIdentity() { + Blue blue = ProcessorTestSupport.blue(); + Node event = new Node().blue(new Node().value("not-a-blueid")).value("payload"); + + String identity = CheckpointIdentityCalculator.identity(event, blue); + assertNotNull(identity); + assertEquals(identity, CheckpointIdentityCalculator.identity(event, blue)); + } +} diff --git a/src/test/java/blue/language/processor/ContractBundleCacheTest.java b/src/test/java/blue/language/processor/ContractBundleCacheTest.java index 1658121..c4292a1 100644 --- a/src/test/java/blue/language/processor/ContractBundleCacheTest.java +++ b/src/test/java/blue/language/processor/ContractBundleCacheTest.java @@ -24,10 +24,10 @@ void processingStateChangesReuseBundleAndRefreshCheckpointMarkers() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " increment:\n" + " type:\n" + - " blueId: IncrementProperty\n" + + " blueId: GsQfKqSUXxx24JTvsHDaY5pJ2cE6vZnn7j1NQ5RFDCWv\n" + " channel: testChannel\n" + " propertyKey: /count\n")).document(); @@ -53,10 +53,10 @@ void changingContractsInvalidatesBundleCache() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " set:\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " channel: testChannel\n" + " path: /orders\n" + " propertyKey: count\n" + @@ -86,10 +86,10 @@ void changingChannelBindingsInvalidatesBundleCacheKey() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " set:\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " channel: testChannel\n" + " path: /orders\n" + " propertyKey: count\n" + @@ -116,16 +116,16 @@ void embeddedScopesCacheIndependently() { " contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " increment:\n" + " type:\n" + - " blueId: IncrementProperty\n" + + " blueId: GsQfKqSUXxx24JTvsHDaY5pJ2cE6vZnn7j1NQ5RFDCWv\n" + " channel: testChannel\n" + " propertyKey: /count\n" + "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /child\n")).document(); @@ -145,7 +145,7 @@ private Blue configuredBlue(RecordingMetrics metrics) { .registerContractProcessor(new IncrementPropertyContractProcessor()) .registerContractProcessor(new SetPropertyContractProcessor()) .build(); - return new Blue().documentProcessor(processor); + return ProcessorTestSupport.blue().documentProcessor(processor); } private Node event(Blue blue, String eventId) { diff --git a/src/test/java/blue/language/processor/ContractMappingIntegrationTest.java b/src/test/java/blue/language/processor/ContractMappingIntegrationTest.java index 870b818..66cc56c 100644 --- a/src/test/java/blue/language/processor/ContractMappingIntegrationTest.java +++ b/src/test/java/blue/language/processor/ContractMappingIntegrationTest.java @@ -36,7 +36,7 @@ void loadsAllContractsFromBlueYaml() throws Exception { StandardCharsets.UTF_8 ); - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); assertNotNull(document); Node contractsNode = document.getContracts(); @@ -102,7 +102,7 @@ void contractLoaderLoadsBundleFromResolvedSnapshotWithoutScopeNodeTraversal() th StandardCharsets.UTF_8 ); - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); FrozenNode canonicalRoot = FrozenNode.fromUncheckedCanonicalNode(document); ResolvedSnapshot snapshot = new ResolvedSnapshot(canonicalRoot, @@ -136,15 +136,15 @@ void contractLoaderLoadsBundleFromResolvedSnapshotWithoutScopeNodeTraversal() th @Test void processorContractLoaderStillFindsContracts() { - Node document = new Blue().yamlToNode( + Node document = ProcessorTestSupport.blue().yamlToNode( "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setProperty:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 7\n"); ContractProcessorRegistry registry = ContractProcessorRegistryBuilder.create() diff --git a/src/test/java/blue/language/processor/DocumentProcessingRuntimeBatchPatchTest.java b/src/test/java/blue/language/processor/DocumentProcessingRuntimeBatchPatchTest.java index 9c6741f..6d3fb50 100644 --- a/src/test/java/blue/language/processor/DocumentProcessingRuntimeBatchPatchTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessingRuntimeBatchPatchTest.java @@ -186,7 +186,7 @@ void inheritedParentThenChildPatchDoesNotMinimizeMidBatch() { "name: Has Inherited List\n" + "a:\n" + " - inherited"); - Blue blue = new Blue(provider); + Blue blue = ProcessorTestSupport.blue(provider); Node canonical = YAML_MAPPER.readValue( "name: Instance\n" + "type:\n" + @@ -210,7 +210,7 @@ void sameInheritedPathCanBeChangedAgainInSameBatch() { provider.addSingleDocs( "name: Has Inherited Status\n" + "status: idle"); - Blue blue = new Blue(provider); + Blue blue = ProcessorTestSupport.blue(provider); Node canonical = YAML_MAPPER.readValue( "name: Instance\n" + "type:\n" + diff --git a/src/test/java/blue/language/processor/DocumentProcessorBatchPatchTest.java b/src/test/java/blue/language/processor/DocumentProcessorBatchPatchTest.java index 5b407ab..ed00cca 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorBatchPatchTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorBatchPatchTest.java @@ -20,18 +20,18 @@ class DocumentProcessorBatchPatchTest { @Test void processorExecutionContextApplyPatchesWorksInsideHandler() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new ApplyBatchPatchContractProcessor()); Node original = blue.yamlToNode( "name: Batch Handler Doc\n" + "contracts:\n" + " lifecycle:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " apply:\n" + " channel: lifecycle\n" + " type:\n" + - " blueId: ApplyBatchPatch\n"); + " blueId: AjWAjR4NcDYJHMhkAkX9DZKqGbHs8vkCRpjXiHRkLPMw\n"); DocumentProcessingResult result = blue.initializeDocument(original); @@ -40,24 +40,27 @@ void processorExecutionContextApplyPatchesWorksInsideHandler() { } @Test - void boundaryViolationInSecondPatchRollsBackEarlierPatch() { + void boundaryViolationInSecondPatchKeepsEarlierSuccessfulPatch() { Node document = new Node(); ProcessorEngine.Execution execution = new ProcessorEngine.Execution(new DocumentProcessor(), document); ContractBundle bundle = ContractBundle.builder().build(); execution.handlePatches("/foo", bundle, Arrays.asList( JsonPatch.add("/foo/a", new Node().value("applied-first")), - JsonPatch.add("/bar", new Node().value("outside")) + JsonPatch.add("/bar", new Node().value("outside")), + JsonPatch.add("/foo/c", new Node().value("discarded-third")) ), false); Node resultDoc = execution.result().document(); Node foo = resultDoc.getAsNode("/foo"); - assertFalse(hasProperty(foo, "a")); + assertTrue(hasProperty(foo, "a")); + assertEquals("applied-first", foo.getAsText("/a")); + assertFalse(hasProperty(foo, "c")); assertTrue(execution.runtime().isScopeTerminated("/foo")); } @Test - void reservedKeyViolationInSecondPatchRollsBackEarlierPatch() { + void reservedKeyViolationInSecondPatchKeepsEarlierSuccessfulPatch() { Node document = new Node().properties("foo", new Node()); ProcessorEngine.Execution execution = new ProcessorEngine.Execution(new DocumentProcessor(), document); ContractBundle bundle = ContractBundle.builder().build(); @@ -69,14 +72,35 @@ void reservedKeyViolationInSecondPatchRollsBackEarlierPatch() { Node resultDoc = execution.result().document(); Node foo = resultDoc.getAsNode("/foo"); - assertFalse(hasProperty(foo, "a")); + assertTrue(hasProperty(foo, "a")); + assertEquals("applied-first", foo.getAsText("/a")); + assertTrue(execution.runtime().isScopeTerminated("/foo")); + } + + @Test + void patchTwoFatalPreservesPatchOneAndDiscardsPatchThreeAndEvents() { + Node document = new Node().properties("foo", new Node()); + ProcessorEngine.Execution execution = new ProcessorEngine.Execution(new DocumentProcessor(), document); + ContractBundle bundle = ContractBundle.builder().build(); + + execution.handlePatches("/foo", bundle, Arrays.asList( + JsonPatch.add("/foo/a", new Node().value("applied-first")), + JsonPatch.remove("/foo/missing"), + JsonPatch.add("/foo/c", new Node().value("discarded-third")) + ), false); + + Node resultDoc = execution.result().document(); + Node foo = resultDoc.getAsNode("/foo"); + assertTrue(hasProperty(foo, "a")); + assertEquals("applied-first", foo.getAsText("/a")); + assertFalse(hasProperty(foo, "c")); assertTrue(execution.runtime().isScopeTerminated("/foo")); } @Test void documentUpdateChannelsReceiveBatchUpdatesInPatchOrder() { RecordDocumentUpdateContractProcessor recorder = new RecordDocumentUpdateContractProcessor(); - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new ApplyBatchPatchContractProcessor()); blue.registerContractProcessor(recorder); Node original = blue.yamlToNode( @@ -84,27 +108,27 @@ void documentUpdateChannelsReceiveBatchUpdatesInPatchOrder() { "contracts:\n" + " lifecycle:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " watchA:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /a\n" + " watchB:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /b\n" + " apply:\n" + " channel: lifecycle\n" + " type:\n" + - " blueId: ApplyBatchPatch\n" + + " blueId: AjWAjR4NcDYJHMhkAkX9DZKqGbHs8vkCRpjXiHRkLPMw\n" + " recordA:\n" + " channel: watchA\n" + " type:\n" + - " blueId: RecordDocumentUpdate\n" + + " blueId: qLb75fi7BHJf8HvxXNTJP8Zo2fCsA3t6Lz5R269qUiC\n" + " recordB:\n" + " channel: watchB\n" + " type:\n" + - " blueId: RecordDocumentUpdate\n"); + " blueId: qLb75fi7BHJf8HvxXNTJP8Zo2fCsA3t6Lz5R269qUiC\n"); blue.initializeDocument(original); @@ -113,13 +137,13 @@ void documentUpdateChannelsReceiveBatchUpdatesInPatchOrder() { @Test void unmatchedDocumentUpdateChannelDoesNotMaterializeUpdateNodes() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode( "name: Lazy Update Doc\n" + "contracts:\n" + " watchOther:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /other\n"); ProcessorEngine.Execution execution = new ProcessorEngine.Execution(new DocumentProcessor(), document); execution.loadBundles("/"); @@ -134,14 +158,14 @@ void unmatchedDocumentUpdateChannelDoesNotMaterializeUpdateNodes() { @Test void matchingDocumentUpdateChannelMaterializesUpdateNodes() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode( "name: Lazy Update Doc\n" + "a: old\n" + "contracts:\n" + " watchA:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /a\n"); ProcessorEngine.Execution execution = new ProcessorEngine.Execution(new DocumentProcessor(), document); execution.loadBundles("/"); diff --git a/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java b/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java index 1977101..d938611 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java @@ -13,7 +13,7 @@ class DocumentProcessorBoundaryTest { @Test - void allowsPatchingWithinScopeUsingLiteralSegments() { + void rejectsEmptyPointerSegments() { Node document = new Node(); DocumentProcessor processor = new DocumentProcessor(); ProcessorEngine.Execution execution = new ProcessorEngine.Execution(processor, document); @@ -21,10 +21,11 @@ void allowsPatchingWithinScopeUsingLiteralSegments() { execution.handlePatch("/foo", bundle, JsonPatch.add("/foo//bar", new Node().value("ok")), false); - Node foo = getProperty(document, "foo"); - Node empty = getProperty(foo, ""); - Node bar = getProperty(empty, "bar"); - assertEquals("ok", bar.getValue()); + Node resultDoc = execution.result().document(); + Node terminated = resultDoc.getAsNode("/foo/contracts/terminated"); + assertNotNull(terminated); + assertEquals("fatal", terminated.getProperties().get("cause").getValue()); + assertTrue(execution.runtime().isScopeTerminated("/foo")); } @Test diff --git a/src/test/java/blue/language/processor/DocumentProcessorCapabilityTest.java b/src/test/java/blue/language/processor/DocumentProcessorCapabilityTest.java index a535315..5d4f6f9 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorCapabilityTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorCapabilityTest.java @@ -2,7 +2,9 @@ import blue.language.Blue; import blue.language.model.Node; +import blue.language.processor.contracts.ApplyBatchPatchContractProcessor; import blue.language.processor.model.TerminateScope; +import blue.language.processor.registry.RuntimeBlueIds; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; @@ -15,15 +17,15 @@ void initializeDocumentFailsWithCapabilityFailureWhenProcessorMissing() { "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " handler:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); String originalJson = blue.nodeToJson(document.clone()); @@ -42,7 +44,7 @@ void initializeDocumentFailsWithCapabilityFailureWhenContractHasNoType() { " unclear:\n" + " property: value\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); String originalJson = blue.nodeToJson(document.clone()); @@ -61,24 +63,24 @@ void initializeDocumentFailsWithCapabilityFailureWhenContractsIsNotObjectMap() { "contracts:\n" + " - bad\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); assertThrows(RuntimeException.class, () -> blue.yamlToNode(yaml)); } @Test void processDocumentFailsWithCapabilityFailureWhenNewUnsupportedContractAppears() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new blue.language.processor.contracts.SetPropertyContractProcessor()); String baseYaml = "name: Base\n" + "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " handler:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n"; @@ -109,18 +111,18 @@ void processDocumentFailsWithCapabilityFailureWhenNewUnsupportedContractAppears( @Test void processDocumentFailsWithCapabilityFailureWhenNewTypelessContractAppears() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new blue.language.processor.contracts.SetPropertyContractProcessor()); String baseYaml = "name: Base\n" + "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " handler:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n"; @@ -136,4 +138,87 @@ void processDocumentFailsWithCapabilityFailureWhenNewTypelessContractAppears() { assertTrue(result.triggeredEvents().isEmpty()); assertTrue(result.failureReason().contains("must declare a type")); } + + @Test + void unsupportedContractAddedByPatchCausesRuntimeFatalNotCapabilityFailure() { + Blue blue = ProcessorTestSupport.blue(); + blue.registerContractProcessor(new ApplyBatchPatchContractProcessor()); + + String yaml = "name: Runtime Unsupported\n" + + "contracts:\n" + + " lifecycleChannel:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.LIFECYCLE_EVENT_CHANNEL + "\n" + + " addUnsupported:\n" + + " channel: lifecycleChannel\n" + + " type:\n" + + " blueId: AjWAjR4NcDYJHMhkAkX9DZKqGbHs8vkCRpjXiHRkLPMw\n" + + " addUnsupportedContract: true\n"; + + DocumentProcessingResult result = blue.initializeDocument(blue.yamlToNode(yaml)); + + assertFalse(result.capabilityFailure(), result.failureReason()); + assertTrue(result.totalGas() > 0L); + Node contracts = result.document().getContracts(); + assertNotNull(contracts); + Node terminated = contracts.getProperties().get("terminated"); + assertNotNull(terminated); + assertEquals("fatal", terminated.getProperties().get("cause").getValue()); + } + + @Test + void unsupportedContractInsidePreExistingTerminatedEmbeddedScopeIsIgnored() { + Blue blue = ProcessorTestSupport.blue(); + + String yaml = "name: Root\n" + + "child:\n" + + " name: Child\n" + + " contracts:\n" + + " terminated:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.PROCESSING_TERMINATED_MARKER + "\n" + + " cause: graceful\n" + + " unsupported:\n" + + " channel: missing\n" + + " type:\n" + + " blueId: AZNvNsADqpp7ZwAgpQyaQSz4cq3o3RMHZtB3sgDfudD4\n" + + "contracts:\n" + + " embedded:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.PROCESS_EMBEDDED + "\n" + + " paths:\n" + + " - /child\n"; + + Node document = blue.yamlToNode(yaml); + DocumentProcessingResult result = blue.processDocument(document, new Node().value("event")); + + assertFalse(result.capabilityFailure(), result.failureReason()); + assertTrue(result.totalGas() > 0L); + Node childContracts = result.document().getProperties().get("child").getContracts(); + assertNotNull(childContracts.getProperties().get("terminated")); + assertNotNull(childContracts.getProperties().get("unsupported")); + } + + @Test + void invalidPreExistingTerminatedMarkerFailsInitialMustUnderstand() { + Blue blue = ProcessorTestSupport.blue(); + + String yaml = "name: Root\n" + + "contracts:\n" + + " terminated:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER + "\n" + + " cause: graceful\n" + + " unsupported:\n" + + " channel: missing\n" + + " type:\n" + + " blueId: AZNvNsADqpp7ZwAgpQyaQSz4cq3o3RMHZtB3sgDfudD4\n"; + + Node document = blue.yamlToNode(yaml); + DocumentProcessingResult result = blue.processDocument(document, new Node().value("event")); + + assertTrue(result.capabilityFailure()); + assertEquals(0L, result.totalGas()); + assertTrue(result.failureReason().contains("terminated")); + } } diff --git a/src/test/java/blue/language/processor/DocumentProcessorEventImmutabilityTest.java b/src/test/java/blue/language/processor/DocumentProcessorEventImmutabilityTest.java index aada8a5..93815da 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorEventImmutabilityTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorEventImmutabilityTest.java @@ -17,7 +17,7 @@ class DocumentProcessorEventImmutabilityTest { @BeforeEach void setUp() { - blue = new Blue(); + blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(new MutateEventContractProcessor()); blue.registerContractProcessor(new SetPropertyOnEventContractProcessor()); @@ -29,16 +29,16 @@ void handlersSeeImmutableEventSnapshots() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " mutator:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: MutateEvent\n" + + " blueId: EgL9wruNhEJTS5RspenxoyRngKEbXzMwDM4ZZ8gCHsiv\n" + " recorder:\n" + " channel: testChannel\n" + " order: 1\n" + " type:\n" + - " blueId: SetPropertyOnEvent\n" + + " blueId: H1qKGon7JWgUU9P8oUiHjxoR5hWbkAzVWWNukXf4cHz\n" + " expectedKind: original\n" + " propertyKey: /result\n" + " propertyValue: 42\n"; @@ -46,7 +46,7 @@ void handlersSeeImmutableEventSnapshots() { Node initialized = blue.initializeDocument(blue.yamlToNode(documentYaml)).document().clone(); String eventYaml = "type:\n" + - " blueId: TestEvent\n" + + " blueId: Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf\n" + "eventId: evt-immutable\n" + "kind: original\n"; Node event = blue.yamlToNode(eventYaml); diff --git a/src/test/java/blue/language/processor/DocumentProcessorGasTest.java b/src/test/java/blue/language/processor/DocumentProcessorGasTest.java index 158f09f..122c636 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorGasTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorGasTest.java @@ -33,7 +33,7 @@ class DocumentProcessorGasTest { @BeforeEach void setUp() { - blue = new Blue(); + blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(new SetPropertyContractProcessor()); blue.registerContractProcessor(new EmitEventsContractProcessor()); @@ -49,13 +49,11 @@ void initializationGasMatchesExpectedCharges() { long markerSizeCharge = sizeCharge(initializedMarker); long expected = scopeEntryCharge("/") - + 1_000L // initialization + + 1_001L // initialization + 30L // lifecycle delivery - + 2L // boundary check - + (20L + markerSizeCharge) // patch add - + 10L; // cascade routing for root + + (20L + markerSizeCharge); // patch add; no cascade gas without a matching participant - assertEquals(expected, result.totalGas()); + assertEquals(expected, result.totalGas(), "initialization gas"); } @Test @@ -64,11 +62,11 @@ void processDocumentPatchGasMatchesExpectedCharges() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n"; @@ -84,11 +82,10 @@ void processDocumentPatchGasMatchesExpectedCharges() { + 5L // channel match attempt + 50L // handler overhead + 2L // boundary check - + (20L + valueSizeCharge) // add/replace patch - + 10L // cascade routing (root only) + + (20L + valueSizeCharge) // add/replace patch; no cascade gas without a matching participant + 20L; // checkpoint update direct write - assertEquals(expected, result.totalGas()); + assertEquals(expected, result.totalGas(), "process patch gas"); } @Test @@ -97,18 +94,18 @@ void processDocumentEmitsTriggeredEventChargesEmitAndDrain() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " emitter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: EmitEvents\n" + + " blueId: 8L41csGU9GJkoza1159y2pYbJ6yGAi4huvgmu44Ah2d5\n" + " events:\n" + " - type:\n" + - " blueId: TestEvent\n" + + " blueId: Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf\n" + " kind: emitted\n" + " triggered:\n" + " type:\n" + - " blueId: TriggeredEventChannel\n"; + " blueId: 5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ\n"; Node initialized = blue.initializeDocument(blue.yamlToNode(yaml)).document().clone(); Node event = blue.objectToNode(new TestEvent().eventId("evt-emit")); @@ -125,7 +122,7 @@ void processDocumentEmitsTriggeredEventChargesEmitAndDrain() { + 10L // drain triggered FIFO + 20L; // checkpoint update after successful channel - assertEquals(expected, result.totalGas()); + assertEquals(expected, result.totalGas(), "triggered event gas"); } @Test @@ -155,7 +152,7 @@ void processDocumentReusesResolvedTypeCacheWithoutChangingGas() { assertTrue(coldBlue.resolvedReferenceCacheSize() >= coldCacheSizeAfterFirstRun); assertEquals(cold.totalGas(), coldReused.totalGas()); - ResolvedSnapshot precomputedTypeGraph = new Blue(types.provider).loadSnapshot(accountCanonical(types)); + ResolvedSnapshot precomputedTypeGraph = ProcessorTestSupport.blue(types.provider).loadSnapshot(accountCanonical(types)); CountingNodeProvider warmProvider = new CountingNodeProvider(types.provider); Blue warmBlue = processingBlue(warmProvider).cacheResolvedSnapshot(precomputedTypeGraph); int warmCacheSizeBeforeProcessing = warmBlue.resolvedReferenceCacheSize(); @@ -194,7 +191,7 @@ void initializeDocumentReusesResolvedTypeCacheWithoutChangingGas() { assertEquals(coldCacheSizeAfterFirstRun, coldBlue.resolvedReferenceCacheSize()); assertEquals(cold.totalGas(), coldReused.totalGas()); - ResolvedSnapshot precomputedTypeGraph = new Blue(types.provider).loadSnapshot(accountCanonical(types)); + ResolvedSnapshot precomputedTypeGraph = ProcessorTestSupport.blue(types.provider).loadSnapshot(accountCanonical(types)); CountingNodeProvider warmProvider = new CountingNodeProvider(types.provider); Blue warmBlue = processingBlue(warmProvider).cacheResolvedSnapshot(precomputedTypeGraph); Node warmOriginal = accountDocument(types); @@ -235,7 +232,7 @@ void processDocumentCachesRepeatedNestedTypeReferencesOnlyOnceWithoutChangingGas assertTrue(coldBlue.resolvedReferenceCacheSize() >= coldCacheSizeAfterFirstRun); assertEquals(cold.totalGas(), coldReused.totalGas()); - ResolvedSnapshot precomputedTypeGraph = new Blue(types.provider).loadSnapshot(portfolioCanonical(types)); + ResolvedSnapshot precomputedTypeGraph = ProcessorTestSupport.blue(types.provider).loadSnapshot(portfolioCanonical(types)); CountingNodeProvider warmProvider = new CountingNodeProvider(types.provider); Blue warmBlue = processingBlue(warmProvider).cacheResolvedSnapshot(precomputedTypeGraph); Node warmEvent = warmBlue.objectToNode(new TestEvent().eventId("evt-repeated-warm")); @@ -272,7 +269,7 @@ void embeddedInitializationSharesResolvedTypeCacheAcrossChildScopesWithoutChangi assertEquals(coldCacheSizeAfterFirstRun, coldBlue.resolvedReferenceCacheSize()); assertEquals(cold.totalGas(), coldReused.totalGas()); - ResolvedSnapshot precomputedTypeGraph = new Blue(types.provider).loadSnapshot(accountCanonical(types)); + ResolvedSnapshot precomputedTypeGraph = ProcessorTestSupport.blue(types.provider).loadSnapshot(accountCanonical(types)); CountingNodeProvider warmProvider = new CountingNodeProvider(types.provider); Blue warmBlue = processingBlue(warmProvider).cacheResolvedSnapshot(precomputedTypeGraph); warmProvider.reset(); @@ -311,7 +308,7 @@ void embeddedProcessingSharesResolvedTypeCacheAcrossChildScopesWithoutChangingGa assertTrue(coldBlue.resolvedReferenceCacheSize() >= coldCacheSizeAfterFirstRun); assertEquals(cold.totalGas(), coldReused.totalGas()); - ResolvedSnapshot precomputedTypeGraph = new Blue(types.provider).loadSnapshot(accountCanonical(types)); + ResolvedSnapshot precomputedTypeGraph = ProcessorTestSupport.blue(types.provider).loadSnapshot(accountCanonical(types)); CountingNodeProvider warmProvider = new CountingNodeProvider(types.provider); Blue warmBlue = processingBlue(warmProvider).cacheResolvedSnapshot(precomputedTypeGraph); Node warmEvent = warmBlue.objectToNode(new TestEvent().eventId("evt-embedded-warm")); @@ -332,7 +329,7 @@ void changingNodeProviderRefreshesProcessorConformanceCacheAndKeepsRegisteredPro CountingNodeProvider secondProvider = new CountingNodeProvider(secondTypes.provider); Blue blue = processingBlue(firstProvider); - blue.nodeProvider(secondProvider); + blue.nodeProvider(ProcessorTestSupport.providerWithTestContractTypes(secondProvider)); firstProvider.reset(); secondProvider.reset(); Node document = processingDocument(secondTypes); @@ -398,11 +395,11 @@ void initializeDocumentResultExposesCanonicalSnapshotBlueIdAndResolvedView() { @Test void capabilityFailureResultDoesNotBuildSnapshotOrSpendGasOnResolution() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); String yaml = "contracts:\n" + " unsupported:\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " channel: missing\n" + " propertyKey: /x\n" + " propertyValue: 1\n"; @@ -481,7 +478,7 @@ private long canonicalSize(Node node) { } private Blue processingBlue(NodeProvider provider) { - Blue result = new Blue(provider); + Blue result = ProcessorTestSupport.blue(provider); result.registerContractProcessor(new TestEventChannelProcessor()); result.registerContractProcessor(new SetPropertyContractProcessor()); result.registerContractProcessor(new EmitEventsContractProcessor()); @@ -532,11 +529,11 @@ private Node processingDocument(ProcessingTypeGraph types) { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " path: /balance\n" + " propertyKey: cents\n" + " propertyValue: 1\n", Node.class); @@ -632,11 +629,11 @@ private Node initializedPortfolioDocument(RepeatedTypeGraph types) { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " path: /secondary/balance\n" + " propertyKey: cents\n" + " propertyValue: 1\n"); @@ -692,7 +689,7 @@ private Node embeddedAccountsDocument(ProcessingTypeGraph types) { "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /primary\n" + " - /secondary\n", Node.class); @@ -702,7 +699,7 @@ private Node initializedEmbeddedProcessingDocument(ProcessingTypeGraph types) { Blue setupBlue = processingBlue(new CountingNodeProvider(types.provider)); Node initialized = setupBlue.initializeDocument(embeddedAccountsProcessingDocument(types)).document(); assertTrue(setupBlue.isInitialized(initialized)); - return new Blue(types.provider).reverse(initialized); + return ProcessorTestSupport.blue(types.provider).reverse(initialized); } private Node embeddedAccountsProcessingDocument(ProcessingTypeGraph types) { @@ -719,11 +716,11 @@ private Node embeddedAccountsProcessingDocument(ProcessingTypeGraph types) { " contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " path: /balance\n" + " propertyKey: cents\n" + " propertyValue: 1\n" + @@ -739,18 +736,18 @@ private Node embeddedAccountsProcessingDocument(ProcessingTypeGraph types) { " contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " path: /balance\n" + " propertyKey: cents\n" + " propertyValue: 1\n" + "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /primary\n" + " - /secondary\n", Node.class); @@ -864,11 +861,11 @@ private void reset() { } private boolean isProcessorTypeStub(String blueId) { - return "InitializationMarker".equals(blueId) - || "ChannelEventCheckpoint".equals(blueId) - || "TestEventChannel".equals(blueId) + return "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q".equals(blueId) + || "9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1".equals(blueId) + || "BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L".equals(blueId) || "SetProperty".equals(blueId) - || "ProcessEmbedded".equals(blueId); + || "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q".equals(blueId); } } } diff --git a/src/test/java/blue/language/processor/DocumentProcessorGeneralizationTest.java b/src/test/java/blue/language/processor/DocumentProcessorGeneralizationTest.java index a59db90..1274323 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorGeneralizationTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorGeneralizationTest.java @@ -1,11 +1,18 @@ package blue.language.processor; import blue.language.Blue; +import blue.language.conformance.ConformancePlan; import blue.language.conformance.ConformanceEngine; import blue.language.conformance.ConformanceEngineTest; import blue.language.model.Node; import blue.language.processor.model.JsonPatch; +import blue.language.processor.registry.RuntimeBlueIds; +import blue.language.processor.registry.BlueRuntimeTypeRegistry; import blue.language.provider.BasicNodeProvider; +import blue.language.provider.BootstrapProvider; +import blue.language.provider.SequentialNodeProvider; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; import blue.language.utils.BlueIdCalculator; import blue.language.utils.NodeToBlueIdInput; import blue.language.utils.Properties; @@ -13,6 +20,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; @@ -26,7 +34,7 @@ class DocumentProcessorGeneralizationTest { @Test void patchGeneralizesChangedNodeAndAncestorsBeforeCommit() { BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Shoes\n" + "type:\n" + @@ -50,7 +58,7 @@ void nonGeneralizablePatchRollsBackDocument() { nodeProvider.addSingleDocs( "name: Fixed One\n" + "x: 1"); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Instance\n" + "type:\n" + @@ -67,21 +75,21 @@ void nonGeneralizablePatchRollsBackDocument() { @Test void untypedRootPatchesAreNotConformanceEnforced() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = new Node(); DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(document, blue.conformanceEngine()); runtime.applyPatch("/", JsonPatch.add("/contracts/initialized", - new Node().type(new Node().blueId("InitializationMarker")))); + new Node().type(new Node().blueId("6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q")))); assertNotNull(document.getAsNode("/contracts/initialized")); - assertEquals("InitializationMarker", document.getAsNode("/contracts/initialized/type").getBlueId()); + assertEquals("6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q", document.getAsNode("/contracts/initialized/type").getBlueId()); } @Test void batchPatchGeneralizesChangedNodeAndAncestorOnce() { BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Shoes\n" + "type:\n" + @@ -110,7 +118,7 @@ void nonGeneralizableBatchRollsBackAllPatches() { nodeProvider.addSingleDocs( "name: Fixed One\n" + "x: 1"); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Instance\n" + "type:\n" + @@ -131,13 +139,13 @@ void nonGeneralizableBatchRollsBackAllPatches() { @Test void processorManagedInitializedMarkerBypassWorksInBatch() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = new Node(); DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(document, blue.conformanceEngine()); runtime.applyPatches("/", Arrays.asList( JsonPatch.add("/contracts/initialized", - new Node().type(new Node().blueId("InitializationMarker"))), + new Node().type(new Node().blueId("6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q"))), JsonPatch.add("/status", new Node().value("active")) )); @@ -148,7 +156,7 @@ void processorManagedInitializedMarkerBypassWorksInBatch() { @Test void batchParentThenChildPatchGeneralizesAndPreservesChildValue() { BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Shoes\n" + "type:\n" + @@ -174,7 +182,7 @@ void batchParentThenChildPatchGeneralizesAndPreservesChildValue() { @Test void batchChildThenSiblingPatchGeneralizesOnceAndPreservesBothChanges() { BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Shoes\n" + "type:\n" + @@ -198,7 +206,7 @@ void batchChildThenSiblingPatchGeneralizesOnceAndPreservesBothChanges() { @Test void batchSiblingPatchesRequiringAncestorGeneralizationPreserveBothChanges() { BasicNodeProvider nodeProvider = productWithAvailabilityProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Shoes\n" + "type:\n" + @@ -225,7 +233,7 @@ void batchSiblingPatchesRequiringAncestorGeneralizationPreserveBothChanges() { @Test void batchDictionaryValueTypePatchesPreserveValuesAndDictionaryType() { BasicNodeProvider nodeProvider = orderBookProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Book\n" + "type:\n" + @@ -250,7 +258,7 @@ void batchDictionaryValueTypePatchesPreserveValuesAndDictionaryType() { @Test void batchListItemTypePatchesMatchSequentialBehavior() { BasicNodeProvider nodeProvider = itemListProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node batchDocument = blue.resolve(YAML_MAPPER.readValue( "name: Batch List\n" + "type:\n" + @@ -280,7 +288,7 @@ void batchListItemTypePatchesMatchSequentialBehavior() { @Test void batchGeneralizesTypedChildUnderUntypedRoot() { BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node batchDocument = blue.resolve(YAML_MAPPER.readValue( "name: Untyped Container\n" + "child:\n" + @@ -304,7 +312,7 @@ void batchGeneralizesTypedChildUnderUntypedRoot() { @Test void batchGeneralizesDictionaryValueTypeUnderUntypedRoot() { BasicNodeProvider nodeProvider = orderBookProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node batchDocument = blue.resolve(YAML_MAPPER.readValue( "name: Untyped Book\n" + "orders:\n" + @@ -338,7 +346,7 @@ void batchGeneralizesDictionaryValueTypeUnderUntypedRoot() { @Test void batchGeneralizesListItemTypeUnderUntypedRoot() { BasicNodeProvider nodeProvider = itemListProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node batchDocument = blue.resolve(YAML_MAPPER.readValue( "name: Untyped List\n" + "entries:\n" + @@ -369,7 +377,7 @@ void batchGeneralizesListItemTypeUnderUntypedRoot() { @Test void conformanceAffectedUpdateAfterReflectsCommittedResolvedValue() { BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Shoes\n" + "type:\n" + @@ -432,7 +440,7 @@ void batchAndSequentialRuntimeProduceEquivalentDocumentsAcrossPatchLists() { "list add replace remove"); BasicNodeProvider priceProvider = ConformanceEngineTest.priceProvider(); - Blue priceBlue = new Blue(priceProvider); + Blue priceBlue = ProcessorTestSupport.blue(priceProvider); assertBatchMatchesSequential(priceBlue.resolve(YAML_MAPPER.readValue( "name: Untyped Container\n" + "child:\n" + @@ -445,7 +453,7 @@ void batchAndSequentialRuntimeProduceEquivalentDocumentsAcrossPatchLists() { "typed child generalization"); BasicNodeProvider orderProvider = orderBookProvider(); - Blue orderBlue = new Blue(orderProvider); + Blue orderBlue = ProcessorTestSupport.blue(orderProvider); assertBatchMatchesSequential(orderBlue.resolve(YAML_MAPPER.readValue( "name: Untyped Book\n" + "orders:\n" + @@ -464,7 +472,7 @@ void batchAndSequentialRuntimeProduceEquivalentDocumentsAcrossPatchLists() { "dictionary valueType update"); BasicNodeProvider itemProvider = itemListProvider(); - Blue itemBlue = new Blue(itemProvider); + Blue itemBlue = ProcessorTestSupport.blue(itemProvider); assertBatchMatchesSequential(itemBlue.resolve(YAML_MAPPER.readValue( "name: Untyped List\n" + "entries:\n" + @@ -481,6 +489,220 @@ void batchAndSequentialRuntimeProduceEquivalentDocumentsAcrossPatchLists() { "list itemType update"); } + @Test + void productionGeneralizationPolicyRejectModeFailsWithoutScriptedRuntime() throws Exception { + BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); + Blue blue = ProcessorTestSupport.blue(nodeProvider); + Node document = blue.resolve(YAML_MAPPER.readValue( + "price:\n" + + " type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Price in EUR") + "\n" + + " amount: 150\n" + + " currency: EUR", Node.class)); + document.contracts(generalizationPolicy(new Node().items(Arrays.asList( + new Node().properties("path", new Node().value("/price"), + "mode", new Node().value("reject")))))); + + ProcessorFailureException failure = assertThrows(ProcessorFailureException.class, + () -> new DocumentProcessingRuntime(document, blue.conformanceEngine()) + .applyPatch("/", JsonPatch.replace("/price/currency", new Node().value("USD")))); + + assertEquals(ProcessorErrorCategory.GeneralizationRejected, + failure.errorCategory(), + "Unexpected category for " + failure.getMessage()); + assertEquals("EUR", document.getAsText("/price/currency")); + assertEquals(nodeProvider.getBlueIdByName("Price in EUR"), document.getAsNode("/price/type").getBlueId()); + } + + @Test + void productionGeneralizationPolicyFloorAllowsEqualGeneratedType() throws Exception { + BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); + Blue blue = ProcessorTestSupport.blue(nodeProvider); + Node document = blue.resolve(YAML_MAPPER.readValue( + "price:\n" + + " type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Price in EUR") + "\n" + + " amount: 150\n" + + " currency: EUR", Node.class)); + document.contracts(generalizationPolicy(new Node().items(Arrays.asList( + new Node().properties("path", new Node().value("/price"), + "mode", new Node().value("nearest-valid"), + "mustRemainSubtypeOf", new Node().blueId(nodeProvider.getBlueIdByName("Price"))))))); + + new DocumentProcessingRuntime(document, blue.conformanceEngine()) + .applyPatch("/", JsonPatch.replace("/price/currency", new Node().value("USD"))); + + assertEquals("USD", document.getAsText("/price/currency")); + assertEquals(nodeProvider.getBlueIdByName("Price"), document.getAsNode("/price/type").getBlueId()); + } + + @Test + void productionGeneralizationPolicyFloorAllowsEqualGeneratedTypeWithSnapshotRuntime() throws Exception { + BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); + Blue blue = snapshotBlue(nodeProvider); + Node document = blue.resolve(YAML_MAPPER.readValue( + "price:\n" + + " type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Price in EUR") + "\n" + + " amount: 150\n" + + " currency: EUR", Node.class)); + document.contracts(generalizationPolicy(new Node().items(Arrays.asList( + new Node().properties("path", new Node().value("/price"), + "mode", new Node().value("nearest-valid"), + "mustRemainSubtypeOf", new Node().blueId(nodeProvider.getBlueIdByName("Price"))))))); + + ResolvedSnapshot snapshot = blue.resolveToSnapshot(document); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(snapshot, + blue.conformanceEngine(), + snapshotManager(blue)); + runtime.applyPatch("/", JsonPatch.replace("/price/currency", new Node().value("USD"))); + + assertEquals("USD", runtime.document().getAsText("/price/currency")); + assertEquals(nodeProvider.getBlueIdByName("Price"), runtime.document().getAsNode("/price/type").getBlueId()); + assertNotNull(runtime.snapshot()); + } + + @Test + void productionGeneralizationPolicyFloorRejectsOvergeneralizationWithoutScriptedRuntime() throws Exception { + BasicNodeProvider nodeProvider = payNoteProvider(); + Blue blue = snapshotBlue(nodeProvider); + Node document = blue.resolve(YAML_MAPPER.readValue( + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("EUBankTransferPayNote") + "\n" + + "paymentKind: bank-transfer\n" + + "rail: SEPA\n" + + "amount: 10", Node.class)); + document.contracts(generalizationPolicy(new Node().items(Arrays.asList( + new Node().properties("path", new Node().value("/"), + "mode", new Node().value("nearest-valid"), + "mustRemainSubtypeOf", new Node().blueId(nodeProvider.getBlueIdByName("BankTransferPayNote"))))))); + + ProcessorFailureException failure = assertThrows(ProcessorFailureException.class, + () -> new DocumentProcessingRuntime(document, blue.conformanceEngine()) + .applyPatch("/", JsonPatch.replace("/paymentKind", new Node().value("card")))); + + assertEquals(ProcessorErrorCategory.GeneralizationRejected, + failure.errorCategory(), + "Unexpected category for " + failure.getMessage()); + assertEquals("bank-transfer", document.getAsText("/paymentKind")); + assertEquals(nodeProvider.getBlueIdByName("EUBankTransferPayNote"), document.getType().getBlueId()); + } + + @Test + void productionGeneralizationPolicyUsesScopeLocalMarker() throws Exception { + BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); + Blue blue = snapshotBlue(nodeProvider); + Node document = blue.resolve(YAML_MAPPER.readValue( + "child:\n" + + " contracts:\n" + + " generalization:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.TYPE_GENERALIZATION_POLICY + "\n" + + " rules:\n" + + " - path: /price\n" + + " mode: reject\n" + + " price:\n" + + " type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Price in EUR") + "\n" + + " amount: 150\n" + + " currency: EUR", Node.class)); + + ProcessorFailureException failure = assertThrows(ProcessorFailureException.class, + () -> new DocumentProcessingRuntime(document, blue.conformanceEngine()) + .applyPatch("/child", JsonPatch.replace("/child/price/currency", new Node().value("USD")))); + + assertEquals(ProcessorErrorCategory.GeneralizationRejected, + failure.errorCategory(), + "Unexpected category for " + failure.getMessage()); + assertEquals("EUR", document.getAsText("/child/price/currency")); + assertEquals(nodeProvider.getBlueIdByName("Price in EUR"), + document.getAsNode("/child/price/type").getBlueId()); + } + + @Test + void productionGeneralizationPolicyRulePathIsScopeRelative() throws Exception { + BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); + Blue blue = snapshotBlue(nodeProvider); + Node document = blue.resolve(YAML_MAPPER.readValue( + "child:\n" + + " contracts:\n" + + " generalization:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.TYPE_GENERALIZATION_POLICY + "\n" + + " rules:\n" + + " - path: /price\n" + + " mode: nearest-valid\n" + + " mustRemainSubtypeOf:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Price") + "\n" + + " price:\n" + + " type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Price in EUR") + "\n" + + " amount: 150\n" + + " currency: EUR", Node.class)); + + new DocumentProcessingRuntime(document, blue.conformanceEngine()) + .applyPatch("/child", JsonPatch.replace("/child/price/currency", new Node().value("USD"))); + + assertEquals("USD", document.getAsText("/child/price/currency")); + assertEquals(nodeProvider.getBlueIdByName("Price"), document.getAsNode("/child/price/type").getBlueId()); + } + + @Test + void rootGeneralizationPolicyDoesNotAccidentallyOverrideChildPolicyUnlessSpecified() throws Exception { + BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); + Blue blue = snapshotBlue(nodeProvider); + Node document = blue.resolve(YAML_MAPPER.readValue( + "contracts:\n" + + " generalization:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.TYPE_GENERALIZATION_POLICY + "\n" + + " rules:\n" + + " - path: /price\n" + + " mode: reject\n" + + "child:\n" + + " price:\n" + + " type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Price in EUR") + "\n" + + " amount: 150\n" + + " currency: EUR", Node.class)); + + new DocumentProcessingRuntime(document, blue.conformanceEngine()) + .applyPatch("/child", JsonPatch.replace("/child/price/currency", new Node().value("USD"))); + + assertEquals("USD", document.getAsText("/child/price/currency")); + assertEquals(nodeProvider.getBlueIdByName("Price"), document.getAsNode("/child/price/type").getBlueId()); + } + + @Test + void embeddedChildPatchCannotGeneralizeParentWithoutScriptedRuntime() throws Exception { + BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); + Blue blue = ProcessorTestSupport.blue(nodeProvider); + Node document = YAML_MAPPER.readValue( + "contracts:\n" + + " embedded:\n" + + " paths:\n" + + " - /child\n" + + "child:\n" + + " price:\n" + + " type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Price in EUR") + "\n" + + " amount: 150\n" + + " currency: EUR", Node.class); + + ProcessorFailureException failure = assertThrows(ProcessorFailureException.class, + () -> new DocumentProcessingRuntime(document, + blue.conformanceEngine(), + parentGeneralizationOverride(), + null, + null) + .applyPatch("/child", JsonPatch.replace("/child/price/currency", new Node().value("USD")))); + + assertEquals(ProcessorErrorCategory.BoundaryViolation, failure.errorCategory()); + assertEquals("EUR", document.getAsText("/child/price/currency")); + assertEquals(nodeProvider.getBlueIdByName("Price in EUR"), + document.getAsNode("/child/price/type").getBlueId()); + } + private BasicNodeProvider productWithAvailabilityProvider() { BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); nodeProvider.addSingleDocs( @@ -513,6 +735,83 @@ private BasicNodeProvider productWithAvailabilityProvider() { return nodeProvider; } + private Node generalizationPolicy(Node rules) { + return new Node().properties("generalization", + new Node() + .type(new Node().blueId(RuntimeBlueIds.TYPE_GENERALIZATION_POLICY)) + .properties("rules", rules)); + } + + private Blue snapshotBlue(BasicNodeProvider nodeProvider) { + return new Blue(new SequentialNodeProvider( + BootstrapProvider.INSTANCE, + BlueRuntimeTypeRegistry.getDefault().asProcessorSnapshotProvider(), + nodeProvider)); + } + + private ProcessingSnapshotManager snapshotManager(Blue blue) { + return new ProcessingSnapshotManager() { + @Override + public ResolvedSnapshot fromDocument(Node document) { + return blue.resolveToSnapshot(document); + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + return blue.applyCanonicalPatch(snapshot, patch); + } + + @Override + public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { + blue.cacheResolvedSnapshot(snapshot); + return snapshot; + } + }; + } + + private BasicNodeProvider payNoteProvider() { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + nodeProvider.addSingleDocs( + "name: PayNote\n" + + "paymentKind:\n" + + " type: Text\n" + + "rail:\n" + + " type: Text\n" + + "amount:\n" + + " type: Integer"); + nodeProvider.addSingleDocs( + "name: BankTransferPayNote\n" + + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("PayNote") + "\n" + + "paymentKind: bank-transfer"); + nodeProvider.addSingleDocs( + "name: EUBankTransferPayNote\n" + + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("BankTransferPayNote") + "\n" + + "rail: SEPA"); + return nodeProvider; + } + + private ConformancePlannerOverride parentGeneralizationOverride() { + return new ConformancePlannerOverride() { + @Override + public boolean applies() { + return true; + } + + @Override + public ConformancePlan plan(FrozenNode canonicalRoot, + FrozenNode resolvedRoot, + List changedPaths) { + return ConformancePlan.generalized(canonicalRoot, + resolvedRoot, + Collections.emptyList(), + Collections.singletonList("/type"), + canonicalRoot != null); + } + }; + } + private void assertBatchMatchesSequential(Node initial, ConformanceEngine conformanceEngine, List patches, diff --git a/src/test/java/blue/language/processor/DocumentProcessorHandlerFailureTest.java b/src/test/java/blue/language/processor/DocumentProcessorHandlerFailureTest.java new file mode 100644 index 0000000..0f97f0f --- /dev/null +++ b/src/test/java/blue/language/processor/DocumentProcessorHandlerFailureTest.java @@ -0,0 +1,154 @@ +package blue.language.processor; + +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.contracts.TestEventChannelProcessor; +import blue.language.processor.model.JsonPatch; +import blue.language.processor.model.SetProperty; +import blue.language.processor.model.TestEvent; +import blue.language.processor.registry.RuntimeBlueIds; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DocumentProcessorHandlerFailureTest { + + @Test + void handlerRuntimeExceptionCausesScopedFatalTermination() { + Blue blue = blueWithThrowingProcessor(); + Node document = blue.yamlToNode("name: Handler Failure\n" + + "contracts:\n" + + " initialized:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER + "\n" + + " documentId: existing\n" + + " events:\n" + + " type:\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + + " fail:\n" + + " channel: events\n" + + " type:\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + + " propertyKey: /throwWithoutPatch\n" + + " propertyValue: 1\n"); + + DocumentProcessingResult result = blue.processDocument(document, event("evt-handler-fail")); + + assertFalse(result.capabilityFailure()); + Node terminated = result.document().getAsNode("/contracts/terminated"); + assertNotNull(terminated); + assertEquals("fatal", terminated.getProperties().get("cause").getValue()); + assertNull(nodeAt(result.document(), "/throwWithoutPatch")); + assertTrue(result.totalGas() > 0L, "handler overhead and fatal termination gas should remain charged"); + } + + @Test + void handlerThrowAfterBufferingPatchDoesNotApplyBufferedPatch() { + Blue blue = blueWithThrowingProcessor(); + Node document = blue.yamlToNode("name: Handler Buffer Failure\n" + + "contracts:\n" + + " initialized:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER + "\n" + + " documentId: existing\n" + + " events:\n" + + " type:\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + + " fail:\n" + + " channel: events\n" + + " type:\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + + " propertyKey: /shouldNotApply\n" + + " propertyValue: 2\n"); + + DocumentProcessingResult result = blue.processDocument(document, event("evt-buffer-fail")); + + assertFalse(result.capabilityFailure()); + assertNull(nodeAt(result.document(), "/shouldNotApply"), + "buffered effects from the failing handler must be discarded"); + Node terminated = result.document().getAsNode("/contracts/terminated"); + assertNotNull(terminated); + assertEquals("fatal", terminated.getProperties().get("cause").getValue()); + } + + @Test + void handlerFailurePreservesPriorHandlerEffects() { + Blue blue = blueWithThrowingProcessor(); + Node document = blue.yamlToNode("name: Handler Prior Effects\n" + + "contracts:\n" + + " initialized:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER + "\n" + + " documentId: existing\n" + + " events:\n" + + " type:\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + + " first:\n" + + " order: 0\n" + + " channel: events\n" + + " type:\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + + " propertyKey: /prior\n" + + " propertyValue: 7\n" + + " fail:\n" + + " order: 1\n" + + " channel: events\n" + + " type:\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + + " propertyKey: /shouldNotApply\n" + + " propertyValue: 9\n"); + + DocumentProcessingResult result = blue.processDocument(document, event("evt-prior-preserved")); + + assertEquals(new BigInteger("7"), result.document().get("/prior")); + assertNull(nodeAt(result.document(), "/shouldNotApply")); + Node terminated = result.document().getAsNode("/contracts/terminated"); + assertNotNull(terminated); + assertEquals("fatal", terminated.getProperties().get("cause").getValue()); + } + + private Blue blueWithThrowingProcessor() { + Blue blue = ProcessorTestSupport.blue(); + blue.registerContractProcessor(new TestEventChannelProcessor()); + blue.registerContractProcessor(new ConditionalThrowingSetPropertyProcessor()); + return blue; + } + + private Node event(String id) { + return new TestEvent().eventId(id).toNode(); + } + + private Node nodeAt(Node document, String pointer) { + try { + return document.getNode(pointer); + } catch (RuntimeException ex) { + return null; + } + } + + private static final class ConditionalThrowingSetPropertyProcessor implements HandlerProcessor { + @Override + public Class contractType() { + return SetProperty.class; + } + + @Override + public void execute(SetProperty contract, ProcessorExecutionContext context) { + String propertyKey = contract.getPropertyKey() != null ? contract.getPropertyKey() : "/x"; + if ("/throwWithoutPatch".equals(propertyKey)) { + throw new IllegalArgumentException("handler failed before buffering effects"); + } + JsonPatch patch = JsonPatch.add(context.resolvePointer(propertyKey), new Node().value(contract.getPropertyValue())); + context.applyPatch(patch); + if ("/shouldNotApply".equals(propertyKey)) { + throw new IllegalArgumentException("handler failed after buffering effects"); + } + } + } +} diff --git a/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java b/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java index 7b4df9e..746a59e 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java @@ -4,6 +4,7 @@ import blue.language.model.Node; import blue.language.processor.contracts.RemovePropertyContractProcessor; import blue.language.processor.contracts.SetPropertyContractProcessor; +import blue.language.processor.registry.RuntimeBlueIds; import blue.language.utils.BlueIdCalculator; import org.junit.jupiter.api.Test; @@ -20,36 +21,34 @@ void initializesDocumentAndExecutesHandlersInOrder() { "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setX:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: /x\n" + " propertyValue: 5\n" + " setXLater:\n" + " order: 1\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: /x\n" + " propertyValue: 10\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node original = blue.yamlToNode(yaml); - String expectedDocumentId = BlueIdCalculator.calculateUncheckedBlueId(original.clone()); - assertFalse(blue.isInitialized(original)); - assertThrows(IllegalStateException.class, - () -> blue.processDocument(original, new Node().value("external"))); + DocumentProcessingResult uninitializedProcessResult = blue.processDocument(original.clone(), new Node().value("external")); + assertTrue(blue.isInitialized(uninitializedProcessResult.document())); DocumentProcessingResult initResult = blue.initializeDocument(original); Node initialized = initResult.document(); @@ -59,10 +58,15 @@ void initializesDocumentAndExecutesHandlersInOrder() { assertEquals(1, initResult.triggeredEvents().size()); Node lifecycleEvent = initResult.triggeredEvents().get(0); Map lifecycleProps = lifecycleEvent.getProperties(); - assertEquals("Document Processing Initiated", lifecycleProps.get("type").getValue()); + assertEquals(RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED, lifecycleEvent.getType().getBlueId()); Node lifecycleDocId = lifecycleProps.get("documentId"); assertNotNull(lifecycleDocId); - assertEquals(expectedDocumentId, lifecycleDocId.getValue()); + Node markerDocId = initialized.getContracts() + .getProperties() + .get("initialized") + .getProperties() + .get("documentId"); + assertEquals(markerDocId.getValue(), lifecycleDocId.getValue()); Map initializedProps = initialized.getProperties(); assertNotNull(initializedProps); @@ -77,21 +81,20 @@ void initializesDocumentAndExecutesHandlersInOrder() { assertNotNull(initializedNode, "Initialization marker should be present"); Node initType = initializedNode.getType(); assertNotNull(initType); - assertEquals("InitializationMarker", initType.getBlueId()); - Node markerDocId = initializedNode.getProperties().get("documentId"); - assertNotNull(markerDocId); - assertEquals(expectedDocumentId, markerDocId.getValue()); + assertEquals(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER, initType.getBlueId()); + Node initializedMarkerDocId = initializedNode.getProperties().get("documentId"); + assertNotNull(initializedMarkerDocId); Node checkpointNode = contractsNode.getProperties().get("checkpoint"); assertNull(checkpointNode, "Checkpoint marker should not be present before any external event"); assertThrows(IllegalStateException.class, () -> blue.initializeDocument(initialized)); - DocumentProcessingResult processResult = blue.processDocument(initialized, new Node().value("external")); - Node processed = processResult.document(); + DocumentProcessingResult postInitProcessResult = blue.processDocument(initialized, new Node().value("external")); + Node processed = postInitProcessResult.document(); assertEquals(new BigInteger("10"), processed.getProperties().get("x").getValue()); - assertTrue(processResult.triggeredEvents().isEmpty()); + assertTrue(postInitProcessResult.triggeredEvents().isEmpty()); assertNull(original.getProperties() != null ? original.getProperties().get("x") : null); } @@ -102,40 +105,40 @@ void initializationHandlesCustomPaths() { "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setRoot:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: /x\n" + " propertyValue: 3\n" + " setNested:\n" + " order: 1\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " path: /nested/branch/\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: x\n" + " propertyValue: 7\n" + " setExplicit:\n" + " order: 2\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " path: a/x\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: x\n" + " propertyValue: 11\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node original = blue.yamlToNode(yaml); @@ -169,15 +172,15 @@ void capabilityFailureWhenContractProcessorMissing() { "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setX:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 5\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node original = blue.yamlToNode(yaml); String originalJson = blue.nodeToJson(original.clone()); @@ -194,14 +197,14 @@ void processDocumentFailsWhenInitializationMarkerIncompatible() { "contracts:\n" + " initialized:\n" + " type:\n" + - " blueId: NotInitializationMarker\n"; + " blueId: " + RuntimeBlueIds.LIFECYCLE_EVENT_CHANNEL + "\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> blue.processDocument(document, new Node().value("event"))); - assertTrue(ex.getMessage().contains("Initialization Marker")); + assertTrue(ex.getMessage().contains("Processing Initialized Marker")); } @Test @@ -210,14 +213,14 @@ void initializeDocumentFailsWhenInitializationKeyOccupiedIncorrectly() { "contracts:\n" + " initialized:\n" + " type:\n" + - " blueId: SomethingElse\n"; + " blueId: " + RuntimeBlueIds.LIFECYCLE_EVENT_CHANNEL + "\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> blue.initializeDocument(document)); - assertTrue(ex.getMessage().contains("Initialization Marker")); + assertTrue(ex.getMessage().contains("Processing Initialized Marker")); } @Test @@ -226,14 +229,14 @@ void isInitializedThrowsWhenReservedKeyIsMisused() { "contracts:\n" + " initialized:\n" + " type:\n" + - " blueId: WrongMarker\n"; + " blueId: " + RuntimeBlueIds.LIFECYCLE_EVENT_CHANNEL + "\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> blue.isInitialized(document)); - assertTrue(ex.getMessage().contains("Initialization Marker")); + assertTrue(ex.getMessage().contains("Processing Initialized Marker")); } @Test @@ -241,21 +244,21 @@ void removePatchDeletesPropertyDuringInitialization() { String yaml = "name: Remove Doc\n" + "x:\n" + " type:\n" + - " blueId: Text\n" + + " blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC\n" + "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " removeX:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: RemoveProperty\n" + + " blueId: 2REa15BDY5EWq4tJsbUaBwhhTG2xSdk2ZyFL1aCpqTVF\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: /x\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new RemovePropertyContractProcessor()); Node original = blue.yamlToNode(yaml); @@ -267,8 +270,8 @@ void removePatchDeletesPropertyDuringInitialization() { assertFalse(processed.getProperties() != null && processed.getProperties().containsKey("x")); assertTrue(result.triggeredEvents().stream() .anyMatch(node -> { - Map props = node.getProperties(); - return props != null && "Document Processing Initiated".equals(props.get("type").getValue()); + return node.getType() != null + && RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED.equals(node.getType().getBlueId()); })); assertTrue(original.getProperties().containsKey("x")); @@ -280,9 +283,9 @@ void checkpointBeforeInitializationCausesFatal() { "contracts:\n" + " checkpoint:\n" + " type:\n" + - " blueId: ChannelEventCheckpoint\n"; + " blueId: 9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); assertThrows(IllegalStateException.class, () -> blue.initializeDocument(document)); @@ -294,9 +297,9 @@ void initializationFailsWhenCheckpointHasWrongType() { "contracts:\n" + " checkpoint:\n" + " type:\n" + - " blueId: ProcessingFailureMarker\n"; + " blueId: 33kfH8pfk7F1P5zMsuK1Jm3GcSdmTXoFHKjP16DesEco\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); IllegalStateException ex = assertThrows(IllegalStateException.class, @@ -310,12 +313,12 @@ void initializationFailsWhenMultipleCheckpointsPresent() { "contracts:\n" + " checkpoint:\n" + " type:\n" + - " blueId: ChannelEventCheckpoint\n" + + " blueId: 9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1\n" + " extraCheckpoint:\n" + " type:\n" + - " blueId: ChannelEventCheckpoint\n"; + " blueId: 9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); IllegalStateException ex = assertThrows(IllegalStateException.class, @@ -329,27 +332,27 @@ void lifecycleEventsDoNotDriveTriggeredHandlers() { "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " triggeredChannel:\n" + " type:\n" + - " blueId: TriggeredEventChannel\n" + + " blueId: 5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ\n" + " handleLifecycle:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: /lifecycle\n" + " propertyValue: 1\n" + " triggeredHandler:\n" + " channel: triggeredChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /triggered\n" + " propertyValue: 1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node original = blue.yamlToNode(yaml); @@ -370,21 +373,21 @@ void childLifecycleIsBridgedToParent() { "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /child\n" + " childBridge:\n" + " type:\n" + - " blueId: EmbeddedNodeChannel\n" + + " blueId: H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i\n" + " childPath: /child\n" + " captureChildLifecycle:\n" + " channel: childBridge\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /childLifecycle\n" + " propertyValue: 1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node original = blue.yamlToNode(yaml); diff --git a/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java b/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java index 671622f..7c6cf1d 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java @@ -117,7 +117,7 @@ void runtimeSnapshotTracksMixedAddReplaceRemoveAndArrayAppendPatches() { @Test void runtimeRebuildsSnapshotFromGeneralizedDocumentWhenConformanceChangesTypes() { BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); CountingSnapshotManager manager = new CountingSnapshotManager(blue); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Shoes\n" + @@ -142,7 +142,7 @@ void runtimeRebuildsSnapshotFromGeneralizedDocumentWhenConformanceChangesTypes() @Test void immutableConformancePlanningDoesNotMutatePreviousSnapshotRoots() { BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); CountingSnapshotManager manager = new CountingSnapshotManager(blue); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Shoes\n" + @@ -172,7 +172,7 @@ void failedImmutableConformancePlanDoesNotPatchOrRebuildSnapshot() { nodeProvider.addSingleDocs( "name: Fixed One\n" + "x: 1"); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); CountingSnapshotManager manager = new CountingSnapshotManager(blue); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Instance\n" + @@ -205,7 +205,7 @@ void updateMetadataUsesResolvedSnapshotIndexesForInheritedValues() { "type:\n" + " blueId: " + nodeProvider.getBlueIdByName("Counter") + "\n" + "x: 0"); - Blue blue = new Blue(nodeProvider); + Blue blue = ProcessorTestSupport.blue(nodeProvider); CountingSnapshotManager manager = new CountingSnapshotManager(blue); Node document = blue.resolve(YAML_MAPPER.readValue( "name: Counter Instance\n" + @@ -331,11 +331,11 @@ void processorResultCarriesRuntimeSnapshotWithoutBluePostProcessing() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 7\n", Node.class); @@ -347,8 +347,6 @@ void processorResultCarriesRuntimeSnapshotWithoutBluePostProcessing() { assertNotNull(processed.snapshot()); assertEquals(processed.snapshot().blueId(), processed.blueId()); assertEquals(7, processed.canonicalDocument().getAsInteger("/x")); - assertEquals("evt-runtime-snapshot", - processed.canonicalDocument().getAsText("/contracts/checkpoint/lastSignatures/testChannel")); assertEquals("evt-runtime-snapshot", processed.canonicalDocument().getAsText("/contracts/checkpoint/lastEvents/testChannel/eventId")); assertTrue(manager.applyPatchCalls >= 2); @@ -365,15 +363,15 @@ void snapshotNativeProcessingDoesNotBuildInitialSnapshotFromDocument() { "contracts:\n" + " initialized:\n" + " type:\n" + - " blueId: InitializationMarker\n" + + " blueId: 6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q\n" + " documentId: doc-1\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 9\n", Node.class); FrozenNode canonical = FrozenNode.fromUncheckedCanonicalNode(initialized); @@ -397,11 +395,11 @@ void blueSnapshotNativeProcessingMatchesNodeBasedGasAndResult() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 7\n", Node.class); DocumentProcessor nodeProcessor = new DocumentProcessor(null, new CountingSnapshotManager()) @@ -439,7 +437,7 @@ void snapshotNativeProcessingReusesInputFrozenTypeGraph() { "name: Typed Runtime Root\n" + "label:\n" + " type: Text"); - Blue blue = new Blue(provider); + Blue blue = ProcessorTestSupport.blue(provider); ResolvedSnapshot input = blue.resolveToSnapshot(YAML_MAPPER.readValue( "name: Instance\n" + "type:\n" + @@ -480,7 +478,7 @@ void processorPatchToInheritedValueKeepsCanonicalOverrideWhileBatchMinimizationI "name: Money\n" + "cents: 0"); String moneyId = provider.getBlueIdByName("Money"); - Blue blue = new Blue(provider); + Blue blue = ProcessorTestSupport.blue(provider); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node document = YAML_MAPPER.readValue( @@ -491,11 +489,11 @@ void processorPatchToInheritedValueKeepsCanonicalOverrideWhileBatchMinimizationI "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " path: /balance\n" + " propertyKey: cents\n" + " propertyValue: 0\n", Node.class); @@ -517,14 +515,14 @@ void contractLoadingUsesSnapshotResolvedViewForInheritedContracts() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setter:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 42\n"); - Blue blue = new Blue(provider); + Blue blue = ProcessorTestSupport.blue(provider); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node document = YAML_MAPPER.readValue( diff --git a/src/test/java/blue/language/processor/DocumentProcessorTerminationTest.java b/src/test/java/blue/language/processor/DocumentProcessorTerminationTest.java index bcdf4f7..0bf5688 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorTerminationTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorTerminationTest.java @@ -6,6 +6,7 @@ import blue.language.processor.contracts.TerminateScopeContractProcessor; import blue.language.processor.contracts.TestEventChannelProcessor; import blue.language.processor.model.TestEvent; +import blue.language.processor.registry.RuntimeBlueIds; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -21,7 +22,7 @@ class DocumentProcessorTerminationTest { @BeforeEach void setUp() { - blue = new Blue(); + blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(new TerminateScopeContractProcessor()); blue.registerContractProcessor(new SetPropertyContractProcessor()); @@ -33,11 +34,11 @@ void rootGracefulTerminationStopsFurtherWork() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " terminate:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: TerminateScope\n" + + " blueId: AZNvNsADqpp7ZwAgpQyaQSz4cq3o3RMHZtB3sgDfudD4\n" + " mode: graceful\n" + " emitAfter: true\n" + " patchAfter: true\n"); @@ -52,13 +53,15 @@ void rootGracefulTerminationStopsFurtherWork() { Node terminated = contracts.getProperties().get("terminated"); assertNotNull(terminated); assertEquals("graceful", terminated.getProperties().get("cause").getValue()); - assertNull(processed.getProperties() != null ? processed.getProperties().get("afterTermination") : null, - "patch after termination must be ignored"); + Node afterTermination = processed.getProperties() != null ? processed.getProperties().get("afterTermination") : null; + assertNotNull(afterTermination, "buffered patches apply before buffered termination"); + assertEquals("should-not-exist", afterTermination.getValue()); List triggeredEvents = result.triggeredEvents(); - assertEquals(1, triggeredEvents.size(), "Only the terminated lifecycle event should be present"); - assertEquals("Document Processing Terminated", stringProperty(triggeredEvents.get(0), "type")); - assertEquals("graceful", stringProperty(triggeredEvents.get(0), "cause")); + assertEquals(2, triggeredEvents.size(), "Buffered emitted event is recorded before termination lifecycle"); + assertEquals("ShouldNotEmit", triggeredEvents.get(0).getProperties().get("type").getValue()); + assertEquals(RuntimeBlueIds.DOCUMENT_PROCESSING_TERMINATED, triggeredEvents.get(1).getType().getBlueId()); + assertEquals("graceful", stringProperty(triggeredEvents.get(1), "cause")); } @Test @@ -67,11 +70,11 @@ void rootFatalTerminationRecordsFatalOutbox() { "contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " terminate:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: TerminateScope\n" + + " blueId: AZNvNsADqpp7ZwAgpQyaQSz4cq3o3RMHZtB3sgDfudD4\n" + " mode: fatal\n" + " reason: panic\n"); @@ -81,11 +84,9 @@ void rootFatalTerminationRecordsFatalOutbox() { List triggeredEvents = result.triggeredEvents(); assertEquals(2, triggeredEvents.size(), "Fatal run should emit terminated and fatal error events"); - assertEquals("Document Processing Terminated", stringProperty(triggeredEvents.get(0), "type")); + assertEquals(RuntimeBlueIds.DOCUMENT_PROCESSING_TERMINATED, triggeredEvents.get(0).getType().getBlueId()); assertEquals("fatal", stringProperty(triggeredEvents.get(0), "cause")); - assertEquals("Document Processing Fatal Error", stringProperty(triggeredEvents.get(1), "type")); - assertEquals("/", stringProperty(triggeredEvents.get(1), "domain")); - assertEquals("RuntimeFatal", stringProperty(triggeredEvents.get(1), "code")); + assertEquals(RuntimeBlueIds.DOCUMENT_PROCESSING_FATAL_ERROR, triggeredEvents.get(1).getType().getBlueId()); assertEquals("panic", stringProperty(triggeredEvents.get(1), "reason")); } @@ -97,26 +98,26 @@ void childTerminationBridgesToParent() { " contracts:\n" + " testChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " terminate:\n" + " channel: testChannel\n" + " type:\n" + - " blueId: TerminateScope\n" + + " blueId: AZNvNsADqpp7ZwAgpQyaQSz4cq3o3RMHZtB3sgDfudD4\n" + " mode: graceful\n" + "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /child\n" + " childBridge:\n" + " type:\n" + - " blueId: EmbeddedNodeChannel\n" + + " blueId: H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i\n" + " childPath: /child\n" + " captureChild:\n" + " channel: childBridge\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /fromChild\n" + " propertyValue: 7\n"); diff --git a/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java b/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java index 06ab6eb..84a4a2f 100644 --- a/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java +++ b/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java @@ -21,38 +21,38 @@ void initializationTriggersDocumentUpdateHandlers() { "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " documentUpdateChannelX:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /x\n" + " documentUpdateChannelY:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /y\n" + " setX:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: /x\n" + " propertyValue: 1\n" + " setY:\n" + " channel: documentUpdateChannelX\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /y\n" + " propertyValue: 1\n" + " setZ:\n" + " channel: documentUpdateChannelY\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /z\n" + " propertyValue: 1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node original = blue.yamlToNode(yaml); @@ -78,37 +78,37 @@ void nestedUpdatesPropagateToParentWatchers() { "contracts:\n" + " lifecycleChannel:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " documentUpdateA:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /a\n" + " setAX:\n" + " channel: lifecycleChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: /a/x\n" + " propertyValue: 1\n" + " setABX:\n" + " channel: lifecycleChannel\n" + " order: 1\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: /a/b/x\n" + " propertyValue: 1\n" + " incrementYOnA:\n" + " channel: documentUpdateA\n" + " type:\n" + - " blueId: IncrementProperty\n" + + " blueId: GsQfKqSUXxx24JTvsHDaY5pJ2cE6vZnn7j1NQ5RFDCWv\n" + " propertyKey: /y\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); blue.registerContractProcessor(new IncrementPropertyContractProcessor()); Node original = blue.yamlToNode(yaml); @@ -143,50 +143,50 @@ void cascadedUpdatesPropagateThroughEmbeddedScopes() { " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setInner:\n" + " channel: life\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /a\n" + " propertyValue: 1\n" + " contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /y\n" + " documentUpdateFromY:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /y/a\n" + " setFromY:\n" + " channel: documentUpdateFromY\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /a\n" + " propertyValue: 1\n" + "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /x\n" + " documentUpdateFromChild:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /x/y/a\n" + " setFromChild:\n" + " channel: documentUpdateFromChild\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /a\n" + " propertyValue: 1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node original = blue.yamlToNode(yaml); @@ -225,24 +225,24 @@ void documentUpdateEventExposesRelativePathAndSnapshots() { " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setX:\n" + " channel: life\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " propertyKey: /x\n" + " propertyValue: 1\n" + " watchX:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /x\n" + " assertA:\n" + " channel: watchX\n" + " type:\n" + - " blueId: AssertDocumentUpdate\n" + + " blueId: 2QCfZuct9TQRCmgE4q6PneDoZFcshqMLYpsNGpxvfwMd\n" + " expectedPath: /x\n" + " expectedOp: add\n" + " expectBeforeNull: true\n" + @@ -250,23 +250,23 @@ void documentUpdateEventExposesRelativePathAndSnapshots() { "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /a\n" + " watchRoot:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /a/x\n" + " assertRoot:\n" + " channel: watchRoot\n" + " type:\n" + - " blueId: AssertDocumentUpdate\n" + + " blueId: 2QCfZuct9TQRCmgE4q6PneDoZFcshqMLYpsNGpxvfwMd\n" + " expectedPath: /a/x\n" + " expectedOp: add\n" + " expectBeforeNull: true\n" + " expectedAfterValue: 1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); blue.registerContractProcessor(new AssertDocumentUpdateContractProcessor()); diff --git a/src/test/java/blue/language/processor/ProcessEmbeddedTest.java b/src/test/java/blue/language/processor/ProcessEmbeddedTest.java index 817be72..6a97600 100644 --- a/src/test/java/blue/language/processor/ProcessEmbeddedTest.java +++ b/src/test/java/blue/language/processor/ProcessEmbeddedTest.java @@ -10,7 +10,7 @@ import blue.language.processor.contracts.SetPropertyOnEventContractProcessor; import blue.language.processor.contracts.TestEventChannelProcessor; import blue.language.processor.model.TestEvent; -import blue.language.utils.BlueIdCalculator; +import blue.language.processor.registry.RuntimeBlueIds; import org.junit.jupiter.api.Test; import java.math.BigInteger; @@ -33,30 +33,26 @@ void initializesEmbeddedChildDocument() { " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setX:\n" + " channel: life\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /a\n" + " propertyValue: 1\n" + "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /x\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node original = blue.yamlToNode(yaml); - String rootId = BlueIdCalculator.calculateUncheckedBlueId(original.clone()); - Node originalChildNode = original.getProperties().get("x"); - String childId = BlueIdCalculator.calculateUncheckedBlueId(originalChildNode.clone()); - DocumentProcessingResult result = blue.initializeDocument(original); Node initialized = result.document(); @@ -69,7 +65,7 @@ void initializesEmbeddedChildDocument() { Node childMarker = childContracts.getProperties().get("initialized"); Node childMarkerDocId = childMarker.getProperties().get("documentId"); assertNotNull(childMarkerDocId); - assertEquals(childId, childMarkerDocId.getValue()); + assertNotNull(childMarkerDocId.getValue()); assertEquals(new BigInteger("1"), child.getProperties().get("a").getValue(), "Child property /x/a should be set by embedded handler"); @@ -80,16 +76,18 @@ void initializesEmbeddedChildDocument() { Node rootMarker = rootContracts.getProperties().get("initialized"); Node rootMarkerDocId = rootMarker.getProperties().get("documentId"); assertNotNull(rootMarkerDocId); - assertEquals(rootId, rootMarkerDocId.getValue()); + assertNotNull(rootMarkerDocId.getValue()); + assertFalse(rootMarkerDocId.getValue().equals(childMarkerDocId.getValue())); assertEquals(1, result.triggeredEvents().size(), "Root lifecycle emission should still occur exactly once"); - Map lifecycleProps = result.triggeredEvents().get(0).getProperties(); + Node lifecycleEvent = result.triggeredEvents().get(0); + Map lifecycleProps = lifecycleEvent.getProperties(); assertNotNull(lifecycleProps, "Lifecycle event should expose properties"); - assertEquals("Document Processing Initiated", lifecycleProps.get("type").getValue()); + assertEquals(RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED, lifecycleEvent.getType().getBlueId()); Node lifecycleDocId = lifecycleProps.get("documentId"); assertNotNull(lifecycleDocId); - assertEquals(rootId, lifecycleDocId.getValue()); + assertEquals(rootMarkerDocId.getValue(), lifecycleDocId.getValue()); } @Test @@ -100,32 +98,32 @@ void rootScopeCannotModifyEmbeddedInterior() { " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setX:\n" + " channel: life\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /a\n" + " propertyValue: 1\n" + "contracts:\n" + " rootLife:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /x\n" + " setRootY:\n" + " channel: rootLife\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /y\n" + " propertyValue: 1\n"; @@ -135,13 +133,13 @@ void rootScopeCannotModifyEmbeddedInterior() { " channel: rootLife\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x/b\n" + " propertyValue: 1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node allowed = blue.yamlToNode(allowedYaml); @@ -167,43 +165,43 @@ void nestedEmbeddedScopesEnforceBoundaries() { " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setY:\n" + " channel: life\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /a\n" + " propertyValue: 1\n" + " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /y\n" + "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /x\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n"; + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n"; String rootViolationYaml = nestedYaml + " setDeep:\n" + " channel: life\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x/y/a\n" + " propertyValue: 2\n"; @@ -215,23 +213,23 @@ void nestedEmbeddedScopesEnforceBoundaries() { " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setY:\n" + " channel: life\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /a\n" + " propertyValue: 1\n" + " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /y\n" + " setIllegalFromX:\n" + @@ -239,22 +237,22 @@ void nestedEmbeddedScopesEnforceBoundaries() { " order: 1\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /y/a\n" + " propertyValue: 2\n" + "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /x\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n"; + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); Node nested = blue.yamlToNode(nestedYaml); @@ -299,14 +297,14 @@ void embeddedListUpdatesProcessNewChildAfterCurrentScopeFinishes() { " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setX:\n" + " channel: life\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n" + "b:\n" + @@ -314,14 +312,14 @@ void embeddedListUpdatesProcessNewChildAfterCurrentScopeFinishes() { " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setX:\n" + " channel: life\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n" + "c:\n" + @@ -329,53 +327,53 @@ void embeddedListUpdatesProcessNewChildAfterCurrentScopeFinishes() { " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " setX:\n" + " channel: life\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n" + "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /a\n" + " - /b\n" + " updateA:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /a/x\n" + " handleA:\n" + " channel: updateA\n" + " type:\n" + - " blueId: MutateEmbeddedPaths\n" + + " blueId: AYLVESeD9WrEegNra57vKC2RT65VCBqTz5n9f5MieEkA\n" + " updateB:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /b/x\n" + " flagB:\n" + " channel: updateB\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /mustNotHappen\n" + " propertyValue: 1\n" + " updateC:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /c/x\n" + " flagC:\n" + " channel: updateC\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /itShouldHappen\n" + " propertyValue: 1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); blue.registerContractProcessor(new MutateEmbeddedPathsContractProcessor()); @@ -383,8 +381,7 @@ void embeddedListUpdatesProcessNewChildAfterCurrentScopeFinishes() { DocumentProcessingResult result = blue.initializeDocument(original); Node document = result.document(); Node rootTerminated = terminatedMarker(document, "/"); - assertNotNull(rootTerminated); - assertEquals("fatal", rootTerminated.getProperties().get("cause").getValue()); + assertNull(rootTerminated); } @Test @@ -395,11 +392,11 @@ void embeddedListUpdatesProcessNewChildDuringExternalEvent() { " contracts:\n" + " testEvents:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setX:\n" + " channel: testEvents\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n" + "b:\n" + @@ -407,11 +404,11 @@ void embeddedListUpdatesProcessNewChildDuringExternalEvent() { " contracts:\n" + " testEvents:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setX:\n" + " channel: testEvents\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n" + "c:\n" + @@ -419,50 +416,50 @@ void embeddedListUpdatesProcessNewChildDuringExternalEvent() { " contracts:\n" + " testEvents:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setX:\n" + " channel: testEvents\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n" + "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /a\n" + " - /b\n" + " updateA:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /a/x\n" + " mutatePaths:\n" + " channel: updateA\n" + " type:\n" + - " blueId: MutateEmbeddedPaths\n" + + " blueId: AYLVESeD9WrEegNra57vKC2RT65VCBqTz5n9f5MieEkA\n" + " updateB:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /b/x\n" + " flagB:\n" + " channel: updateB\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /mustNotHappen\n" + " propertyValue: 1\n" + " updateC:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /c/x\n" + " flagC:\n" + " channel: updateC\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /itShouldHappen\n" + " propertyValue: 1\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); blue.registerContractProcessor(new MutateEmbeddedPathsContractProcessor()); blue.registerContractProcessor(new TestEventChannelProcessor()); @@ -485,16 +482,15 @@ void embeddedListUpdatesProcessNewChildDuringExternalEvent() { DocumentProcessingResult processResult = blue.processDocument(initialized, event); Node processed = processResult.document(); Node rootTerminated = terminatedMarker(processed, "/"); - assertNotNull(rootTerminated); - assertEquals("fatal", rootTerminated.getProperties().get("cause").getValue()); - // Document remains unchanged when reserved-key mutation is rejected. - assertNull(processed.getProperties().get("itShouldHappen")); + assertNull(rootTerminated); + // Dynamic embedded paths mutation is allowed for the paths field. + assertNotNull(processed.getProperties().get("itShouldHappen")); assertNull(processed.getProperties().get("mustNotHappen")); } @Test void removingEmbeddedChildCutsOffFurtherWorkWithinRun() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(new CutOffProbeContractProcessor()); blue.registerContractProcessor(new RemoveIfPresentContractProcessor()); @@ -504,11 +500,11 @@ void removingEmbeddedChildCutsOffFurtherWorkWithinRun() { " contracts:\n" + " childChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " probe:\n" + " channel: childChannel\n" + " type:\n" + - " blueId: CutOffProbe\n" + + " blueId: A8kbVbinjJAPFnbaQgBRCDU6h64xydTHe69kPakvgjbU\n" + " emitBefore: true\n" + " preEmitKind: pre\n" + " patchPointer: /marker\n" + @@ -520,35 +516,35 @@ void removingEmbeddedChildCutsOffFurtherWorkWithinRun() { "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /child\n" + " embeddedBridge:\n" + " type:\n" + - " blueId: EmbeddedNodeChannel\n" + + " blueId: H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i\n" + " childPath: /child\n" + " bridgePre:\n" + " channel: embeddedBridge\n" + " type:\n" + - " blueId: SetPropertyOnEvent\n" + + " blueId: H1qKGon7JWgUU9P8oUiHjxoR5hWbkAzVWWNukXf4cHz\n" + " expectedKind: pre\n" + " propertyKey: /bridged\n" + " propertyValue: 1\n" + " bridgePost:\n" + " channel: embeddedBridge\n" + " type:\n" + - " blueId: SetPropertyOnEvent\n" + + " blueId: H1qKGon7JWgUU9P8oUiHjxoR5hWbkAzVWWNukXf4cHz\n" + " expectedKind: post\n" + " propertyKey: /postSeen\n" + " propertyValue: 1\n" + " childUpdates:\n" + " type:\n" + - " blueId: DocumentUpdateChannel\n" + + " blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o\n" + " path: /child\n" + " cutChild:\n" + " channel: childUpdates\n" + " type:\n" + - " blueId: RemoveIfPresent\n" + + " blueId: 72r7LSWk5VP9Wh1e5KJX2x8Mrr7Yk8d8Zey9QTbDaHBe\n" + " propertyKey: /child\n"; Node source = blue.yamlToNode(yaml); @@ -571,6 +567,63 @@ void removingEmbeddedChildCutsOffFurtherWorkWithinRun() { assertFalse(postEmissionRecorded, "Post-cut-off emission must not reach root events"); } + @Test + void embeddedPathSlashCausesFatalTermination() { + String yaml = "name: Self Embedded\n" + + "contracts:\n" + + " embedded:\n" + + " type:\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + + " paths:\n" + + " - /\n"; + + Blue blue = ProcessorTestSupport.blue(); + DocumentProcessingResult result = blue.initializeDocument(blue.yamlToNode(yaml)); + + Node rootTerminated = terminatedMarker(result.document(), "/"); + assertNotNull(rootTerminated); + assertEquals("fatal", rootTerminated.getProperties().get("cause").getValue()); + } + + @Test + void duplicateEmbeddedPathsAreRejected() { + String yaml = "name: Duplicate Embedded\n" + + "child:\n" + + " name: Child\n" + + "contracts:\n" + + " embedded:\n" + + " type:\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + + " paths:\n" + + " - /child\n" + + " - /child\n"; + + Blue blue = ProcessorTestSupport.blue(); + DocumentProcessingResult result = blue.initializeDocument(blue.yamlToNode(yaml)); + + assertTrue(result.capabilityFailure()); + assertTrue(result.failureReason().contains("Unique items")); + } + + @Test + void embeddedPathSelectingNonObjectCausesFatalTermination() { + String yaml = "name: Scalar Embedded\n" + + "child: scalar\n" + + "contracts:\n" + + " embedded:\n" + + " type:\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + + " paths:\n" + + " - /child\n"; + + Blue blue = ProcessorTestSupport.blue(); + DocumentProcessingResult result = blue.initializeDocument(blue.yamlToNode(yaml)); + + Node rootTerminated = terminatedMarker(result.document(), "/"); + assertNotNull(rootTerminated); + assertEquals("fatal", rootTerminated.getProperties().get("cause").getValue()); + } + @Test void rejectsMultipleProcessEmbeddedMarkersWithinScope() { String yaml = "name: Multi Embedded Doc\n" + @@ -581,16 +634,16 @@ void rejectsMultipleProcessEmbeddedMarkersWithinScope() { "contracts:\n" + " embeddedPrimary:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /x\n" + " embeddedSecondary:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /y\n"; - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); Node document = blue.yamlToNode(yaml); IllegalStateException ex = assertThrows(IllegalStateException.class, diff --git a/src/test/java/blue/language/processor/ProcessorExecutionContextTest.java b/src/test/java/blue/language/processor/ProcessorExecutionContextTest.java index 7cd0d38..6c4abb1 100644 --- a/src/test/java/blue/language/processor/ProcessorExecutionContextTest.java +++ b/src/test/java/blue/language/processor/ProcessorExecutionContextTest.java @@ -62,12 +62,33 @@ void emitEventQueuesAndChargesGas() { ProcessorExecutionContext context = execution.createContext("/", execution.bundleForScope("/"), new Node(), false, false); context.emitEvent(new Node().value("payload")); + context.applyBufferedEffects(); ScopeRuntimeContext scopeRuntime = execution.runtime().scope("/"); assertEquals(1, scopeRuntime.triggeredQueue().size()); assertTrue(execution.runtime().totalGas() >= 20L); } + @Test + void invalidEmitEventFatalsBeforeQueueAndEmitGas() { + DocumentProcessor owner = new DocumentProcessor(); + ProcessorEngine.Execution execution = new ProcessorEngine.Execution(owner, new Node()); + execution.loadBundles("/"); + ProcessorExecutionContext context = execution.createContext("/", execution.bundleForScope("/"), new Node(), false, false); + Node invalidEvent = new Node() + .value("payload") + .properties("alsoPayload", new Node().value("invalid")); + + context.emitEvent(invalidEvent); + assertThrows(RunTerminationException.class, context::applyBufferedEffects); + + ScopeRuntimeContext scopeRuntime = execution.runtime().scope("/"); + assertTrue(scopeRuntime.triggeredQueue().isEmpty()); + assertEquals(150L, execution.runtime().totalGas()); + assertEquals(2, execution.runtime().rootEmissions().size(), + "Only termination and fatal outbox events should be recorded for the failed emit"); + } + @Test void fatalExceptionCarriesPartialResultFromCurrentExecutionState() { DocumentProcessor owner = new DocumentProcessor(); @@ -94,7 +115,7 @@ void fatalExceptionCarriesPartialResultFromCurrentExecutionState() { @Test void fatalExceptionCarriesSnapshotBackedPartialResultDuringDocumentProcessing() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(new FatalSetPropertyProcessor()); @@ -102,27 +123,25 @@ void fatalExceptionCarriesSnapshotBackedPartialResultDuringDocumentProcessing() "contracts:\n" + " events:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " fatal:\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " channel: events\n"); DocumentProcessingResult initialized = blue.initializeDocument(document); - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> blue.processDocument(initialized.snapshot(), blue.objectToNode(new TestEvent().eventId("evt-fatal")))); - - DocumentProcessingResult partial = ex.partialResult(); - assertNotNull(partial); - assertNotNull(partial.snapshot()); - assertEquals(partial.totalGas(), ex.totalGas()); - assertTrue(partial.totalGas() >= 222L); - assertNotNull(partial.blueId()); - assertFalse(initialized.blueId().equals(partial.blueId()), + DocumentProcessingResult result = blue.processDocument(initialized.snapshot(), + blue.objectToNode(new TestEvent().eventId("evt-fatal"))); + + assertNotNull(result); + assertNotNull(result.snapshot()); + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status()); + assertTrue(result.totalGas() >= 222L); + assertNotNull(result.blueId()); + assertFalse(initialized.blueId().equals(result.blueId()), "checkpoint marker creation before handler execution is part of the exposed partial state"); - assertNotNull(partial.canonicalDocument().get("/contracts/checkpoint")); - assertEquals(0, partial.triggeredEvents().size()); - assertEquals(initialized.canonicalDocument().get("/name"), partial.canonicalDocument().get("/name")); + assertNotNull(result.canonicalDocument().get("/contracts/checkpoint")); + assertEquals(initialized.canonicalDocument().get("/name"), result.canonicalDocument().get("/name")); } @Test @@ -149,7 +168,7 @@ void fatalExceptionFallsBackToMaterializedPartialResultIfSnapshotCaptureFails() @Test void executingHandlerContextExposesContractKeyAndOriginalContractNode() { MetadataProbeProcessor processor = new MetadataProbeProcessor(); - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new TestEventChannelProcessor()); blue.registerContractProcessor(processor); @@ -157,12 +176,12 @@ void executingHandlerContextExposesContractKeyAndOriginalContractNode() { "contracts:\n" + " events:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " probe:\n" + " name: Probe Handler\n" + " description: Captures execution context metadata\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " channel: events\n" + " propertyKey: /x\n" + " propertyValue: 1\n"); diff --git a/src/test/java/blue/language/processor/ProcessorStaticSafetyTest.java b/src/test/java/blue/language/processor/ProcessorStaticSafetyTest.java new file mode 100644 index 0000000..ab5b24e --- /dev/null +++ b/src/test/java/blue/language/processor/ProcessorStaticSafetyTest.java @@ -0,0 +1,181 @@ +package blue.language.processor; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ProcessorStaticSafetyTest { + + private static final Path MAIN = Paths.get("src/main/java"); + private static final Path PROCESSOR_MAIN = Paths.get("src/main/java/blue/language/processor"); + private static final Pattern DISPLAY_NAME_BLUE_ID = Pattern.compile( + "(blueId|TypeBlueId)\\(\\\"[A-Za-z][A-Za-z ]*\\\"\\)"); + + @Test + void noCoreProcessorManagedTypeUsesDisplayNameAsBlueId() throws IOException { + List offenders = new ArrayList<>(); + for (Path file : javaFiles(MAIN)) { + String source = read(file); + if (source.contains("PROCESSOR_MANAGED_TYPE_BLUE_IDS")) { + offenders.add(file + ": PROCESSOR_MANAGED_TYPE_BLUE_IDS"); + } + if (DISPLAY_NAME_BLUE_ID.matcher(source).find()) { + offenders.add(file + ": display-name BlueId literal"); + } + } + + assertTrue(offenders.isEmpty(), () -> String.join("\n", offenders)); + } + + @Test + void noRuntimeRegistryDummyNodeProviderInCorePath() throws IOException { + List offenders = new ArrayList<>(); + for (Path file : javaFiles(MAIN)) { + String source = read(file); + if (source.contains("new Node().name(type.getSimpleName())")) { + offenders.add(file + ": fabricated type node from Java simple name"); + } + } + + assertTrue(offenders.isEmpty(), () -> String.join("\n", offenders)); + } + + @Test + void runtimePointerComparisonsUsePointerUtils() throws IOException { + List offenders = new ArrayList<>(); + for (Path file : javaFiles(PROCESSOR_MAIN)) { + String relative = PROCESSOR_MAIN.relativize(file).toString(); + if (relative.equals("util/PointerUtils.java")) { + continue; + } + String source = read(file); + if (source.contains(".startsWith(")) { + offenders.add(file + ": raw startsWith pointer comparison"); + } + } + + assertTrue(offenders.isEmpty(), () -> String.join("\n", offenders)); + } + + @Test + void onlyAllowedDirectWriteCallSitesUseDirectWrite() throws IOException { + List offenders = new ArrayList<>(); + for (Path file : javaFiles(PROCESSOR_MAIN)) { + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (!line.contains("directWrite(")) { + continue; + } + String relative = PROCESSOR_MAIN.relativize(file).toString(); + boolean allowed = relative.equals("CheckpointManager.java") + || relative.equals("TerminationService.java") + || (relative.equals("DocumentProcessingRuntime.java") && line.contains("void directWrite(")); + if (!allowed) { + offenders.add(file + ":" + (i + 1) + ": " + line.trim()); + } + } + } + + assertTrue(offenders.isEmpty(), () -> String.join("\n", offenders)); + } + + @Test + void initializationMarkerIsPatchWrittenAndNotDirectWrite() throws IOException { + String source = read(PROCESSOR_MAIN.resolve("ScopeExecutor.java")); + + assertTrue(source.contains("JsonPatch.add(pointer, marker)")); + assertTrue(!source.contains("directWrite(")); + } + + @Test + void checkpointAndTerminationUseDirectWrite() throws IOException { + assertTrue(read(PROCESSOR_MAIN.resolve("CheckpointManager.java")).contains("runtime.directWrite(")); + assertTrue(read(PROCESSOR_MAIN.resolve("TerminationService.java")).contains("runtime.directWrite(")); + } + + @Test + void contractsConformanceRunnerDoesNotNormalizeOfficialFixtureResults() throws IOException { + String source = read(Paths.get("src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java")); + + assertTrue(!source.contains("normalizeOfficialFixtureResult")); + assertTrue(!source.contains("applyExpectedDocumentShape")); + assertTrue(!source.contains("safeOfficialInitialDocument")); + assertTrue(!source.contains("isOfficialProcessFixture")); + assertTrue(!source.contains("forcedFatalResult")); + assertTrue(!source.contains("preValidateProcessDocument")); + } + + @Test + void contractsConformanceRunnerDoesNotSynthesizeExpectedGasOrEvents() throws IOException { + String source = read(Paths.get("src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java")); + + assertTrue(!source.contains("expectedGas(")); + assertTrue(!source.contains("expectedRootEvents(")); + } + + @Test + void contractsConformanceRunnerUsesTypedStatusAndErrorCategories() throws IOException { + String source = read(Paths.get("src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java")); + + assertTrue(!source.contains("actualStatus(JsonNode")); + assertTrue(!source.contains("actualErrorCategory(JsonNode")); + assertTrue(!source.contains("fixtureId.contains")); + assertTrue(!source.contains("expectedStatus\")\n &&")); + String statusMethod = source.substring(source.indexOf("private static String actualStatus"), + source.indexOf("private static String actualErrorCategory")); + assertTrue(!statusMethod.contains("contracts/terminated/cause")); + } + + @Test + void batchPatchTransactionDoesNotDependOnScriptedContractsRuntime() throws IOException { + String source = read(PROCESSOR_MAIN.resolve("BatchPatchTransaction.java")); + + assertTrue(!source.contains("ScriptedContractsRuntime")); + } + + @Test + void contractsConformanceRunnerDoesNotContainLegacyOrderLogTraceMethod() throws IOException { + String source = read(Paths.get("src/main/java/blue/language/processor/conformance/ScriptedContractsRuntime.java")); + + assertTrue(!source.contains("appendOrderLog")); + } + + @Test + void dispatchSnapshotDoesNotSkipReplacedLaterHandler() throws IOException { + String source = read(PROCESSOR_MAIN.resolve("ChannelRunner.java")); + + assertTrue(!source.contains("handlerWasReplaced")); + } + + @Test + void scriptedRuntimeDoesNotMutateDocumentForTraceCollection() throws IOException { + String source = read(Paths.get("src/main/java/blue/language/processor/conformance/ScriptedContractsRuntime.java")); + + assertTrue(!source.contains("recordDocumentVisibleOrder")); + assertTrue(!source.contains("/orderLog")); + } + + private static List javaFiles(Path root) throws IOException { + try (Stream stream = Files.walk(root)) { + return stream + .filter(path -> path.toString().endsWith(".java")) + .collect(Collectors.toList()); + } + } + + private static String read(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/blue/language/processor/ProcessorTestSupport.java b/src/test/java/blue/language/processor/ProcessorTestSupport.java new file mode 100644 index 0000000..3aca4e5 --- /dev/null +++ b/src/test/java/blue/language/processor/ProcessorTestSupport.java @@ -0,0 +1,91 @@ +package blue.language.processor; + +import blue.language.Blue; +import blue.language.NodeProvider; +import blue.language.model.Node; +import blue.language.model.TypeBlueId; +import blue.language.processor.model.ApplyBatchPatch; +import blue.language.processor.model.AssertDocumentUpdate; +import blue.language.processor.model.CutOffProbe; +import blue.language.processor.model.EmitEvents; +import blue.language.processor.model.IncrementProperty; +import blue.language.processor.model.MutateEmbeddedPaths; +import blue.language.processor.model.MutateEvent; +import blue.language.processor.model.ProcessingFailureMarker; +import blue.language.processor.model.RecordDocumentUpdate; +import blue.language.processor.model.RemoveIfPresent; +import blue.language.processor.model.RemoveProperty; +import blue.language.processor.model.SetProperty; +import blue.language.processor.model.SetPropertyOnEvent; +import blue.language.processor.model.TerminateScope; +import blue.language.processor.model.TestEvent; +import blue.language.processor.model.TestEventChannel; +import blue.language.provider.SequentialNodeProvider; +import blue.language.utils.BlueIdCalculator; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +final class ProcessorTestSupport { + + private static final Class[] TEST_CONTRACT_TYPES = new Class[]{ + ApplyBatchPatch.class, + AssertDocumentUpdate.class, + CutOffProbe.class, + EmitEvents.class, + IncrementProperty.class, + MutateEmbeddedPaths.class, + MutateEvent.class, + ProcessingFailureMarker.class, + RecordDocumentUpdate.class, + RemoveIfPresent.class, + RemoveProperty.class, + SetProperty.class, + SetPropertyOnEvent.class, + TerminateScope.class, + TestEvent.class, + TestEventChannel.class + }; + + private ProcessorTestSupport() { + } + + static Blue blue() { + return new Blue(testContractTypeProvider()); + } + + static Blue blue(NodeProvider provider) { + return new Blue(providerWithTestContractTypes(provider)); + } + + static NodeProvider providerWithTestContractTypes(NodeProvider provider) { + return new SequentialNodeProvider(testContractTypeProvider(), provider); + } + + static NodeProvider testContractTypeProvider() { + return simpleNameTypeProvider(TEST_CONTRACT_TYPES); + } + + static NodeProvider simpleNameTypeProvider(Class... types) { + Map nodesByBlueId = new LinkedHashMap<>(); + for (Class type : types) { + Node node = new Node().name(type.getSimpleName()); + String calculated = BlueIdCalculator.calculateBlueId(node); + TypeBlueId annotation = type.getAnnotation(TypeBlueId.class); + if (annotation != null) { + for (String blueId : annotation.value()) { + if (blueId != null && !blueId.isEmpty() && !blueId.equals(calculated)) { + throw new IllegalStateException(type.getName() + + " test type node hashes to " + calculated + ", not " + blueId); + } + } + } + nodesByBlueId.put(calculated, node); + } + return blueId -> { + Node node = nodesByBlueId.get(blueId); + return node != null ? Collections.singletonList(node.clone()) : null; + }; + } +} diff --git a/src/test/java/blue/language/processor/TestEventChannelTest.java b/src/test/java/blue/language/processor/TestEventChannelTest.java index bd34853..699b0c7 100644 --- a/src/test/java/blue/language/processor/TestEventChannelTest.java +++ b/src/test/java/blue/language/processor/TestEventChannelTest.java @@ -20,7 +20,7 @@ class TestEventChannelTest { @Test void testEventChannelMatchesOnlyTestEvents() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); blue.registerContractProcessor(new TestEventChannelProcessor()); @@ -28,11 +28,11 @@ void testEventChannelMatchesOnlyTestEvents() { "contracts:\n" + " testEventsChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " setX:\n" + " channel: testEventsChannel\n" + " type:\n" + - " blueId: SetProperty\n" + + " blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts\n" + " propertyKey: /x\n" + " propertyValue: 1\n"; @@ -57,7 +57,7 @@ void testEventChannelMatchesOnlyTestEvents() { @Test void triggeredAndEmbeddedChannelsPropagateChildEvents() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); blue.registerContractProcessor(new EmitEventsContractProcessor()); blue.registerContractProcessor(new SetPropertyOnEventContractProcessor()); @@ -68,25 +68,25 @@ void triggeredAndEmbeddedChannelsPropagateChildEvents() { " contracts:\n" + " life:\n" + " type:\n" + - " blueId: LifecycleChannel\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + " triggered:\n" + " type:\n" + - " blueId: TriggeredEventChannel\n" + + " blueId: 5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ\n" + " emitOnInit:\n" + " channel: life\n" + " event:\n" + " type:\n" + - " blueId: DocumentProcessingInitiated\n" + + " blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL\n" + " type:\n" + - " blueId: EmitEvents\n" + + " blueId: 8L41csGU9GJkoza1159y2pYbJ6yGAi4huvgmu44Ah2d5\n" + " events:\n" + " - type:\n" + - " blueId: TestEvent\n" + + " blueId: Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf\n" + " kind: first\n" + " setLocalFirst:\n" + " channel: triggered\n" + " type:\n" + - " blueId: SetPropertyOnEvent\n" + + " blueId: H1qKGon7JWgUU9P8oUiHjxoR5hWbkAzVWWNukXf4cHz\n" + " expectedKind: first\n" + " propertyKey: /localFirst\n" + " propertyValue: 1\n" + @@ -94,34 +94,34 @@ void triggeredAndEmbeddedChannelsPropagateChildEvents() { " channel: triggered\n" + " order: 1\n" + " type:\n" + - " blueId: EmitEvents\n" + + " blueId: 8L41csGU9GJkoza1159y2pYbJ6yGAi4huvgmu44Ah2d5\n" + " expectedKind: first\n" + " events:\n" + " - type:\n" + - " blueId: TestEvent\n" + + " blueId: Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf\n" + " kind: second\n" + " setLocalSecond:\n" + " channel: triggered\n" + " order: 2\n" + " type:\n" + - " blueId: SetPropertyOnEvent\n" + + " blueId: H1qKGon7JWgUU9P8oUiHjxoR5hWbkAzVWWNukXf4cHz\n" + " expectedKind: second\n" + " propertyKey: /localSecond\n" + " propertyValue: 1\n" + "contracts:\n" + " embedded:\n" + " type:\n" + - " blueId: ProcessEmbedded\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /a\n" + " embeddedEvents:\n" + " type:\n" + - " blueId: EmbeddedNodeChannel\n" + + " blueId: H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i\n" + " childPath: /a\n" + " setRootFromChild:\n" + " channel: embeddedEvents\n" + " type:\n" + - " blueId: SetPropertyOnEvent\n" + + " blueId: H1qKGon7JWgUU9P8oUiHjxoR5hWbkAzVWWNukXf4cHz\n" + " expectedKind: second\n" + " propertyKey: /fromChild\n" + " propertyValue: 1\n"; @@ -142,7 +142,7 @@ void triggeredAndEmbeddedChannelsPropagateChildEvents() { @Test void checkpointSkipsStaleEvents() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); blue.registerContractProcessor(new IncrementPropertyContractProcessor()); blue.registerContractProcessor(new TestEventChannelProcessor()); @@ -151,11 +151,11 @@ void checkpointSkipsStaleEvents() { "contracts:\n" + " testEventsChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " incrementX:\n" + " channel: testEventsChannel\n" + " type:\n" + - " blueId: IncrementProperty\n" + + " blueId: GsQfKqSUXxx24JTvsHDaY5pJ2cE6vZnn7j1NQ5RFDCWv\n" + " propertyKey: /x\n"; Node document = blue.yamlToNode(yaml); @@ -185,13 +185,6 @@ private String checkpointValue(Node document) { if (checkpoint == null) { return null; } - Node lastSignatures = checkpoint.getProperties().get("lastSignatures"); - if (lastSignatures != null && lastSignatures.getProperties() != null) { - Node sigNode = lastSignatures.getProperties().get("testEventsChannel"); - if (sigNode != null && sigNode.getValue() != null) { - return sigNode.getValue().toString(); - } - } Node lastEvents = checkpoint.getProperties().get("lastEvents"); if (lastEvents == null || lastEvents.getProperties() == null) { return null; @@ -207,7 +200,7 @@ private String checkpointValue(Node document) { @Test void checkpointStoresFullEventAndComparesPayload() { - Blue blue = new Blue(); + Blue blue = ProcessorTestSupport.blue(); blue.registerContractProcessor(new SetPropertyContractProcessor()); blue.registerContractProcessor(new IncrementPropertyContractProcessor()); blue.registerContractProcessor(new TestEventChannelProcessor()); @@ -216,28 +209,28 @@ void checkpointStoresFullEventAndComparesPayload() { "contracts:\n" + " testEventsChannel:\n" + " type:\n" + - " blueId: TestEventChannel\n" + + " blueId: BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L\n" + " incrementX:\n" + " channel: testEventsChannel\n" + " type:\n" + - " blueId: IncrementProperty\n" + + " blueId: GsQfKqSUXxx24JTvsHDaY5pJ2cE6vZnn7j1NQ5RFDCWv\n" + " propertyKey: /x\n"; Node initialized = blue.initializeDocument(blue.yamlToNode(yaml)).document(); - Node firstEvent = blue.yamlToNode("type:\n blueId: TestEvent\nkind: alpha\n"); + Node firstEvent = blue.yamlToNode("type:\n blueId: Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf\nkind: alpha\n"); Node afterFirst = blue.processDocument(initialized, firstEvent).document(); assertEquals(new BigInteger("1"), afterFirst.getProperties().get("x").getValue()); Node storedEvent = checkpointStoredEvent(afterFirst); assertNotNull(storedEvent); assertEquals("alpha", storedEvent.getProperties().get("kind").getValue()); - Node identicalEvent = blue.yamlToNode("type:\n blueId: TestEvent\nkind: alpha\n"); + Node identicalEvent = blue.yamlToNode("type:\n blueId: Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf\nkind: alpha\n"); Node afterSecond = blue.processDocument(afterFirst, identicalEvent).document(); assertEquals(new BigInteger("1"), afterSecond.getProperties().get("x").getValue(), "Identical payload should be gated by checkpoint"); - Node changedEvent = blue.yamlToNode("type:\n blueId: TestEvent\nkind: beta\n"); + Node changedEvent = blue.yamlToNode("type:\n blueId: Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf\nkind: beta\n"); Node afterThird = blue.processDocument(afterSecond, changedEvent).document(); assertEquals(new BigInteger("2"), afterThird.getProperties().get("x").getValue(), "Changed payload should be processed"); diff --git a/src/test/java/blue/language/processor/conformance/BlueContractsConformanceFixtureTest.java b/src/test/java/blue/language/processor/conformance/BlueContractsConformanceFixtureTest.java new file mode 100644 index 0000000..075e0fe --- /dev/null +++ b/src/test/java/blue/language/processor/conformance/BlueContractsConformanceFixtureTest.java @@ -0,0 +1,516 @@ +package blue.language.processor.conformance; + +import blue.language.Blue; +import blue.language.BlueContractsConformanceFailure; +import blue.language.BlueContractsConformanceReport; +import blue.language.BlueContractsConformanceSuiteRunner; +import blue.language.utils.UncheckedObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BlueContractsConformanceFixtureTest { + + @Test + void blueContractsConformanceSuitePassesFixtures() { + BlueContractsConformanceReport report = new Blue().runContractsConformanceSuite(); + Map failuresById = report.getFailures().stream() + .collect(Collectors.toMap(BlueContractsConformanceFailure::getFixtureId, Function.identity())); + + assertTrue(report.getFailures().isEmpty(), () -> failuresById.values().stream() + .map(this::failureMessage) + .collect(Collectors.joining("\n"))); + assertEquals(report.getFixtureIds(), report.getPassedFixtureIds()); + } + + @Test + void contractsConformanceManifestIdentityMatchesFixtureFiles() { + assertEquals(BlueContractsConformanceReport.computeFixturePackageIdentity(), + new Blue().contractsConformanceReport().getFixturePackageIdentity()); + assertTrue(BlueContractsConformanceReport.fixturePackageIdentityMatchesFixtureFiles()); + } + + @Test + void contractsRequiredFixtureCoverageIsReported() { + assertTrue(BlueContractsConformanceReport.requiredFixtureIdsForContracts10() + .contains("T078_direct_write_termination_costs_configured_amount")); + assertTrue(new Blue().contractsConformanceReport().hasRequiredFixtureCoverage()); + } + + @Test + void contractsRequiredFixtureCoverageAllowsSuperset() { + List ids = new ArrayList<>(BlueContractsConformanceReport.requiredFixtureIdsForContracts10()); + ids.add("T999_extra_contract_fixture"); + BlueContractsConformanceReport report = reportWithFixtureIds(ids); + + assertTrue(report.hasRequiredFixtureCoverage()); + } + + @Test + void contractsExactRequiredFixtureSetRejectsExtraOrMissing() { + List ids = new ArrayList<>(BlueContractsConformanceReport.requiredFixtureIdsForContracts10()); + BlueContractsConformanceReport exact = reportWithFixtureIds(ids); + assertTrue(exact.hasRequiredFixtureCoverage()); + assertTrue(exact.hasExactRequiredFixtureSet()); + + List withExtra = new ArrayList<>(ids); + withExtra.add("T999_extra_contract_fixture"); + BlueContractsConformanceReport extra = reportWithFixtureIds(withExtra); + assertTrue(extra.hasRequiredFixtureCoverage()); + assertFalse(extra.hasExactRequiredFixtureSet()); + + List missing = Collections.singletonList(ids.get(0)); + BlueContractsConformanceReport incomplete = reportWithFixtureIds(missing); + assertFalse(incomplete.hasRequiredFixtureCoverage()); + assertFalse(incomplete.hasExactRequiredFixtureSet()); + } + + @Test + void contractsExactRequiredFixtureSetAcceptsCurrentManifest() { + assertTrue(new Blue().contractsConformanceReport().hasExactRequiredFixtureSet()); + } + + @Test + void contractsManifestAndRequiredFixtureSetAligned() throws Exception { + JsonNode manifest = readFixture("manifest.yaml"); + Set required = new HashSet<>(BlueContractsConformanceReport.requiredFixtureIdsForContracts10()); + Set manifestIds = new HashSet<>(); + Set manifestPaths = new HashSet<>(); + Path fixtureRoot = Paths.get("src/test/resources/blue-contracts-1.0/fixtures"); + for (JsonNode fixture : manifest.get("fixtures")) { + String id = fixture.get("id").asText(); + String path = fixture.get("path").asText(); + manifestIds.add(id); + manifestPaths.add(path); + assertTrue(Files.exists(fixtureRoot.resolve(path)), + "Missing fixture file " + path); + assertEquals(id, readFixture(path).get("id").asText()); + } + assertEquals(required, manifestIds); + + Set yamlFiles = Files.walk(fixtureRoot) + .filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".yaml")) + .map(path -> fixtureRoot.relativize(path).toString()) + .filter(path -> !"manifest.yaml".equals(path)) + .collect(Collectors.toSet()); + assertEquals(manifestPaths, yamlFiles); + } + + @Test + void contractsFixtureMetadataIsValid() throws Exception { + JsonNode manifest = readFixture("manifest.yaml"); + for (JsonNode fixture : manifest.get("fixtures")) { + String path = fixture.get("path").asText(); + BlueContractsConformanceSuiteRunner.validateFixtureMetadataForTest(readFixture(path)); + } + } + + @Test + void contractsFixtureWithoutMeaningfulAssertionFails() { + JsonNode spec = fixtureSpec( + "id: local_no_assertion\n" + + "category: Initialization\n" + + "operation: processDocument\n" + + "initialDocument: {}\n"); + + assertThrows(IllegalArgumentException.class, + () -> BlueContractsConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void contractsFixtureExpectedCapabilityFailureFalseAloneIsNotMeaningful() { + JsonNode spec = fixtureSpec( + "id: local_capability_false_only\n" + + "category: Initialization\n" + + "operation: processDocument\n" + + "initialDocument: {}\n" + + "expectedCapabilityFailure: false\n"); + + assertThrows(IllegalArgumentException.class, + () -> BlueContractsConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void contractsFixtureExpectedCapabilityFailureTrueRequiresNoMutationOrReason() { + JsonNode spec = fixtureSpec( + "id: local_capability_true_only\n" + + "category: MustUnderstand\n" + + "operation: processDocument\n" + + "initialDocument: {}\n" + + "expectedCapabilityFailure: true\n"); + + assertThrows(IllegalArgumentException.class, + () -> BlueContractsConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void contractsFixtureExpectedCapabilityFailureWithNoMutationIsMeaningful() { + JsonNode spec = fixtureSpec( + "id: local_capability_true_with_no_mutation\n" + + "category: MustUnderstand\n" + + "operation: processDocument\n" + + "initialDocument: {}\n" + + "expectedCapabilityFailure: true\n" + + "expectedNoDocumentMutation: true\n"); + + BlueContractsConformanceSuiteRunner.validateFixtureMetadataForTest(spec); + } + + @Test + void contractsFixtureUnknownExpectedFieldFailsMetadataValidation() { + JsonNode spec = fixtureSpec( + "id: local_unknown_expected\n" + + "category: Initialization\n" + + "operation: processDocument\n" + + "initialDocument: {}\n" + + "expectedDocument: {}\n" + + "expectedNotARealField: true\n"); + + assertThrows(IllegalArgumentException.class, + () -> BlueContractsConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void contractsFixtureUnknownProcessorCapabilityFails() { + JsonNode spec = fixtureSpec( + "id: local_unknown_capability\n" + + "category: Initialization\n" + + "operation: processDocument\n" + + "processorCapabilities:\n" + + " - blue-contracts-fixture-missing-v1\n" + + "initialDocument: {}\n" + + "expectedDocument: {}\n"); + + assertThrows(IllegalArgumentException.class, + () -> BlueContractsConformanceSuiteRunner.validateFixtureMetadataForTest(spec)); + } + + @Test + void contractsFixtureExpectedStatusIsChecked() { + JsonNode spec = fixtureSpec( + "id: local_expected_status\n" + + "category: Initialization\n" + + "operation: processDocument\n" + + "initialDocument: {}\n" + + "event:\n" + + " value: event\n" + + "expectedStatus: runtime-fatal\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void contractsFixtureExpectedErrorCategoryIsChecked() { + JsonNode spec = fixtureSpec( + "id: local_expected_error_category\n" + + "category: ContractKey\n" + + "operation: processDocument\n" + + "initialDocument:\n" + + " contracts:\n" + + " \"\": {}\n" + + "expectedStatus: runtime-fatal\n" + + "expectedErrorCategory: UnsupportedContract\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void contractsFixtureExpectedErrorCategoriesAcceptsAnyListedCategory() { + JsonNode spec = fixtureSpec( + "id: local_expected_error_categories\n" + + "category: ContractKey\n" + + "operation: processDocument\n" + + "initialDocument:\n" + + " contracts:\n" + + " \"\": {}\n" + + "expectedStatus: runtime-fatal\n" + + "expectedErrorCategories: [UnsupportedContract, InvalidRuntimePointer]\n"); + + BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec); + } + + @Test + void contractsFixtureExpectedDocumentIsCompared() { + JsonNode spec = fixtureSpec( + "id: local_expected_document\n" + + "category: Initialization\n" + + "operation: processDocument\n" + + "initialDocument: {}\n" + + "event:\n" + + " value: event\n" + + "expectedDocument: {}\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void contractsFixtureExpectedAbsentPathIsChecked() { + JsonNode spec = fixtureSpec( + "id: local_absent_path\n" + + "category: Patching\n" + + "operation: processDocument\n" + + "initialDocument:\n" + + " present: true\n" + + "event:\n" + + " value: event\n" + + "expectedAbsentDocumentPaths:\n" + + " - /present\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void contractsFixtureExpectedRootEventsCompared() { + JsonNode spec = fixtureSpec( + "id: local_root_events\n" + + "category: Initialization\n" + + "operation: processDocument\n" + + "initialDocument: {}\n" + + "event:\n" + + " value: event\n" + + "expectedRootEvents: []\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void expectedRootEventsFailsWhenExtraRootEventExists() { + JsonNode spec = fixtureSpec( + emitScalarFixture("local_exact_root_events") + + "expectedRootEvents:\n" + + " - value: emitted-scalar\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void expectedRootEventSuffixWorksOnlyWhenExplicitlyRequested() { + JsonNode spec = fixtureSpec( + emitScalarFixture("local_root_event_suffix") + + "expectedRootEventSuffix:\n" + + " - value: emitted-scalar\n"); + + BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec); + } + + @Test + void runtimeInsertionEventIndexIsZeroBasedFromBeginning() { + JsonNode spec = fixtureSpec( + emitScalarFixture("local_event_index") + + "expectedRuntimeInsertionNormalizedValues:\n" + + " - eventIndex: 0\n" + + " selectedDocumentForm:\n" + + " value: emitted-scalar\n" + + " type:\n" + + " blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void runtimeInsertionEventIndexFromEndRequiresExplicitField() { + JsonNode spec = fixtureSpec( + emitScalarFixture("local_event_index_from_end") + + "expectedRuntimeInsertionNormalizedValues:\n" + + " - eventIndexFromEnd: 0\n" + + " selectedDocumentForm:\n" + + " value: emitted-scalar\n" + + " type:\n" + + " blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC\n"); + + BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec); + } + + @Test + void dispatchSnapshotDoesNotSkipReplacedLaterHandler() throws Exception { + BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(readFixture( + "dispatch-snapshot/T065_replacing_later_handler_does_not_affect_current_delivery_content.yaml")); + } + + @Test + void contractsFixtureExpectedDocumentPathValuesCompared() { + JsonNode spec = fixtureSpec( + "id: local_path_values\n" + + "category: Initialization\n" + + "operation: processDocument\n" + + "initialDocument:\n" + + " present:\n" + + " value: true\n" + + "event:\n" + + " value: event\n" + + "expectedDocumentPathValues:\n" + + " - path: /present\n" + + " value:\n" + + " value: false\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void contractsFixtureExpectedRootEventPathValuesCompared() { + JsonNode spec = fixtureSpec( + "id: local_root_event_path_values\n" + + "category: Initialization\n" + + "operation: processDocument\n" + + "initialDocument: {}\n" + + "event:\n" + + " value: event\n" + + "expectedRootEventPathValues:\n" + + " - index: 0\n" + + " path: /documentId\n" + + " value:\n" + + " value: not-the-document-id\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void contractsFixtureExpectedExactGasCompared() { + JsonNode spec = fixtureSpec( + "id: local_exact_gas\n" + + "category: Initialization\n" + + "operation: processDocument\n" + + "initialDocument: {}\n" + + "event:\n" + + " value: event\n" + + "expectedExactGas: 999999\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void contractsFixtureCheckpointLastEventsCompared() { + JsonNode spec = fixtureSpec( + "id: local_checkpoint_last_events\n" + + "category: Checkpoint\n" + + "operation: processDocument\n" + + "initialDocument:\n" + + " contracts:\n" + + " channel:\n" + + " type:\n" + + " blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi\n" + + "event:\n" + + " kind: checkpoint\n" + + "expectedCheckpointLastEvents:\n" + + " channel:\n" + + " kind: different\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + @Test + void contractsFixtureFailureReasonChecked() { + JsonNode spec = fixtureSpec( + "id: local_failure_reason\n" + + "category: ProcessingDocument\n" + + "operation: processDocument\n" + + "initialDocument:\n" + + " value: scalar-root\n" + + "event:\n" + + " value: event\n" + + "expectedCapabilityFailure: true\n" + + "expectedFailureReasonContains: not-the-reason\n"); + + assertThrows(AssertionError.class, + () -> BlueContractsConformanceSuiteRunner.runFixtureSpecForTest(spec)); + } + + private JsonNode readFixture(String path) throws Exception { + String resource = "blue-contracts-1.0/fixtures/" + path; + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + if (input == null) { + throw new IllegalStateException("Missing fixture resource: " + resource); + } + return UncheckedObjectMapper.YAML_MAPPER.readTree(input); + } + } + + private JsonNode fixtureSpec(String yaml) { + return UncheckedObjectMapper.YAML_MAPPER.readTree(yaml); + } + + private String emitScalarFixture(String id) { + return "id: " + id + "\n" + + "category: Normalization\n" + + "operation: processDocument\n" + + "processorCapabilities:\n" + + " - blue-contracts-fixture-scripted-runtime-v1\n" + + "initialDocument:\n" + + " contracts:\n" + + " incoming:\n" + + " type:\n" + + " blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm\n" + + " emitter:\n" + + " type:\n" + + " blueId: 3rHWt14WhTvmBBQ6Cr1Mb263KuxSdwqvb2jD7oPbkNL3\n" + + " channel: incoming\n" + + "event:\n" + + " kind: emit-bare-scalar\n" + + "mockRuntime:\n" + + " channels:\n" + + " - contract: /contracts/incoming\n" + + " calls:\n" + + " - when:\n" + + " event:\n" + + " kind: emit-bare-scalar\n" + + " accepted: true\n" + + " payload:\n" + + " kind: emit-bare-scalar\n" + + " handlers:\n" + + " - contract: /contracts/emitter\n" + + " calls:\n" + + " - when:\n" + + " channelKey: incoming\n" + + " result:\n" + + " triggeredEvents:\n" + + " - emitted-scalar\n" + + "expectedStatus: success\n"; + } + + private BlueContractsConformanceReport reportWithFixtureIds(List ids) { + return new BlueContractsConformanceReport( + "1.0", + "sha256:test", + ids, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyMap(), + Collections.emptyList()); + } + + private String failureMessage(BlueContractsConformanceFailure failure) { + return failure.getFixtureId() + + " [" + failure.getCategory() + "/" + failure.getOperation() + "] " + + failure.getExceptionClass() + + ": " + failure.getMessage(); + } +} diff --git a/src/test/java/blue/language/processor/conformance/BlueContractsConformanceReportTest.java b/src/test/java/blue/language/processor/conformance/BlueContractsConformanceReportTest.java new file mode 100644 index 0000000..606eb10 --- /dev/null +++ b/src/test/java/blue/language/processor/conformance/BlueContractsConformanceReportTest.java @@ -0,0 +1,56 @@ +package blue.language.processor.conformance; + +import blue.language.Blue; +import blue.language.BlueContractsConformanceFailure; +import blue.language.BlueContractsConformanceReport; +import org.junit.jupiter.api.Test; + +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BlueContractsConformanceReportTest { + + @Test + void contractsConformanceReportPassesReleaseGates() { + BlueContractsConformanceReport report = new Blue().runContractsConformanceSuite(); + + assertTrue(report.getFailures().isEmpty(), () -> report.getFailures().stream() + .map(this::failureMessage) + .collect(Collectors.joining("\n"))); + assertTrue(report.getFailedFixtureIds().isEmpty()); + assertEquals(report.getFixtureIds(), report.getPassedFixtureIds()); + assertTrue(report.hasRequiredFixtureCoverage()); + assertTrue(report.hasExactRequiredFixtureSet()); + assertTrue(report.isOfficialContracts10FixturePackage()); + assertTrue(BlueContractsConformanceReport.fixturePackageIdentityMatchesFixtureFiles()); + } + + @Test + void staticContractsConformanceReportExposesReleaseMetadata() { + BlueContractsConformanceReport report = new Blue().contractsConformanceReport(); + + assertEquals(BlueContractsConformanceReport.computeFixturePackageIdentity(), + report.getFixturePackageIdentity()); + assertTrue(report.hasRequiredFixtureCoverage()); + assertTrue(report.hasExactRequiredFixtureSet()); + assertTrue(report.isOfficialContracts10FixturePackage()); + assertTrue(BlueContractsConformanceReport.fixturePackageIdentityMatchesFixtureFiles()); + } + + @Test + void contractsFixturePackageIdentityMatchesOfficialContracts10Release() { + BlueContractsConformanceReport report = new Blue().contractsConformanceReport(); + + assertEquals(BlueContractsConformanceReport.BLUE_CONTRACTS_1_0_FIXTURE_PACKAGE_IDENTITY, + report.getFixturePackageIdentity()); + assertTrue(report.isOfficialContracts10FixturePackage()); + } + + private String failureMessage(BlueContractsConformanceFailure failure) { + return failure.getFixtureId() + " [" + failure.getCategory().name() + "] " + + failure.getOperation() + " -> " + failure.getExceptionClass() + + ": " + failure.getMessage(); + } +} diff --git a/src/test/java/blue/language/processor/contracts/ApplyBatchPatchContractProcessor.java b/src/test/java/blue/language/processor/contracts/ApplyBatchPatchContractProcessor.java index 69ba2ca..3791665 100644 --- a/src/test/java/blue/language/processor/contracts/ApplyBatchPatchContractProcessor.java +++ b/src/test/java/blue/language/processor/contracts/ApplyBatchPatchContractProcessor.java @@ -5,6 +5,7 @@ import blue.language.processor.ProcessorExecutionContext; import blue.language.processor.model.ApplyBatchPatch; import blue.language.processor.model.JsonPatch; +import blue.language.processor.registry.RuntimeBlueIds; import java.util.Arrays; @@ -17,6 +18,11 @@ public Class contractType() { @Override public void execute(ApplyBatchPatch contract, ProcessorExecutionContext context) { + if (contract.isAddUnsupportedContract()) { + Node unsupported = new Node().type(new Node().blueId(RuntimeBlueIds.BLUE_ID_TYPE)); + context.applyPatch(JsonPatch.add(context.resolvePointer("/contracts/runtimeUnsupported"), unsupported)); + return; + } context.applyPatches(Arrays.asList( JsonPatch.replace("/a", new Node().value("one")), JsonPatch.replace("/b", new Node().value("two")) diff --git a/src/test/java/blue/language/processor/contracts/TestEventChannelProcessor.java b/src/test/java/blue/language/processor/contracts/TestEventChannelProcessor.java index 2a8d4ed..cdbe5e6 100644 --- a/src/test/java/blue/language/processor/contracts/TestEventChannelProcessor.java +++ b/src/test/java/blue/language/processor/contracts/TestEventChannelProcessor.java @@ -8,7 +8,7 @@ public class TestEventChannelProcessor implements ChannelProcessor { - private static final String DEFAULT_EVENT_TYPE = "TestEvent"; + private static final String DEFAULT_EVENT_TYPE = "Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf"; @Override public Class contractType() { diff --git a/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java b/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java index 1325fe8..ca8006e 100644 --- a/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java +++ b/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java @@ -26,17 +26,17 @@ class ExternalContractIntegrationTest { - private static final String CHANNEL_BLUE_ID = "external.counter/Always Channel"; - private static final String MUTATING_CHANNEL_BLUE_ID = "external.counter/Mutating Channel"; - private static final String SEQUENCE_CHANNEL_BLUE_ID = "external.counter/Sequence Channel"; - private static final String MULTI_DELIVERY_CHANNEL_BLUE_ID = "external.counter/Multi Delivery Channel"; - private static final String DELEGATING_CHANNEL_BLUE_ID = "external.counter/Delegating Channel"; - private static final String OPERATION_BLUE_ID = "external.counter/Operation"; - private static final String HANDLER_BLUE_ID = "external.counter/Add Amount"; - private static final String MATCHING_HANDLER_BLUE_ID = "external.counter/Matching Add Amount"; - private static final String DERIVED_HANDLER_BLUE_ID = "external.counter/Derived Add Amount"; - private static final String CAPTURE_HANDLER_BLUE_ID = "external.counter/Capture Event Flag"; - private static final String UNKNOWN_BLUE_ID = "external.counter/Unknown Handler"; + private static final String CHANNEL_BLUE_ID = "48YcT2K2ghpM7VPcx6u8dFvS2so2DkgCvAbWfNfzKeek"; + private static final String MUTATING_CHANNEL_BLUE_ID = "H5CsySZCnz5KqbP3N29DPZ3TaYbn73Ku9J3VQaJzyMXs"; + private static final String SEQUENCE_CHANNEL_BLUE_ID = "j4iiHC8rFNQfrpRTqSeFzHs8SNyiZTcZUYb3autoqUw"; + private static final String MULTI_DELIVERY_CHANNEL_BLUE_ID = "EzS7MG35zJPCVgV3YyFgG2ucMYrj1qr4V3wR9xitadsw"; + private static final String DELEGATING_CHANNEL_BLUE_ID = "A61X264nXcmWE4FxWWXgtmnaAR1ESqJ8j1LQ2MZu8AP7"; + private static final String OPERATION_BLUE_ID = "8wnsu2ad91yewKk69dh5dt8UxDTMXsGuAzMFAcNHDhK8"; + private static final String HANDLER_BLUE_ID = "4uWFGYDqgCiWitoNymc9KQXNoKWRHPLVyTv3qgmTUdEA"; + private static final String MATCHING_HANDLER_BLUE_ID = "BqMA7bX8UzYscCebKK9tBgF2QDibc9tWo3yKUqQMJWeS"; + private static final String DERIVED_HANDLER_BLUE_ID = "BHmAMaH5P9PiHKs2d8b73oLaZTVPgBNALHyJnVJBLeFs"; + private static final String CAPTURE_HANDLER_BLUE_ID = "12VvzAWHUMyDtQFGibr2Kbry7eieMmY8uzqHPRjrzzpt"; + private static final String UNKNOWN_BLUE_ID = "9Y8k2srt1DgxP51iCCQJhrib2tJdjuf7D28MmS5B1udZ"; @Test void builderRegistersExternalContractsByExplicitBlueIdAndExecutesThem() { @@ -65,8 +65,10 @@ void builderRegistersExternalContractsByExplicitBlueIdAndExecutesThem() { void blueFacadePreservesExternalContractResolverWhenRuntimeServicesRefresh() { ExternalAddAmountProcessor.reset(); Blue blue = new Blue(); - blue.registerContractProcessor(CHANNEL_BLUE_ID, new ExternalAlwaysChannelProcessor()); - blue.registerContractProcessor(HANDLER_BLUE_ID, new ExternalAddAmountProcessor()); + blue.registerExternalContractType(CHANNEL_BLUE_ID, externalTypeNode(ExternalAlwaysChannel.class), + new ExternalAlwaysChannelProcessor()); + blue.registerExternalContractType(HANDLER_BLUE_ID, externalTypeNode(ExternalAddAmount.class), + new ExternalAddAmountProcessor()); blue.nodeProvider(ignored -> null); @@ -79,6 +81,31 @@ void blueFacadePreservesExternalContractResolverWhenRuntimeServicesRefresh() { assertEquals(HANDLER_BLUE_ID, ExternalAddAmountProcessor.lastTypeBlueId); } + @Test + void blueFacadeRequiresCanonicalNodeForRegisteredExternalType() { + Blue blue = new Blue(); + blue.registerContractProcessor(CHANNEL_BLUE_ID, new ExternalAlwaysChannelProcessor()); + blue.registerContractProcessor(HANDLER_BLUE_ID, new ExternalAddAmountProcessor()); + Node document = blue.yamlToNode(counterDocument(HANDLER_BLUE_ID)); + + RuntimeException failure = assertThrows(RuntimeException.class, () -> blue.initializeDocument(document)); + + assertTrue(failure.getMessage().contains(CHANNEL_BLUE_ID) + || failure.getMessage().contains(HANDLER_BLUE_ID)); + } + + @Test + void registeredExternalTypeRejectsWrongCanonicalNode() { + Blue blue = new Blue(); + Node wrongTypeNode = new Node().name("WrongExternalType"); + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> blue.registerExternalContractType(CHANNEL_BLUE_ID, wrongTypeNode, + new ExternalAlwaysChannelProcessor())); + + assertTrue(failure.getMessage().contains("not declared BlueId")); + } + @Test void unknownExternalContractTypeProducesCapabilityFailureWithoutMutation() { DocumentProcessor processor = DocumentProcessor.builder() @@ -279,7 +306,7 @@ void channelProcessorCanEvaluateSameScopeChannelFromContext() { } @Test - void derivedHandlerChannelMustResolveToRegisteredChannelInSameScope() { + void derivedHandlerWithoutSameScopeChannelIsInert() { DocumentProcessor processor = DocumentProcessor.builder() .registerContractProcessor(OPERATION_BLUE_ID, new ExternalOperationProcessor()) .registerContractProcessor(DERIVED_HANDLER_BLUE_ID, new DerivingAddAmountProcessor()) @@ -299,10 +326,11 @@ void derivedHandlerChannelMustResolveToRegisteredChannelInSameScope() { " operation: increment\n" + " counterPath: /counter\n"); - IllegalStateException ex = assertThrows(IllegalStateException.class, - () -> processor.initializeDocument(document)); + DocumentProcessingResult initialized = processor.initializeDocument(document); + assertFalse(initialized.capabilityFailure(), initialized.failureReason()); - assertTrue(ex.getMessage().contains("unknown channel 'missing'")); + DocumentProcessingResult processed = processor.processDocument(initialized.document(), amountEvent(7)); + assertEquals(BigInteger.ZERO, processed.document().get("/counter")); } private static String counterDocument(String handlerBlueId) { @@ -335,6 +363,10 @@ private static Node sequencedAmountEvent(int amount, int sequence) { return amountEvent(amount).properties("sequence", new Node().value(BigInteger.valueOf(sequence))); } + private static Node externalTypeNode(Class type) { + return new Node().name(type.getSimpleName()); + } + public static final class ExternalAlwaysChannel extends ChannelContract { } diff --git a/src/test/java/blue/language/processor/model/ApplyBatchPatch.java b/src/test/java/blue/language/processor/model/ApplyBatchPatch.java index 2ac3337..23095f7 100644 --- a/src/test/java/blue/language/processor/model/ApplyBatchPatch.java +++ b/src/test/java/blue/language/processor/model/ApplyBatchPatch.java @@ -2,6 +2,20 @@ import blue.language.model.TypeBlueId; -@TypeBlueId("ApplyBatchPatch") +@TypeBlueId("AjWAjR4NcDYJHMhkAkX9DZKqGbHs8vkCRpjXiHRkLPMw") public class ApplyBatchPatch extends HandlerContract { + + private boolean addUnsupportedContract; + + public boolean isAddUnsupportedContract() { + return addUnsupportedContract; + } + + public boolean getAddUnsupportedContract() { + return addUnsupportedContract; + } + + public void setAddUnsupportedContract(boolean addUnsupportedContract) { + this.addUnsupportedContract = addUnsupportedContract; + } } diff --git a/src/test/java/blue/language/processor/model/AssertDocumentUpdate.java b/src/test/java/blue/language/processor/model/AssertDocumentUpdate.java index 3c2b5c6..1119551 100644 --- a/src/test/java/blue/language/processor/model/AssertDocumentUpdate.java +++ b/src/test/java/blue/language/processor/model/AssertDocumentUpdate.java @@ -3,7 +3,7 @@ import blue.language.processor.model.HandlerContract; import blue.language.model.TypeBlueId; -@TypeBlueId("AssertDocumentUpdate") +@TypeBlueId("2QCfZuct9TQRCmgE4q6PneDoZFcshqMLYpsNGpxvfwMd") public class AssertDocumentUpdate extends HandlerContract { private String expectedPath; diff --git a/src/test/java/blue/language/processor/model/CutOffProbe.java b/src/test/java/blue/language/processor/model/CutOffProbe.java index d3e80b9..bab36d7 100644 --- a/src/test/java/blue/language/processor/model/CutOffProbe.java +++ b/src/test/java/blue/language/processor/model/CutOffProbe.java @@ -2,7 +2,7 @@ import blue.language.model.TypeBlueId; -@TypeBlueId("CutOffProbe") +@TypeBlueId("A8kbVbinjJAPFnbaQgBRCDU6h64xydTHe69kPakvgjbU") public class CutOffProbe extends HandlerContract { private boolean emitBefore; diff --git a/src/test/java/blue/language/processor/model/EmitEvents.java b/src/test/java/blue/language/processor/model/EmitEvents.java index 4fd8044..aaa6f51 100644 --- a/src/test/java/blue/language/processor/model/EmitEvents.java +++ b/src/test/java/blue/language/processor/model/EmitEvents.java @@ -7,7 +7,7 @@ import java.util.ArrayList; import java.util.List; -@TypeBlueId("EmitEvents") +@TypeBlueId("8L41csGU9GJkoza1159y2pYbJ6yGAi4huvgmu44Ah2d5") public class EmitEvents extends HandlerContract { private List events = new ArrayList<>(); diff --git a/src/test/java/blue/language/processor/model/IncrementProperty.java b/src/test/java/blue/language/processor/model/IncrementProperty.java index cc4b0c5..7f3b4f8 100644 --- a/src/test/java/blue/language/processor/model/IncrementProperty.java +++ b/src/test/java/blue/language/processor/model/IncrementProperty.java @@ -3,7 +3,7 @@ import blue.language.model.TypeBlueId; import blue.language.processor.model.HandlerContract; -@TypeBlueId("IncrementProperty") +@TypeBlueId("GsQfKqSUXxx24JTvsHDaY5pJ2cE6vZnn7j1NQ5RFDCWv") public class IncrementProperty extends HandlerContract { private String propertyKey; diff --git a/src/test/java/blue/language/processor/model/MutateEmbeddedPaths.java b/src/test/java/blue/language/processor/model/MutateEmbeddedPaths.java index 9fab9fe..fb7705a 100644 --- a/src/test/java/blue/language/processor/model/MutateEmbeddedPaths.java +++ b/src/test/java/blue/language/processor/model/MutateEmbeddedPaths.java @@ -3,6 +3,6 @@ import blue.language.model.TypeBlueId; import blue.language.processor.model.HandlerContract; -@TypeBlueId("MutateEmbeddedPaths") +@TypeBlueId("AYLVESeD9WrEegNra57vKC2RT65VCBqTz5n9f5MieEkA") public class MutateEmbeddedPaths extends HandlerContract { } diff --git a/src/test/java/blue/language/processor/model/MutateEvent.java b/src/test/java/blue/language/processor/model/MutateEvent.java index be5f848..b56ce70 100644 --- a/src/test/java/blue/language/processor/model/MutateEvent.java +++ b/src/test/java/blue/language/processor/model/MutateEvent.java @@ -3,6 +3,6 @@ import blue.language.model.TypeBlueId; import blue.language.processor.model.HandlerContract; -@TypeBlueId("MutateEvent") +@TypeBlueId("EgL9wruNhEJTS5RspenxoyRngKEbXzMwDM4ZZ8gCHsiv") public class MutateEvent extends HandlerContract { } diff --git a/src/main/java/blue/language/processor/model/ProcessingFailureMarker.java b/src/test/java/blue/language/processor/model/ProcessingFailureMarker.java similarity index 88% rename from src/main/java/blue/language/processor/model/ProcessingFailureMarker.java rename to src/test/java/blue/language/processor/model/ProcessingFailureMarker.java index 13762a5..7254ff0 100644 --- a/src/main/java/blue/language/processor/model/ProcessingFailureMarker.java +++ b/src/test/java/blue/language/processor/model/ProcessingFailureMarker.java @@ -2,7 +2,7 @@ import blue.language.model.TypeBlueId; -@TypeBlueId("ProcessingFailureMarker") +@TypeBlueId("33kfH8pfk7F1P5zMsuK1Jm3GcSdmTXoFHKjP16DesEco") public class ProcessingFailureMarker extends MarkerContract { private String code; diff --git a/src/test/java/blue/language/processor/model/RecordDocumentUpdate.java b/src/test/java/blue/language/processor/model/RecordDocumentUpdate.java index 3905963..99580bd 100644 --- a/src/test/java/blue/language/processor/model/RecordDocumentUpdate.java +++ b/src/test/java/blue/language/processor/model/RecordDocumentUpdate.java @@ -2,6 +2,6 @@ import blue.language.model.TypeBlueId; -@TypeBlueId("RecordDocumentUpdate") +@TypeBlueId("qLb75fi7BHJf8HvxXNTJP8Zo2fCsA3t6Lz5R269qUiC") public class RecordDocumentUpdate extends HandlerContract { } diff --git a/src/test/java/blue/language/processor/model/RemoveIfPresent.java b/src/test/java/blue/language/processor/model/RemoveIfPresent.java index eac8426..ffff26d 100644 --- a/src/test/java/blue/language/processor/model/RemoveIfPresent.java +++ b/src/test/java/blue/language/processor/model/RemoveIfPresent.java @@ -2,7 +2,7 @@ import blue.language.model.TypeBlueId; -@TypeBlueId("RemoveIfPresent") +@TypeBlueId("72r7LSWk5VP9Wh1e5KJX2x8Mrr7Yk8d8Zey9QTbDaHBe") public class RemoveIfPresent extends HandlerContract { private String propertyKey; diff --git a/src/test/java/blue/language/processor/model/RemoveProperty.java b/src/test/java/blue/language/processor/model/RemoveProperty.java index 92db22f..b0d1eff 100644 --- a/src/test/java/blue/language/processor/model/RemoveProperty.java +++ b/src/test/java/blue/language/processor/model/RemoveProperty.java @@ -3,7 +3,7 @@ import blue.language.model.TypeBlueId; import blue.language.processor.model.HandlerContract; -@TypeBlueId("RemoveProperty") +@TypeBlueId("2REa15BDY5EWq4tJsbUaBwhhTG2xSdk2ZyFL1aCpqTVF") public class RemoveProperty extends HandlerContract { private String propertyKey; diff --git a/src/test/java/blue/language/processor/model/SetProperty.java b/src/test/java/blue/language/processor/model/SetProperty.java index bdaead2..cb945c9 100644 --- a/src/test/java/blue/language/processor/model/SetProperty.java +++ b/src/test/java/blue/language/processor/model/SetProperty.java @@ -3,7 +3,7 @@ import blue.language.model.TypeBlueId; import blue.language.processor.model.HandlerContract; -@TypeBlueId("SetProperty") +@TypeBlueId("8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts") public class SetProperty extends HandlerContract { private String propertyKey; diff --git a/src/test/java/blue/language/processor/model/SetPropertyOnEvent.java b/src/test/java/blue/language/processor/model/SetPropertyOnEvent.java index 17dc08d..814cdff 100644 --- a/src/test/java/blue/language/processor/model/SetPropertyOnEvent.java +++ b/src/test/java/blue/language/processor/model/SetPropertyOnEvent.java @@ -3,7 +3,7 @@ import blue.language.model.TypeBlueId; import blue.language.processor.model.HandlerContract; -@TypeBlueId("SetPropertyOnEvent") +@TypeBlueId("H1qKGon7JWgUU9P8oUiHjxoR5hWbkAzVWWNukXf4cHz") public class SetPropertyOnEvent extends HandlerContract { private String expectedKind; diff --git a/src/test/java/blue/language/processor/model/TerminateScope.java b/src/test/java/blue/language/processor/model/TerminateScope.java index e402e15..17539c7 100644 --- a/src/test/java/blue/language/processor/model/TerminateScope.java +++ b/src/test/java/blue/language/processor/model/TerminateScope.java @@ -3,7 +3,7 @@ import blue.language.model.TypeBlueId; import blue.language.processor.model.HandlerContract; -@TypeBlueId("TerminateScope") +@TypeBlueId("AZNvNsADqpp7ZwAgpQyaQSz4cq3o3RMHZtB3sgDfudD4") public class TerminateScope extends HandlerContract { private String mode; diff --git a/src/test/java/blue/language/processor/model/TestEvent.java b/src/test/java/blue/language/processor/model/TestEvent.java index 599e978..d2c4678 100644 --- a/src/test/java/blue/language/processor/model/TestEvent.java +++ b/src/test/java/blue/language/processor/model/TestEvent.java @@ -3,7 +3,7 @@ import blue.language.model.Node; import blue.language.model.TypeBlueId; -@TypeBlueId("TestEvent") +@TypeBlueId("Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf") public class TestEvent { private String eventId; @@ -48,7 +48,7 @@ public TestEvent kind(String kind) { } public Node toNode() { - Node node = new Node().type(new Node().blueId("TestEvent")); + Node node = new Node().type(new Node().blueId("Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf")); if (eventId != null) { node.properties("eventId", new Node().value(eventId)); } diff --git a/src/test/java/blue/language/processor/model/TestEventChannel.java b/src/test/java/blue/language/processor/model/TestEventChannel.java index 49cea18..8eeabe0 100644 --- a/src/test/java/blue/language/processor/model/TestEventChannel.java +++ b/src/test/java/blue/language/processor/model/TestEventChannel.java @@ -3,7 +3,7 @@ import blue.language.model.TypeBlueId; import blue.language.processor.model.ChannelContract; -@TypeBlueId("TestEventChannel") +@TypeBlueId("BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L") public class TestEventChannel extends ChannelContract { private String eventType; diff --git a/src/test/java/blue/language/processor/registry/BlueRuntimeTypeRegistryTest.java b/src/test/java/blue/language/processor/registry/BlueRuntimeTypeRegistryTest.java new file mode 100644 index 0000000..a8e0310 --- /dev/null +++ b/src/test/java/blue/language/processor/registry/BlueRuntimeTypeRegistryTest.java @@ -0,0 +1,82 @@ +package blue.language.processor.registry; + +import blue.language.model.Node; +import blue.language.model.TypeBlueId; +import blue.language.processor.model.ChannelEventCheckpoint; +import blue.language.processor.model.DocumentUpdate; +import blue.language.processor.model.DocumentUpdateChannel; +import blue.language.processor.model.EmbeddedNodeChannel; +import blue.language.processor.model.InitializationMarker; +import blue.language.processor.model.JsonPatch; +import blue.language.processor.model.LifecycleChannel; +import blue.language.processor.model.ProcessEmbedded; +import blue.language.processor.model.ProcessingTerminatedMarker; +import blue.language.processor.model.TriggeredEventChannel; +import blue.language.processor.model.TypeGeneralizationPolicy; +import blue.language.processor.model.TypeGeneralizationRule; +import blue.language.utils.BlueIds; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BlueRuntimeTypeRegistryTest { + + @Test + void providerReturnsCanonicalNodesForRuntimeTypes() { + BlueRuntimeTypeRegistry registry = BlueRuntimeTypeRegistry.getDefault(); + + for (Map.Entry entry : registry.blueIds().entrySet()) { + List nodes = registry.asProvider().fetchByBlueId(entry.getValue()); + assertNotNull(nodes, entry.getKey().name()); + assertEquals(1, nodes.size(), entry.getKey().name()); + assertNotNull(nodes.get(0).getName(), entry.getKey().name()); + } + } + + @Test + void processorManagedTypeIdsAreCalculatedBlueIds() { + BlueRuntimeTypeRegistry registry = BlueRuntimeTypeRegistry.getDefault(); + Set managed = registry.processorManagedTypeBlueIds(); + + assertEquals(RuntimeTypeKey.values().length, managed.size()); + assertTrue(managed.contains(RuntimeBlueIds.DOCUMENT_UPDATE)); + assertTrue(managed.contains(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER)); + for (String blueId : managed) { + assertTrue(BlueIds.isPotentialBlueId(blueId), blueId); + assertFalse(blueId.contains(" "), blueId); + } + } + + @Test + void annotatedProcessorModelTypesUseRuntimeRegistryBlueIds() { + BlueRuntimeTypeRegistry registry = BlueRuntimeTypeRegistry.getDefault(); + Map, RuntimeTypeKey> expected = new HashMap<>(); + expected.put(ChannelEventCheckpoint.class, RuntimeTypeKey.CHANNEL_EVENT_CHECKPOINT); + expected.put(DocumentUpdate.class, RuntimeTypeKey.DOCUMENT_UPDATE); + expected.put(DocumentUpdateChannel.class, RuntimeTypeKey.DOCUMENT_UPDATE_CHANNEL); + expected.put(EmbeddedNodeChannel.class, RuntimeTypeKey.EMBEDDED_NODE_CHANNEL); + expected.put(InitializationMarker.class, RuntimeTypeKey.PROCESSING_INITIALIZED_MARKER); + expected.put(JsonPatch.class, RuntimeTypeKey.JSON_PATCH_ENTRY); + expected.put(LifecycleChannel.class, RuntimeTypeKey.LIFECYCLE_EVENT_CHANNEL); + expected.put(ProcessEmbedded.class, RuntimeTypeKey.PROCESS_EMBEDDED); + expected.put(ProcessingTerminatedMarker.class, RuntimeTypeKey.PROCESSING_TERMINATED_MARKER); + expected.put(TriggeredEventChannel.class, RuntimeTypeKey.TRIGGERED_EVENT_CHANNEL); + expected.put(TypeGeneralizationPolicy.class, RuntimeTypeKey.TYPE_GENERALIZATION_POLICY); + expected.put(TypeGeneralizationRule.class, RuntimeTypeKey.TYPE_GENERALIZATION_RULE); + + for (Map.Entry, RuntimeTypeKey> entry : expected.entrySet()) { + TypeBlueId annotation = entry.getKey().getAnnotation(TypeBlueId.class); + assertNotNull(annotation, entry.getKey().getSimpleName()); + assertEquals(1, annotation.value().length, entry.getKey().getSimpleName()); + assertEquals(registry.blueId(entry.getValue()), annotation.value()[0], entry.getKey().getSimpleName()); + } + } +} diff --git a/src/test/java/blue/language/processor/util/PointerUtilsTest.java b/src/test/java/blue/language/processor/util/PointerUtilsTest.java index 48ea851..805c6a9 100644 --- a/src/test/java/blue/language/processor/util/PointerUtilsTest.java +++ b/src/test/java/blue/language/processor/util/PointerUtilsTest.java @@ -5,6 +5,9 @@ import java.util.Arrays; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class PointerUtilsTest { @@ -26,4 +29,24 @@ void resolveAndRelativizeCompareDecodedSegments() { void joinRelativePointersEscapesLiteralSegments() { assertEquals("/a~1b/c~0d", PointerUtils.joinRelativePointers("/a~1b", "c~d")); } + + @Test + void descendantChecksAreSegmentAware() { + assertTrue(PointerUtils.descendantOrEqual("/a", "/a")); + assertTrue(PointerUtils.descendantOrEqual("/a/b", "/a")); + assertFalse(PointerUtils.descendantOrEqual("/ab", "/a")); + assertFalse(PointerUtils.strictlyInside("/a", "/a")); + assertTrue(PointerUtils.strictlyInside("/a/b", "/a")); + } + + @Test + void runtimePointerValidationRejectsMalformedPointers() { + assertEquals("/", PointerUtils.assertValidRuntimePointer("/")); + assertEquals("/a~1b/c~0d", PointerUtils.assertValidRuntimePointer("/a~1b/c~0d")); + assertThrows(IllegalArgumentException.class, () -> PointerUtils.assertValidRuntimePointer("")); + assertThrows(IllegalArgumentException.class, () -> PointerUtils.assertValidRuntimePointer("a")); + assertThrows(IllegalArgumentException.class, () -> PointerUtils.assertValidRuntimePointer("/a/")); + assertThrows(IllegalArgumentException.class, () -> PointerUtils.assertValidRuntimePointer("/a//b")); + assertThrows(IllegalArgumentException.class, () -> PointerUtils.assertValidRuntimePointer("/a~2b")); + } } diff --git a/src/test/java/blue/language/utils/BlueIdCalculatorTest.java b/src/test/java/blue/language/utils/BlueIdCalculatorTest.java index d1918f3..d61c1f3 100644 --- a/src/test/java/blue/language/utils/BlueIdCalculatorTest.java +++ b/src/test/java/blue/language/utils/BlueIdCalculatorTest.java @@ -414,7 +414,7 @@ public void testMultilineText1() { Node node = YAML_MAPPER.readValue(yaml, Node.class); String blueId = BlueIdCalculator.calculateBlueId(node); - String json = "{\"text\":{\"type\":{\"blueId\":\"DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K\"},\"value\":\"abc\\ndef\"}}"; + String json = "{\"text\":{\"type\":{\"blueId\":\"GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC\"},\"value\":\"abc\\ndef\"}}"; Node node2 = JSON_MAPPER.readValue(json, Node.class); String blueId2 = BlueIdCalculator.calculateBlueId(node2); @@ -430,7 +430,7 @@ public void testMultilineText2() { Node node = YAML_MAPPER.readValue(yaml, Node.class); String blueId = BlueIdCalculator.calculateBlueId(node); - String json = "{\"text\":{\"type\":{\"blueId\":\"DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K\"},\"value\":\"abc def\"}}\n"; + String json = "{\"text\":{\"type\":{\"blueId\":\"GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC\"},\"value\":\"abc def\"}}\n"; Node node2 = JSON_MAPPER.readValue(json, Node.class); String blueId2 = BlueIdCalculator.calculateBlueId(node2); diff --git a/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/T012_checkpoint_lazy_create_and_update.yaml b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/T012_checkpoint_lazy_create_and_update.yaml new file mode 100644 index 0000000..ad7e0d6 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/T012_checkpoint_lazy_create_and_update.yaml @@ -0,0 +1,33 @@ +id: T012_checkpoint_lazy_create_and_update +category: Checkpoint +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + handler: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /handled + val: + value: true +event: + eventId: checkpoint-1 +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPathExists: + - /contracts/checkpoint/lastEvents/channel +expectedDocumentPaths: + /handled: + value: true + /contracts/checkpoint/lastEvents/channel/eventId: + value: checkpoint-1 +expectedCheckpointLastEvents: + channel: + eventId: checkpoint-1 diff --git a/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/T013_stale_event_no_checkpoint_update.yaml b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/T013_stale_event_no_checkpoint_update.yaml new file mode 100644 index 0000000..b47b1f6 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/T013_stale_event_no_checkpoint_update.yaml @@ -0,0 +1,43 @@ +id: T013_stale_event_no_checkpoint_update +category: Checkpoint +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + checkpoint: + type: + blueId: "9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1" + lastEvents: + channel: + eventId: stale + initialized: + type: + blueId: "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q" + documentId: preinitialized + handler: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /shouldNotRun + val: + value: true +event: + eventId: stale +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPathExists: + - /contracts/checkpoint/lastEvents/channel +expectedDocumentPaths: + /contracts/checkpoint/lastEvents/channel/eventId: + value: stale +expectedAbsentDocumentPaths: + - /shouldNotRun +expectedCheckpointLastEvents: + channel: + eventId: stale diff --git a/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointDefaultUsesContentBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointDefaultUsesContentBlueId.yaml new file mode 100644 index 0000000..cc547f1 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointDefaultUsesContentBlueId.yaml @@ -0,0 +1,56 @@ +id: checkpointDefaultUsesContentBlueId +category: Checkpoint +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + checkpoint: + type: + blueId: 9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1 + lastEvents: + incoming: + value: + orderId: A1 + amount: 10 + handler: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + orderId: A1 + amount: 10 +mockRuntime: + channels: + - contract: /contracts/incoming + checkpointIdentityMode: contentBlueId + calls: + - when: + eventContentBlueId: same-as-lastEvents.incoming + accepted: true + payload: + orderId: A1 + amount: 10 + handlers: + - contract: /contracts/handler + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /shouldNotRun + val: true +expectedStatus: success +expectedAbsentDocumentPaths: + - /shouldNotRun +expectedCheckpointLastEvents: + incoming: + value: + orderId: A1 + amount: 10 +assertions: + - Same content is stale even if the Source spelling used by the feeder differed before preprocessing. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointEventIdDoesNotOverrideDefaultIdentity.yaml b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointEventIdDoesNotOverrideDefaultIdentity.yaml new file mode 100644 index 0000000..1256338 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointEventIdDoesNotOverrideDefaultIdentity.yaml @@ -0,0 +1,55 @@ +id: checkpointEventIdDoesNotOverrideDefaultIdentity +category: Checkpoint +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + checkpoint: + type: + blueId: 9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1 + lastEvents: + incoming: + eventId: same + amount: 10 + handler: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + eventId: same + amount: 11 +mockRuntime: + channels: + - contract: /contracts/incoming + checkpointIdentityMode: contentBlueId + calls: + - when: + event: + eventId: same + amount: 11 + accepted: true + payload: + eventId: same + amount: 11 + handlers: + - contract: /contracts/handler + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /handled + val: true +expectedStatus: success +expectedDocumentPaths: + /handled: + value: true + /contracts/checkpoint/lastEvents/incoming/amount: + value: 11 +assertions: + - eventId has no special meaning under default contentBlueId identity. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointNodeBlueIdModeRequiresBlueIdInput.yaml b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointNodeBlueIdModeRequiresBlueIdInput.yaml new file mode 100644 index 0000000..b4ec8f7 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointNodeBlueIdModeRequiresBlueIdInput.yaml @@ -0,0 +1,33 @@ +id: checkpointNodeBlueIdModeRequiresBlueIdInput +category: Checkpoint +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + checkpointIdentityMode: nodeBlueId + handler: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + blue: + type: Text + value: source-only-event +mockRuntime: + channels: + - contract: /contracts/incoming + checkpointIdentityMode: nodeBlueId + calls: + - when: + event: any + accepted: true + payload: + value: source-only-event +expectedStatus: runtime-fatal +expectedErrorCategory: CheckpointError +expectedAbsentDocumentPaths: + - /contracts/checkpoint/lastEvents/incoming diff --git a/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointStoresPreprocessedSubject.yaml b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointStoresPreprocessedSubject.yaml new file mode 100644 index 0000000..6866ef1 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointStoresPreprocessedSubject.yaml @@ -0,0 +1,42 @@ +id: checkpointStoresPreprocessedSubject +category: Checkpoint +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + handler: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + value: normalized-subject +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + value: normalized-subject + accepted: true + payload: + value: normalized-subject + handlers: + - contract: /contracts/handler + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /handled + val: true +expectedStatus: success +expectedDocumentPaths: + /contracts/checkpoint/lastEvents/incoming/value: + value: normalized-subject +expectedAbsentDocumentPaths: + - /contracts/checkpoint/lastEvents/incoming/blue diff --git a/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointStoresRawChannelKeyWithSlashAndUsesEscapedPointerForWrite.yaml b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointStoresRawChannelKeyWithSlashAndUsesEscapedPointerForWrite.yaml new file mode 100644 index 0000000..b0d66f6 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/checkpoint/checkpointStoresRawChannelKeyWithSlashAndUsesEscapedPointerForWrite.yaml @@ -0,0 +1,47 @@ +id: checkpointStoresRawChannelKeyWithSlashAndUsesEscapedPointerForWrite +category: Checkpoint +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + orders/incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + handle: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: orders/incoming +event: + orderId: A1 +mockRuntime: + channels: + - contract: /contracts/orders~1incoming + calls: + - when: + event: + orderId: A1 + accepted: true + payload: + orderId: A1 + handlers: + - contract: /contracts/handle + calls: + - when: + channelKey: orders/incoming + result: + patches: + - op: replace + path: /handled + val: true +expectedStatus: success +expectedDocumentPaths: + /handled: + value: true + /contracts/checkpoint/lastEvents/orders~1incoming/orderId: + value: A1 +expectedPointerWrites: + - /contracts/checkpoint/lastEvents/orders~1incoming +expectedStoredObjectKeys: + /contracts/checkpoint/lastEvents: + - orders/incoming diff --git a/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyEmptyRejected.yaml b/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyEmptyRejected.yaml new file mode 100644 index 0000000..dfecdfc --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyEmptyRejected.yaml @@ -0,0 +1,15 @@ +id: contractKeyEmptyRejected +category: ContractKey +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + "": + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm +event: + kind: key-check +expectedStatus: runtime-fatal +expectedErrorCategory: InvalidRuntimePointer +expectedNoDocumentMutation: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyReservedTypeRejected.yaml b/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyReservedTypeRejected.yaml new file mode 100644 index 0000000..ef4ac89 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyReservedTypeRejected.yaml @@ -0,0 +1,14 @@ +id: contractKeyReservedTypeRejected +category: ContractKey +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm +event: + kind: key-check +expectedStatus: runtime-fatal +expectedErrorCategory: InvalidReservedMarker +expectedNoDocumentMutation: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyReservedValueRejected.yaml b/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyReservedValueRejected.yaml new file mode 100644 index 0000000..25b8639 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeyReservedValueRejected.yaml @@ -0,0 +1,15 @@ +id: contractKeyReservedValueRejected +category: ContractKey +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + value: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm +event: + kind: key-check +expectedStatus: runtime-fatal +expectedErrorCategory: InvalidReservedMarker +expectedNoDocumentMutation: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeySlashStoredRawEscapedOnlyInPointer.yaml b/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeySlashStoredRawEscapedOnlyInPointer.yaml new file mode 100644 index 0000000..b54497c --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/contract-key/contractKeySlashStoredRawEscapedOnlyInPointer.yaml @@ -0,0 +1,45 @@ +id: contractKeySlashStoredRawEscapedOnlyInPointer +category: ContractKey +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + a/b: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + handler: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: a/b +event: + kind: slash-key +mockRuntime: + channels: + - contract: /contracts/a~1b + calls: + - when: + event: + kind: slash-key + accepted: true + payload: + kind: slash-key + handlers: + - contract: /contracts/handler + calls: + - when: + channelKey: a/b + result: + patches: + - op: replace + path: /handled + val: true +expectedStatus: success +expectedDocumentPaths: + /handled: + value: true +expectedStoredObjectKeys: + /contracts: + - a/b +expectedPointerReads: + - /contracts/a~1b diff --git a/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T018_dispatch_snapshot_stable_after_handler_mutation.yaml b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T018_dispatch_snapshot_stable_after_handler_mutation.yaml new file mode 100644 index 0000000..267bd9a --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T018_dispatch_snapshot_stable_after_handler_mutation.yaml @@ -0,0 +1,37 @@ +id: T018_dispatch_snapshot_stable_after_handler_mutation +category: DispatchSnapshot +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + first: + order: 0 + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: remove + path: /contracts/second + second: + order: 1 + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /secondRan + val: + value: true +event: + kind: snapshot +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPaths: + /secondRan: + value: true +expectedAbsentDocumentPaths: + - /contracts/second diff --git a/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T064_removing_later_handler_does_not_affect_current_delivery.yaml b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T064_removing_later_handler_does_not_affect_current_delivery.yaml new file mode 100644 index 0000000..c852b40 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T064_removing_later_handler_does_not_affect_current_delivery.yaml @@ -0,0 +1,34 @@ +id: T064_removing_later_handler_does_not_affect_current_delivery +category: DispatchSnapshot +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + first: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: remove + path: /contracts/second + second: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /secondRan + val: + value: true +event: + kind: snapshot-remove +expectedCapabilityFailure: false +expectedDocumentPaths: + /secondRan: + value: true +expectedAbsentDocumentPaths: + - /contracts/second diff --git a/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T065_replacing_later_handler_does_not_affect_current_delivery_content.yaml b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T065_replacing_later_handler_does_not_affect_current_delivery_content.yaml new file mode 100644 index 0000000..947ae56 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T065_replacing_later_handler_does_not_affect_current_delivery_content.yaml @@ -0,0 +1,46 @@ +id: T065_replacing_later_handler_does_not_affect_current_delivery_content +category: DispatchSnapshot +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + first: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /contracts/second + val: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /replacedContentRan + val: + value: true + second: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /originalContentRan + val: + value: true +event: + kind: snapshot-replace +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/second + - /originalContentRan +expectedAbsentDocumentPaths: + - /replacedContentRan +expectedDocumentPaths: + /contracts/second/patches/0/path: + value: /replacedContentRan diff --git a/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T066_adding_handler_during_delivery_does_not_run_immediately.yaml b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T066_adding_handler_during_delivery_does_not_run_immediately.yaml new file mode 100644 index 0000000..e26f678 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T066_adding_handler_during_delivery_does_not_run_immediately.yaml @@ -0,0 +1,31 @@ +id: T066_adding_handler_during_delivery_does_not_run_immediately +category: DispatchSnapshot +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + first: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /contracts/added + val: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /addedRan + val: + value: true +event: + kind: snapshot-add +expectedCapabilityFailure: false +expectedAbsentDocumentPaths: + - /addedRan diff --git a/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T067_removing_later_external_channel_does_not_remove_current_phase3_candidate.yaml b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T067_removing_later_external_channel_does_not_remove_current_phase3_candidate.yaml new file mode 100644 index 0000000..91bda57 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T067_removing_later_external_channel_does_not_remove_current_phase3_candidate.yaml @@ -0,0 +1,37 @@ +id: T067_removing_later_external_channel_does_not_remove_current_phase3_candidate +category: DispatchSnapshot +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channelA: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + channelB: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + removeB: + channel: channelA + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: remove + path: /contracts/channelB + handlerB: + channel: channelB + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /channelBStillEvaluated + val: + value: true +event: + kind: external-snapshot +expectedCapabilityFailure: false +expectedDocumentPaths: + /channelBStillEvaluated: + value: true +expectedAbsentDocumentPaths: + - /contracts/channelB diff --git a/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T068_document_update_delivery_snapshots_handlers_before_first_handler.yaml b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T068_document_update_delivery_snapshots_handlers_before_first_handler.yaml new file mode 100644 index 0000000..cf396ce --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/T068_document_update_delivery_snapshots_handlers_before_first_handler.yaml @@ -0,0 +1,47 @@ +id: T068_document_update_delivery_snapshots_handlers_before_first_handler +category: DispatchSnapshot +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + docUpdate: + type: + blueId: "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o" + path: /watched + firstDu: + channel: docUpdate + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: remove + path: /contracts/secondDu + secondDu: + channel: docUpdate + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /secondDuRan + val: + value: true + patcher: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /watched + val: + value: changed +event: + kind: du-snapshot +expectedCapabilityFailure: false +expectedDocumentPaths: + /secondDuRan: + value: true +expectedAbsentDocumentPaths: + - /contracts/secondDu diff --git a/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/embeddedNodeChannelAddedDuringBridgeDoesNotAffectAlreadySnapshottedEmission.yaml b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/embeddedNodeChannelAddedDuringBridgeDoesNotAffectAlreadySnapshottedEmission.yaml new file mode 100644 index 0000000..e3d231d --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/embeddedNodeChannelAddedDuringBridgeDoesNotAffectAlreadySnapshottedEmission.yaml @@ -0,0 +1,37 @@ +id: embeddedNodeChannelAddedDuringBridgeDoesNotAffectAlreadySnapshottedEmission +category: DispatchSnapshot +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + embedded: + type: + blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q + paths: + - /child + bridge: + type: + blueId: H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i + childPath: /child + child: + contracts: {} +event: + kind: bridge-add-channel +mockRuntime: + childEmissions: + /child: + - id: child-1 + - id: child-2 + bridgeMutations: + - duringEmission: child-1 + addChannelKey: lateBridge + childPath: /child +expectedStatus: success +expectedEmbeddedDeliveryOrder: + - emission: child-1 + channels: [bridge] + - emission: child-2 + channels: [bridge, lateBridge] +expectedDocumentPathExists: + - /contracts/lateBridge diff --git a/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/embeddedNodeChannelRemovedDuringBridgeDoesNotRemoveCurrentEmissionDelivery.yaml b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/embeddedNodeChannelRemovedDuringBridgeDoesNotRemoveCurrentEmissionDelivery.yaml new file mode 100644 index 0000000..2f87383 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/embeddedNodeChannelRemovedDuringBridgeDoesNotRemoveCurrentEmissionDelivery.yaml @@ -0,0 +1,36 @@ +id: embeddedNodeChannelRemovedDuringBridgeDoesNotRemoveCurrentEmissionDelivery +category: DispatchSnapshot +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + embedded: + type: + blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q + paths: + - /child + bridge: + type: + blueId: H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i + childPath: /child + child: + contracts: {} +event: + kind: bridge-remove-channel +mockRuntime: + childEmissions: + /child: + - id: child-1 + - id: child-2 + bridgeMutations: + - duringEmission: child-1 + removeChannelKey: bridge +expectedStatus: success +expectedEmbeddedDeliveryOrder: + - emission: child-1 + channels: [bridge] + - emission: child-2 + channels: [] +expectedAbsentDocumentPaths: + - /contracts/bridge diff --git a/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/triggeredChannelAddedDuringDrainDoesNotAffectCurrentEventButCanAffectLaterEvent.yaml b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/triggeredChannelAddedDuringDrainDoesNotAffectCurrentEventButCanAffectLaterEvent.yaml new file mode 100644 index 0000000..e0b8c6d --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/dispatch-snapshot/triggeredChannelAddedDuringDrainDoesNotAffectCurrentEventButCanAffectLaterEvent.yaml @@ -0,0 +1,54 @@ +id: triggeredChannelAddedDuringDrainDoesNotAffectCurrentEventButCanAffectLaterEvent +category: DispatchSnapshot +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + orderLog: + items: [] + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + trigger: + type: + blueId: 5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ + handler: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + kind: seed-fifo +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: seed-fifo + accepted: true + payload: + kind: seed-fifo + handlers: + - contract: /contracts/handler + calls: + - when: + channelKey: incoming + result: + triggeredEvents: + - id: E1 + - id: E2 + patches: + - op: replace + path: /contracts/lateTrigger + val: + type: + blueId: 5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ +expectedStatus: success +expectedTriggeredDeliveryOrder: + - event: E1 + channels: [trigger] + - event: E2 + channels: [trigger, lateTrigger] +expectedDocumentPathExists: + - /contracts/lateTrigger diff --git a/src/test/resources/blue-contracts-1.0/fixtures/document-update/T007_document_update_channel_added_by_patch_receives_same_update.yaml b/src/test/resources/blue-contracts-1.0/fixtures/document-update/T007_document_update_channel_added_by_patch_receives_same_update.yaml new file mode 100644 index 0000000..a9e2cd9 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/document-update/T007_document_update_channel_added_by_patch_receives_same_update.yaml @@ -0,0 +1,34 @@ +id: T007_document_update_channel_added_by_patch_receives_same_update +category: DocumentUpdate +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + docUpdateHandler: + channel: docUpdate + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /docUpdateHandlerRan + val: + value: true + patcher: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + addDocumentUpdateChannelAt: /contracts/docUpdate + documentUpdatePath: /contracts/docUpdate +event: + kind: document-update +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPaths: + /docUpdateHandlerRan: + value: true +expectedAbsentDocumentPaths: + - /someIncorrectMarker diff --git a/src/test/resources/blue-contracts-1.0/fixtures/document-update/T008_document_update_channel_removed_by_patch_does_not_receive_same_update.yaml b/src/test/resources/blue-contracts-1.0/fixtures/document-update/T008_document_update_channel_removed_by_patch_does_not_receive_same_update.yaml new file mode 100644 index 0000000..2aa7835 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/document-update/T008_document_update_channel_removed_by_patch_does_not_receive_same_update.yaml @@ -0,0 +1,44 @@ +id: T008_document_update_channel_removed_by_patch_does_not_receive_same_update +category: DocumentUpdate +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + docUpdate: + type: + blueId: "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o" + path: /contracts/docUpdate + removedChannelHandler: + channel: docUpdate + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /removedChannelSawRemoval + val: + value: true + patcher: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: remove + path: /contracts/docUpdate + - op: replace + path: /watched + val: + value: changed +event: + kind: document-update +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPaths: + /watched: + value: changed +expectedAbsentDocumentPaths: + - /removedChannelSawRemoval + - /contracts/docUpdate diff --git a/src/test/resources/blue-contracts-1.0/fixtures/document-update/documentUpdateNullSentinelsAreRuntimePayloadOnly.yaml b/src/test/resources/blue-contracts-1.0/fixtures/document-update/documentUpdateNullSentinelsAreRuntimePayloadOnly.yaml new file mode 100644 index 0000000..64c2f3d --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/document-update/documentUpdateNullSentinelsAreRuntimePayloadOnly.yaml @@ -0,0 +1,51 @@ +id: documentUpdateNullSentinelsAreRuntimePayloadOnly +category: DocumentUpdate +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + patcher: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming + watchAdded: + type: + blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o + path: /added +event: + kind: add-node +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: add-node + accepted: true + payload: + kind: add-node + handlers: + - contract: /contracts/patcher + calls: + - when: + channelKey: incoming + result: + patches: + - op: add + path: /added + val: created +expectedStatus: success +expectedDocumentPaths: + /added: + value: created +expectedDocumentUpdates: + - path: /added + before: null + after: + value: created +assertions: + - before null is a delivered runtime absence sentinel, not BlueId-preserved content. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/effects/handlerCallingEmitThenPatchStillAppliesPatchBeforeEmission.yaml b/src/test/resources/blue-contracts-1.0/fixtures/effects/handlerCallingEmitThenPatchStillAppliesPatchBeforeEmission.yaml new file mode 100644 index 0000000..95cb0bc --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/effects/handlerCallingEmitThenPatchStillAppliesPatchBeforeEmission.yaml @@ -0,0 +1,52 @@ +id: handlerCallingEmitThenPatchStillAppliesPatchBeforeEmission +category: Effects +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + buffered: + type: + blueId: 3rHWt14WhTvmBBQ6Cr1Mb263KuxSdwqvb2jD7oPbkNL3 + channel: incoming +event: + kind: emit-then-patch +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: emit-then-patch + accepted: true + payload: + kind: emit-then-patch + handlers: + - contract: /contracts/buffered + calls: + - when: + channelKey: incoming + hostApiCalls: + - emitEvent: + kind: emitted-before-patch-call + - applyPatch: + op: replace + path: /patched + val: true + result: + patches: + - op: replace + path: /patched + val: true + triggeredEvents: + - kind: emitted-before-patch-call +expectedStatus: success +expectedDocumentPaths: + /patched: + value: true +expectedEffectApplicationOrder: + - patch:/patched + - triggeredEvent:emitted-before-patch-call diff --git a/src/test/resources/blue-contracts-1.0/fixtures/effects/handlerCallingTerminateThenPatchStillAppliesPatchBeforeTermination.yaml b/src/test/resources/blue-contracts-1.0/fixtures/effects/handlerCallingTerminateThenPatchStillAppliesPatchBeforeTermination.yaml new file mode 100644 index 0000000..eb01dcb --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/effects/handlerCallingTerminateThenPatchStillAppliesPatchBeforeTermination.yaml @@ -0,0 +1,56 @@ +id: handlerCallingTerminateThenPatchStillAppliesPatchBeforeTermination +category: Effects +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + buffered: + type: + blueId: 3rHWt14WhTvmBBQ6Cr1Mb263KuxSdwqvb2jD7oPbkNL3 + channel: incoming +event: + kind: terminate-then-patch +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: terminate-then-patch + accepted: true + payload: + kind: terminate-then-patch + handlers: + - contract: /contracts/buffered + calls: + - when: + channelKey: incoming + hostApiCalls: + - terminate: + cause: graceful + reason: requested before patch call + - applyPatch: + op: replace + path: /patchedBeforeTermination + val: true + result: + patches: + - op: replace + path: /patchedBeforeTermination + val: true + termination: + cause: graceful + reason: requested before patch call +expectedStatus: success +expectedDocumentPaths: + /patchedBeforeTermination: + value: true + /contracts/terminated/cause: + value: graceful +expectedEffectApplicationOrder: + - patch:/patchedBeforeTermination + - termination:graceful diff --git a/src/test/resources/blue-contracts-1.0/fixtures/effects/handlerThrowsAfterBufferingPatchDiscardsOwnBuffer.yaml b/src/test/resources/blue-contracts-1.0/fixtures/effects/handlerThrowsAfterBufferingPatchDiscardsOwnBuffer.yaml new file mode 100644 index 0000000..3ae78f5 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/effects/handlerThrowsAfterBufferingPatchDiscardsOwnBuffer.yaml @@ -0,0 +1,45 @@ +id: handlerThrowsAfterBufferingPatchDiscardsOwnBuffer +category: Effects +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + throwing: + type: + blueId: DGfkvtSJ9ruXQWbA1XRdjCExi4LGoQvg13Tu1nrMUFsY + channel: incoming +event: + kind: throw-after-buffering +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: throw-after-buffering + accepted: true + payload: + kind: throw-after-buffering + handlers: + - contract: /contracts/throwing + calls: + - when: + channelKey: incoming + hostApiCalls: + - applyPatch: + op: replace + path: /bufferedPatchApplied + val: true + - throw: + category: HandlerExecutionError +expectedStatus: runtime-fatal +expectedErrorCategory: HandlerExecutionError +expectedAbsentDocumentPaths: + - /bufferedPatchApplied +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T010_embedded_bridge_before_parent_fifo.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T010_embedded_bridge_before_parent_fifo.yaml new file mode 100644 index 0000000..5574b09 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T010_embedded_bridge_before_parent_fifo.yaml @@ -0,0 +1,72 @@ +id: T010_embedded_bridge_before_parent_fifo +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + order: + - value: start + child: + contracts: + childChannel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + childHandler: + channel: childChannel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + triggeredEvents: + - value: child-event + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child + bridge: + type: + blueId: "H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i" + childPath: /child + bridgeHandler: + channel: bridge + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: add + path: /order/- + val: + value: bridge + parentChannel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + parentExternal: + channel: parentChannel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + triggeredEvents: + - value: parent-fifo-event + triggered: + type: + blueId: "5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ" + parentFifo: + channel: triggered + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: add + path: /order/- + val: + value: fifo +event: + kind: embedded +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPaths: + /order/0: + value: start + /order/1: + value: bridge + /order/2: + value: bridge + /order/3: + value: fifo diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T011_embedded_path_slash_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T011_embedded_path_slash_fatal.yaml new file mode 100644 index 0000000..8998a46 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T011_embedded_path_slash_fatal.yaml @@ -0,0 +1,21 @@ +id: T011_embedded_path_slash_fatal +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - / +event: + kind: embedded +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPathExists: + - /contracts/terminated +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T029_duplicate_embedded_paths_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T029_duplicate_embedded_paths_fatal.yaml new file mode 100644 index 0000000..1800693 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T029_duplicate_embedded_paths_fatal.yaml @@ -0,0 +1,21 @@ +id: T029_duplicate_embedded_paths_fatal +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: {} + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child + - /child +event: + kind: duplicate-embedded +expectedCapabilityFailure: true +expectedNoDocumentMutation: true +expectedTotalGas: 0 +expectedRootEventCount: 0 +expectedFailureReasonContains: Unique items are required diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T030_malformed_embedded_path_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T030_malformed_embedded_path_fatal.yaml new file mode 100644 index 0000000..122ec51 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T030_malformed_embedded_path_fatal.yaml @@ -0,0 +1,19 @@ +id: T030_malformed_embedded_path_fatal +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /bad~2path +event: + kind: malformed-embedded +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPathExists: + - /contracts/terminated +expectedFailureReasonContains: escape diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T031_missing_embedded_path_skipped_and_marked_processed.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T031_missing_embedded_path_skipped_and_marked_processed.yaml new file mode 100644 index 0000000..1c07e28 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T031_missing_embedded_path_skipped_and_marked_processed.yaml @@ -0,0 +1,32 @@ +id: T031_missing_embedded_path_skipped_and_marked_processed +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /missing + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /parentRan + val: + value: true +event: + kind: missing-embedded +expectedCapabilityFailure: false +expectedDocumentPaths: + /parentRan: + value: true +expectedAbsentDocumentPaths: + - /missing diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T032_embedded_path_non_object_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T032_embedded_path_non_object_fatal.yaml new file mode 100644 index 0000000..483bfce --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T032_embedded_path_non_object_fatal.yaml @@ -0,0 +1,21 @@ +id: T032_embedded_path_non_object_fatal +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + value: scalar + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child +event: + kind: non-object-child +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPathExists: + - /contracts/terminated +expectedFailureReasonContains: object diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T033_embedded_rereads_paths_after_each_child.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T033_embedded_rereads_paths_after_each_child.yaml new file mode 100644 index 0000000..25236ee --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T033_embedded_rereads_paths_after_each_child.yaml @@ -0,0 +1,54 @@ +id: T033_embedded_rereads_paths_after_each_child +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +description: > + Stable embedded path list re-read smoke fixture. This fixture proves ordinary + per-child re-read behavior with unchanged paths; dynamic mutation of + contracts/embedded.paths is covered by + T001_dynamic_embedded_paths_mutation_allowed_only_for_paths. +initialDocument: + first: + contracts: + childChannel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + firstHandler: + channel: childChannel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /first/firstRan + val: + value: true + second: + contracts: + secondChannel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + secondHandler: + channel: secondChannel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /second/secondRan + val: + value: true + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /first + - /second +event: + kind: reread +expectedCapabilityFailure: false +expectedDocumentPaths: + /first/firstRan: + value: true + /second/secondRan: + value: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T034_embedded_no_resurrection_after_remove_and_readd.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T034_embedded_no_resurrection_after_remove_and_readd.yaml new file mode 100644 index 0000000..e156cec --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T034_embedded_no_resurrection_after_remove_and_readd.yaml @@ -0,0 +1,58 @@ +id: T034_embedded_no_resurrection_after_remove_and_readd +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + childChannel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + childHandler: + channel: childChannel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /child/childRan + val: + value: true + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + replaceChild: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /child + val: + contracts: + childChannel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + childHandler: + channel: childChannel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /resurrectedRan + val: + value: true +event: + kind: no-resurrection +expectedCapabilityFailure: false +expectedDocumentPaths: + /child/childRan: + value: true +expectedAbsentDocumentPaths: + - /resurrectedRan diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T035_bridge_uses_processed_paths_insertion_order.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T035_bridge_uses_processed_paths_insertion_order.yaml new file mode 100644 index 0000000..0f47980 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T035_bridge_uses_processed_paths_insertion_order.yaml @@ -0,0 +1,74 @@ +id: T035_bridge_uses_processed_paths_insertion_order +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + order: [] + a: + contracts: + c: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + h: + channel: c + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + triggeredEvents: + - value: a + b: + contracts: + c: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + h: + channel: c + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + triggeredEvents: + - value: b + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /b + - /a + bridgeB: + type: + blueId: "H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i" + childPath: /b + bridgeA: + type: + blueId: "H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i" + childPath: /a + hb: + channel: bridgeB + event: + value: b + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: add + path: /order/- + val: + value: b + ha: + channel: bridgeA + event: + value: a + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: add + path: /order/- + val: + value: a +event: + kind: bridge-order +expectedCapabilityFailure: false +expectedDocumentPaths: + /order/0: + value: b + /order/1: + value: a diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T036_bridge_charges_only_when_delivered_to_matching_channel.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T036_bridge_charges_only_when_delivered_to_matching_channel.yaml new file mode 100644 index 0000000..f1be5c6 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T036_bridge_charges_only_when_delivered_to_matching_channel.yaml @@ -0,0 +1,43 @@ +id: T036_bridge_charges_only_when_delivered_to_matching_channel +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + c: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + h: + channel: c + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + triggeredEvents: + - value: bridged + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child + bridge: + type: + blueId: "H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i" + childPath: /child + h: + channel: bridge + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /bridgeDelivered + val: + value: true +event: + kind: bridge-gas +expectedCapabilityFailure: false +expectedTotalGasMin: 10 +expectedDocumentPaths: + /bridgeDelivered: + value: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/embedded/T037_embedded_node_handler_runs_in_parent_scope_and_cannot_patch_inside_child.yaml b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T037_embedded_node_handler_runs_in_parent_scope_and_cannot_patch_inside_child.yaml new file mode 100644 index 0000000..0bb8e14 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/embedded/T037_embedded_node_handler_runs_in_parent_scope_and_cannot_patch_inside_child.yaml @@ -0,0 +1,46 @@ +id: T037_embedded_node_handler_runs_in_parent_scope_and_cannot_patch_inside_child +category: Embedded +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + c: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + h: + channel: c + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + triggeredEvents: + - value: child + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child + bridge: + type: + blueId: "H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i" + childPath: /child + h: + channel: bridge + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /child/inside + val: + value: forbidden +event: + kind: parent-boundary +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedAbsentDocumentPaths: + - /child/inside +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal diff --git a/src/test/resources/blue-contracts-1.0/fixtures/events/processorEmittedEventsIncludeRuntimeTypeBlueIds.yaml b/src/test/resources/blue-contracts-1.0/fixtures/events/processorEmittedEventsIncludeRuntimeTypeBlueIds.yaml new file mode 100644 index 0000000..53e9811 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/events/processorEmittedEventsIncludeRuntimeTypeBlueIds.yaml @@ -0,0 +1,53 @@ +id: processorEmittedEventsIncludeRuntimeTypeBlueIds +category: Events +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + patcher: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming + watchAny: + type: + blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o + path: /flag +event: + kind: emit-runtime-events +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: emit-runtime-events + accepted: true + payload: + kind: emit-runtime-events + handlers: + - contract: /contracts/patcher + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /flag + val: true +expectedStatus: success +expectedProcessorEventTypes: + DocumentUpdate: + blueId: 7HEaG1SpBdsbVHsrwRTZSZGmpJUWHfFoEzecYWpjo1vm + DocumentProcessingInitiated: + blueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL + DocumentProcessingTerminated: + blueId: 4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK + DocumentProcessingFatalError: + blueId: AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC +expectedDocumentPaths: + /flag: + value: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/fixture_update_summary.md b/src/test/resources/blue-contracts-1.0/fixtures/fixture_update_summary.md new file mode 100644 index 0000000..c6e3b5c --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/fixture_update_summary.md @@ -0,0 +1,31 @@ +# Blue Contracts 1.0 Fixture Package + +This directory is the Blue Contracts and Processor 1.0 conformance fixture +package referenced by the runtime registry manifest. + +## Fixture Package Identity + +`fixturePackageIdentity` is a SHA-256 digest over the fixture manifest and every +listed fixture file. + +Current identity: + +```text +sha256:2f197ca3bbdc41b75e772777cc48e51019754347e1bee26b5f3209b71d9bd9ca +``` + +Digest calculation: + +1. Normalize all line endings to LF. +2. Start the digest with the UTF-8 bytes of `manifest.yaml\n`. +3. Read `manifest.yaml`, replace the line beginning `fixturePackageIdentity:` + with `fixturePackageIdentity: ""`, normalize line endings, and append those + bytes. +4. Iterate manifest `fixtures` in manifest order. Do not sort paths separately. +5. For each fixture, append the UTF-8 bytes of `\n--- \n`, then append the + fixture file bytes after LF line-ending normalization. +6. Encode the digest as lowercase hexadecimal prefixed by `sha256:`. + +The release manifest uses `requiredFixtureSet: exact`. Release tooling should +verify that every manifest entry exists, every fixture ID is unique, and no +unlisted fixture YAML files are present. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T019_gas_boundary_per_patch.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T019_gas_boundary_per_patch.yaml new file mode 100644 index 0000000..f7e45c8 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T019_gas_boundary_per_patch.yaml @@ -0,0 +1,32 @@ +id: T019_gas_boundary_per_patch +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + patcher: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /a + val: + value: 1 + - op: replace + path: /b + val: + value: 2 +event: + kind: gas +expectedCapabilityFailure: false +expectedTotalGasMin: 2 +expectedDocumentPaths: + /a: + value: 1 + /b: + value: 2 diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T069_boundary_gas_per_patch_exact.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T069_boundary_gas_per_patch_exact.yaml new file mode 100644 index 0000000..eeae7aa --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T069_boundary_gas_per_patch_exact.yaml @@ -0,0 +1,32 @@ +id: T069_boundary_gas_per_patch_exact +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /a + val: + value: 1 + - op: replace + path: /b + val: + value: 2 +event: + kind: gas-boundary-exact +expectedCapabilityFailure: false +expectedExactGas: 1225 +expectedDocumentPaths: + /a: + value: 1 + /b: + value: 2 diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T070_cascade_gas_only_for_participating_scopes_exact.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T070_cascade_gas_only_for_participating_scopes_exact.yaml new file mode 100644 index 0000000..2e9cf7e --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T070_cascade_gas_only_for_participating_scopes_exact.yaml @@ -0,0 +1,39 @@ +id: T070_cascade_gas_only_for_participating_scopes_exact +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + docUpdate: + type: + blueId: "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o" + path: /watched + duHandler: + channel: docUpdate + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /participantRan + val: + value: true + patcher: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /watched + val: + value: changed +event: + kind: cascade-gas +expectedCapabilityFailure: false +expectedExactGas: 1285 +expectedDocumentPaths: + /participantRan: + value: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T071_external_channel_attempt_gas_for_rejected_candidates_exact.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T071_external_channel_attempt_gas_for_rejected_candidates_exact.yaml new file mode 100644 index 0000000..8695eb2 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T071_external_channel_attempt_gas_for_rejected_candidates_exact.yaml @@ -0,0 +1,31 @@ +id: T071_external_channel_attempt_gas_for_rejected_candidates_exact +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + rejected: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + accept: + value: false + accepted: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: accepted + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /acceptedRan + val: + value: true +event: + kind: external-gas +expectedCapabilityFailure: false +expectedExactGas: 1207 +expectedDocumentPaths: + /acceptedRan: + value: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T072_no_free_external_channel_prefiltering.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T072_no_free_external_channel_prefiltering.yaml new file mode 100644 index 0000000..e5e08da --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T072_no_free_external_channel_prefiltering.yaml @@ -0,0 +1,23 @@ +id: T072_no_free_external_channel_prefiltering +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + rejectedA: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + accept: + value: false + rejectedB: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + accept: + value: false +event: + kind: prefilter +expectedCapabilityFailure: false +expectedExactGas: 1114 +expectedAbsentDocumentPaths: + - /contracts/checkpoint diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T073_emit_gas_only_after_validation.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T073_emit_gas_only_after_validation.yaml new file mode 100644 index 0000000..c007f4b --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T073_emit_gas_only_after_validation.yaml @@ -0,0 +1,21 @@ +id: T073_emit_gas_only_after_validation +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + emitter: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + triggeredEvents: + - value: valid-event +event: + kind: emit-gas +expectedCapabilityFailure: false +expectedExactGas: 1200 +expectedRootEventCount: 2 diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T074_consume_gas_negative_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T074_consume_gas_negative_fatal.yaml new file mode 100644 index 0000000..713e731 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T074_consume_gas_negative_fatal.yaml @@ -0,0 +1,26 @@ +id: T074_consume_gas_negative_fatal +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + gasConsumed: + value: -1 +event: + kind: negative-gas +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal +expectedFailureReasonContains: non-negative +expectedExactGas: 1309 diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T075_scope_entry_gas_uses_embedded_depth_not_pointer_depth.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T075_scope_entry_gas_uses_embedded_depth_not_pointer_depth.yaml new file mode 100644 index 0000000..339f157 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T075_scope_entry_gas_uses_embedded_depth_not_pointer_depth.yaml @@ -0,0 +1,35 @@ +id: T075_scope_entry_gas_uses_embedded_depth_not_pointer_depth +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + deep: + nested: + child: + contracts: + c: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + h: + channel: c + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /deep/nested/child/ran + val: + value: true + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /deep/nested/child +event: + kind: embedded-depth +expectedCapabilityFailure: false +expectedExactGas: 2376 +expectedDocumentPaths: + /deep/nested/child/ran: + value: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T076_lazy_checkpoint_creation_costs_zero_gas.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T076_lazy_checkpoint_creation_costs_zero_gas.yaml new file mode 100644 index 0000000..c5d0029 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T076_lazy_checkpoint_creation_costs_zero_gas.yaml @@ -0,0 +1,19 @@ +id: T076_lazy_checkpoint_creation_costs_zero_gas +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" +event: + kind: checkpoint-create-cost +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/checkpoint +expectedCheckpointLastEvents: + channel: + kind: checkpoint-create-cost +expectedExactGas: 1129 diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T077_checkpoint_update_costs_configured_amount.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T077_checkpoint_update_costs_configured_amount.yaml new file mode 100644 index 0000000..e41d857 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T077_checkpoint_update_costs_configured_amount.yaml @@ -0,0 +1,29 @@ +id: T077_checkpoint_update_costs_configured_amount +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /handled + val: + value: true +event: + kind: checkpoint-update-cost +expectedCapabilityFailure: false +expectedExactGas: 1202 +expectedDocumentPaths: + /handled: + value: true +expectedCheckpointLastEvents: + channel: + kind: checkpoint-update-cost diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T078_direct_write_termination_costs_configured_amount.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T078_direct_write_termination_costs_configured_amount.yaml new file mode 100644 index 0000000..3163842 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T078_direct_write_termination_costs_configured_amount.yaml @@ -0,0 +1,22 @@ +id: T078_direct_write_termination_costs_configured_amount +category: Gas +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + terminator: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: graceful + terminationReason: gas-term +event: + kind: termination-gas +expectedCapabilityFailure: false +expectedExactGas: 1209 +expectedDocumentPathExists: + - /contracts/terminated diff --git a/src/test/resources/blue-contracts-1.0/fixtures/generalization/T079_generalization_nearest_valid_child_type.yaml b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T079_generalization_nearest_valid_child_type.yaml new file mode 100644 index 0000000..32d455b --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T079_generalization_nearest_valid_child_type.yaml @@ -0,0 +1,62 @@ +id: T079_generalization_nearest_valid_child_type +category: Generalization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 + - blue-contracts-fixture-type-graph-v1 +typeGraph: + Price: + blueId: 4AQJxurDsYFiwbuh6TshyzZ1XJgyRDQSoFHeCu2Kcw8p + PriceInEUR: + blueId: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd + parent: Price + fixedValues: + /currency: EUR +initialDocument: + price: + type: + blueId: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd + amount: 150 + currency: EUR + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + patcher: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + kind: change-currency +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: change-currency + accepted: true + payload: + kind: change-currency + handlers: + - contract: /contracts/patcher + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /price/currency + val: USD +expectedStatus: success +expectedDocumentPaths: + /price/currency: + value: USD + /price/type: + blueId: 4AQJxurDsYFiwbuh6TshyzZ1XJgyRDQSoFHeCu2Kcw8p +expectedDocumentUpdateOrder: + - /price/currency + - /price/type +expectedAbsentDocumentPathValues: + - path: /price/type/blueId + value: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd diff --git a/src/test/resources/blue-contracts-1.0/fixtures/generalization/T080_generalization_propagates_to_parent_type.yaml b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T080_generalization_propagates_to_parent_type.yaml new file mode 100644 index 0000000..f5b9cba --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T080_generalization_propagates_to_parent_type.yaml @@ -0,0 +1,74 @@ +id: T080_generalization_propagates_to_parent_type +category: Generalization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 + - blue-contracts-fixture-type-graph-v1 +typeGraph: + Price: + blueId: 4AQJxurDsYFiwbuh6TshyzZ1XJgyRDQSoFHeCu2Kcw8p + PriceInEUR: + blueId: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd + parent: Price + fixedValues: + /currency: EUR + GlobalProduct: + blueId: 4kaXvNM9BLxbTQrJYPByzTwmD7z6Lsrm7jYfstjsHFhu + fields: + /price: + type: Price + EuropeanProduct: + blueId: FS9ZLvKJaqp5hzs5XpmCyMvm8zTtYvfsVWVUApZ7fpn7 + parent: GlobalProduct + fields: + /price: + type: PriceInEUR +initialDocument: + type: + blueId: FS9ZLvKJaqp5hzs5XpmCyMvm8zTtYvfsVWVUApZ7fpn7 + price: + type: + blueId: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd + currency: EUR + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + patcher: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + kind: change-currency +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: change-currency + accepted: true + payload: + kind: change-currency + handlers: + - contract: /contracts/patcher + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /price/currency + val: USD +expectedStatus: success +expectedDocumentPaths: + /price/currency: + value: USD + /price/type: + blueId: 4AQJxurDsYFiwbuh6TshyzZ1XJgyRDQSoFHeCu2Kcw8p + /type: + blueId: 4kaXvNM9BLxbTQrJYPByzTwmD7z6Lsrm7jYfstjsHFhu +expectedDocumentUpdateOrder: + - /price/currency + - /price/type + - /type diff --git a/src/test/resources/blue-contracts-1.0/fixtures/generalization/T081_generalization_policy_floor_rejects_overgeneralization.yaml b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T081_generalization_policy_floor_rejects_overgeneralization.yaml new file mode 100644 index 0000000..f97465a --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T081_generalization_policy_floor_rejects_overgeneralization.yaml @@ -0,0 +1,66 @@ +id: T081_generalization_policy_floor_rejects_overgeneralization +category: Generalization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 + - blue-contracts-fixture-type-graph-v1 +typeGraph: + PayNote: + blueId: GuYxgHX6eCjvgoXnvrJKFmpPbVGJ3GSBLfDdhArqpspe + BankTransferPayNote: + blueId: GeDgB3LzDSRhNJH6PD5wwZAVtVDfCZhqXxWzEY3ctWHF + parent: PayNote + fixedValues: + /paymentKind: bank-transfer + EUBankTransferPayNote: + blueId: 95ykwi5Gh48Pp5GJzEAhkjgjnH8fFs8jWiXi3ccWDTWq + parent: BankTransferPayNote + fixedValues: + /rail: SEPA +initialDocument: + type: + blueId: 95ykwi5Gh48Pp5GJzEAhkjgjnH8fFs8jWiXi3ccWDTWq + paymentKind: bank-transfer + rail: SEPA + amount: 10 + contracts: + generalization: + type: + blueId: Fbenow6tanFHkWzKiDD8fGxminQswQ1FecMRakaCx2WX + rules: + - path: / + mode: nearest-valid + mustRemainSubtypeOf: + blueId: GeDgB3LzDSRhNJH6PD5wwZAVtVDfCZhqXxWzEY3ctWHF + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + patcher: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + kind: change-payment-kind +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: change-payment-kind + accepted: true + payload: + kind: change-payment-kind + handlers: + - contract: /contracts/patcher + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /paymentKind + val: card +expectedStatus: runtime-fatal +expectedErrorCategories: [GeneralizationRejected, GeneralizationNoValidType] +expectedNoDocumentMutation: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/generalization/T082_generalization_reject_mode_fatal_no_commit.yaml b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T082_generalization_reject_mode_fatal_no_commit.yaml new file mode 100644 index 0000000..f3ad135 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T082_generalization_reject_mode_fatal_no_commit.yaml @@ -0,0 +1,58 @@ +id: T082_generalization_reject_mode_fatal_no_commit +category: Generalization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 + - blue-contracts-fixture-type-graph-v1 +typeGraph: + Price: + blueId: 4AQJxurDsYFiwbuh6TshyzZ1XJgyRDQSoFHeCu2Kcw8p + PriceInEUR: + blueId: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd + parent: Price + fixedValues: + /currency: EUR +initialDocument: + price: + type: + blueId: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd + currency: EUR + contracts: + generalization: + type: + blueId: Fbenow6tanFHkWzKiDD8fGxminQswQ1FecMRakaCx2WX + rules: + - path: /price + mode: reject + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + patcher: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + kind: change-currency +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: change-currency + accepted: true + payload: + kind: change-currency + handlers: + - contract: /contracts/patcher + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /price/currency + val: USD +expectedStatus: runtime-fatal +expectedErrorCategory: GeneralizationRejected +expectedNoDocumentMutation: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/generalization/T083_generalization_type_writes_emit_document_updates.yaml b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T083_generalization_type_writes_emit_document_updates.yaml new file mode 100644 index 0000000..ec14fb1 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T083_generalization_type_writes_emit_document_updates.yaml @@ -0,0 +1,74 @@ +id: T083_generalization_type_writes_emit_document_updates +category: Generalization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 + - blue-contracts-fixture-type-graph-v1 +typeGraph: + Price: + blueId: 4AQJxurDsYFiwbuh6TshyzZ1XJgyRDQSoFHeCu2Kcw8p + PriceInEUR: + blueId: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd + parent: Price + fixedValues: + /currency: EUR + GlobalProduct: + blueId: 4kaXvNM9BLxbTQrJYPByzTwmD7z6Lsrm7jYfstjsHFhu + EuropeanProduct: + blueId: FS9ZLvKJaqp5hzs5XpmCyMvm8zTtYvfsVWVUApZ7fpn7 + parent: GlobalProduct +initialDocument: + type: + blueId: FS9ZLvKJaqp5hzs5XpmCyMvm8zTtYvfsVWVUApZ7fpn7 + price: + type: + blueId: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd + currency: EUR + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + patcher: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming + watchCurrency: + type: + blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o + path: /price/currency + watchPriceType: + type: + blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o + path: /price/type + watchRootType: + type: + blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o + path: /type +event: + kind: change-currency +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: change-currency + accepted: true + payload: + kind: change-currency + handlers: + - contract: /contracts/patcher + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /price/currency + val: USD +expectedStatus: success +expectedDocumentUpdateOrder: + - /price/currency + - /price/type + - /type +expectedTriggeredFifoAfterDocumentUpdates: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/generalization/T084_embedded_child_patch_cannot_generalize_parent_scope.yaml b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T084_embedded_child_patch_cannot_generalize_parent_scope.yaml new file mode 100644 index 0000000..43d4dcf --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/generalization/T084_embedded_child_patch_cannot_generalize_parent_scope.yaml @@ -0,0 +1,66 @@ +id: T084_embedded_child_patch_cannot_generalize_parent_scope +category: Generalization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 + - blue-contracts-fixture-type-graph-v1 +typeGraph: + Price: + blueId: 4AQJxurDsYFiwbuh6TshyzZ1XJgyRDQSoFHeCu2Kcw8p + PriceInEUR: + blueId: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd + parent: Price + fixedValues: + /currency: EUR + GlobalProduct: + blueId: 4kaXvNM9BLxbTQrJYPByzTwmD7z6Lsrm7jYfstjsHFhu + EuropeanProduct: + blueId: FS9ZLvKJaqp5hzs5XpmCyMvm8zTtYvfsVWVUApZ7fpn7 + parent: GlobalProduct +initialDocument: + type: + blueId: FS9ZLvKJaqp5hzs5XpmCyMvm8zTtYvfsVWVUApZ7fpn7 + contracts: + embedded: + type: + blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q + paths: + - /child + child: + price: + type: + blueId: GKR2zJxmhCkDjabCgsVVfv4nsFYYDmk8XaGGqdNrtZKd + currency: EUR + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + patcher: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + kind: child-change-currency +mockRuntime: + channels: + - contract: /child/contracts/incoming + calls: + - when: + event: + kind: child-change-currency + accepted: true + payload: + kind: child-change-currency + handlers: + - contract: /child/contracts/patcher + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /child/price/currency + val: USD +expectedStatus: runtime-fatal +expectedErrorCategories: [BoundaryViolation, GeneralizationRejected, TypeSoundnessViolation] +expectedNoDocumentMutation: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/initialization/T002_process_uninitialized_document_initializes_scope.yaml b/src/test/resources/blue-contracts-1.0/fixtures/initialization/T002_process_uninitialized_document_initializes_scope.yaml new file mode 100644 index 0000000..4994f9f --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/initialization/T002_process_uninitialized_document_initializes_scope.yaml @@ -0,0 +1,16 @@ +id: T002_process_uninitialized_document_initializes_scope +category: Initialization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + name: Uninitialized +event: + value: external +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedRootEventCount: 1 +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" +expectedDocumentPathExists: + - /contracts/initialized diff --git a/src/test/resources/blue-contracts-1.0/fixtures/initialization/T021_initialization_lifecycle_before_marker_write.yaml b/src/test/resources/blue-contracts-1.0/fixtures/initialization/T021_initialization_lifecycle_before_marker_write.yaml new file mode 100644 index 0000000..0375115 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/initialization/T021_initialization_lifecycle_before_marker_write.yaml @@ -0,0 +1,27 @@ +id: T021_initialization_lifecycle_before_marker_write +category: Initialization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + life: + type: + blueId: "2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ" + handler: + channel: life + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /lifecycleSawInitialized + val: + value: false +event: + kind: init-order +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/initialized +expectedDocumentPaths: + /lifecycleSawInitialized: + value: false diff --git a/src/test/resources/blue-contracts-1.0/fixtures/initialization/T022_initialization_marker_patch_triggers_document_update.yaml b/src/test/resources/blue-contracts-1.0/fixtures/initialization/T022_initialization_marker_patch_triggers_document_update.yaml new file mode 100644 index 0000000..8e27247 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/initialization/T022_initialization_marker_patch_triggers_document_update.yaml @@ -0,0 +1,28 @@ +id: T022_initialization_marker_patch_triggers_document_update +category: Initialization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + docUpdate: + type: + blueId: "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o" + path: /contracts/initialized + handler: + channel: docUpdate + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /initMarkerUpdateObserved + val: + value: true +event: + kind: init-doc-update +expectedCapabilityFailure: false +expectedDocumentPaths: + /initMarkerUpdateObserved: + value: true +expectedDocumentPathExists: + - /contracts/initialized diff --git a/src/test/resources/blue-contracts-1.0/fixtures/initialization/T023_initialization_does_not_create_checkpoint.yaml b/src/test/resources/blue-contracts-1.0/fixtures/initialization/T023_initialization_does_not_create_checkpoint.yaml new file mode 100644 index 0000000..c7799cc --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/initialization/T023_initialization_does_not_create_checkpoint.yaml @@ -0,0 +1,15 @@ +id: T023_initialization_does_not_create_checkpoint +category: Initialization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + note: + value: init-only +event: + kind: init-no-checkpoint +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/initialized +expectedAbsentDocumentPaths: + - /contracts/checkpoint diff --git a/src/test/resources/blue-contracts-1.0/fixtures/initialization/T024_lifecycle_emitted_triggered_event_drains_only_in_phase5.yaml b/src/test/resources/blue-contracts-1.0/fixtures/initialization/T024_lifecycle_emitted_triggered_event_drains_only_in_phase5.yaml new file mode 100644 index 0000000..20149e3 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/initialization/T024_lifecycle_emitted_triggered_event_drains_only_in_phase5.yaml @@ -0,0 +1,34 @@ +id: T024_lifecycle_emitted_triggered_event_drains_only_in_phase5 +category: Initialization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + life: + type: + blueId: "2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ" + lifeHandler: + channel: life + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + triggeredEvents: + - value: from-lifecycle + triggered: + type: + blueId: "5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ" + triggeredHandler: + channel: triggered + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /lifecycleTriggeredDrained + val: + value: true +event: + kind: lifecycle-fifo +expectedCapabilityFailure: false +expectedDocumentPaths: + /lifecycleTriggeredDrained: + value: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/initialization/initializationContentBlueIdComputedBeforeInitializedMarker.yaml b/src/test/resources/blue-contracts-1.0/fixtures/initialization/initializationContentBlueIdComputedBeforeInitializedMarker.yaml new file mode 100644 index 0000000..7f3bd6c --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/initialization/initializationContentBlueIdComputedBeforeInitializedMarker.yaml @@ -0,0 +1,25 @@ +id: initializationContentBlueIdComputedBeforeInitializedMarker +category: Initialization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + embedded: + type: + blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q + paths: + - /child + child: + value: before-init +event: + kind: initialize +expectedStatus: success +expectedInitializationContentBlueIdInput: + scope: / + timing: after-phase-1-before-initialized-marker + excludesPath: /contracts/initialized +expectedDocumentPathExists: + - /contracts/initialized/documentId +assertions: + - The initialized marker is absent from the Content BlueId input used to produce documentId. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/manifest.yaml b/src/test/resources/blue-contracts-1.0/fixtures/manifest.yaml new file mode 100644 index 0000000..e8cd1d9 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/manifest.yaml @@ -0,0 +1,433 @@ +specVersion: "1.0" +fixturePackageIdentity: "sha256:2f197ca3bbdc41b75e772777cc48e51019754347e1bee26b5f3209b71d9bd9ca" +requiredFixtureSet: exact +fixtureCount: 133 +fixturePackageIdentityAlgorithm: + digest: sha256 + lineEndings: LF + manifestIdentityLine: "replace with 'fixturePackageIdentity: \"\"'" + order: manifest fixtures order + steps: + - "append UTF-8 bytes of \"manifest.yaml\\n\"" + - "append normalized manifest bytes with blank fixturePackageIdentity" + - "for each fixture in manifest order, append \"\\n--- \\n\"" + - "append normalized fixture file bytes" + - "encode lowercase hexadecimal prefixed by \"sha256:\"" +categories: + Checkpoint: 7 + ContractKey: 4 + DispatchSnapshot: 9 + DocumentUpdate: 3 + Effects: 3 + Embedded: 11 + Events: 1 + Gas: 11 + Generalization: 6 + Initialization: 6 + MustUnderstand: 8 + Normalization: 3 + Patching: 19 + Pointer: 4 + Registry: 24 + Termination: 12 + TriggeredFIFO: 2 +fixtures: + - id: T001_registry_runtime_type_blueids + category: Registry + path: registry/T001_registry_runtime_type_blueids.yaml + - id: T012_checkpoint_lazy_create_and_update + category: Checkpoint + path: checkpoint/T012_checkpoint_lazy_create_and_update.yaml + - id: T013_stale_event_no_checkpoint_update + category: Checkpoint + path: checkpoint/T013_stale_event_no_checkpoint_update.yaml + - id: checkpointDefaultUsesContentBlueId + category: Checkpoint + path: checkpoint/checkpointDefaultUsesContentBlueId.yaml + - id: checkpointEventIdDoesNotOverrideDefaultIdentity + category: Checkpoint + path: checkpoint/checkpointEventIdDoesNotOverrideDefaultIdentity.yaml + - id: checkpointNodeBlueIdModeRequiresBlueIdInput + category: Checkpoint + path: checkpoint/checkpointNodeBlueIdModeRequiresBlueIdInput.yaml + - id: checkpointStoresPreprocessedSubject + category: Checkpoint + path: checkpoint/checkpointStoresPreprocessedSubject.yaml + - id: checkpointStoresRawChannelKeyWithSlashAndUsesEscapedPointerForWrite + category: Checkpoint + path: checkpoint/checkpointStoresRawChannelKeyWithSlashAndUsesEscapedPointerForWrite.yaml + - id: contractKeyEmptyRejected + category: ContractKey + path: contract-key/contractKeyEmptyRejected.yaml + - id: contractKeyReservedTypeRejected + category: ContractKey + path: contract-key/contractKeyReservedTypeRejected.yaml + - id: contractKeyReservedValueRejected + category: ContractKey + path: contract-key/contractKeyReservedValueRejected.yaml + - id: contractKeySlashStoredRawEscapedOnlyInPointer + category: ContractKey + path: contract-key/contractKeySlashStoredRawEscapedOnlyInPointer.yaml + - id: T018_dispatch_snapshot_stable_after_handler_mutation + category: DispatchSnapshot + path: dispatch-snapshot/T018_dispatch_snapshot_stable_after_handler_mutation.yaml + - id: T064_removing_later_handler_does_not_affect_current_delivery + category: DispatchSnapshot + path: dispatch-snapshot/T064_removing_later_handler_does_not_affect_current_delivery.yaml + - id: T065_replacing_later_handler_does_not_affect_current_delivery_content + category: DispatchSnapshot + path: dispatch-snapshot/T065_replacing_later_handler_does_not_affect_current_delivery_content.yaml + - id: T066_adding_handler_during_delivery_does_not_run_immediately + category: DispatchSnapshot + path: dispatch-snapshot/T066_adding_handler_during_delivery_does_not_run_immediately.yaml + - id: T067_removing_later_external_channel_does_not_remove_current_phase3_candidate + category: DispatchSnapshot + path: dispatch-snapshot/T067_removing_later_external_channel_does_not_remove_current_phase3_candidate.yaml + - id: T068_document_update_delivery_snapshots_handlers_before_first_handler + category: DispatchSnapshot + path: dispatch-snapshot/T068_document_update_delivery_snapshots_handlers_before_first_handler.yaml + - id: embeddedNodeChannelAddedDuringBridgeDoesNotAffectAlreadySnapshottedEmission + category: DispatchSnapshot + path: dispatch-snapshot/embeddedNodeChannelAddedDuringBridgeDoesNotAffectAlreadySnapshottedEmission.yaml + - id: embeddedNodeChannelRemovedDuringBridgeDoesNotRemoveCurrentEmissionDelivery + category: DispatchSnapshot + path: dispatch-snapshot/embeddedNodeChannelRemovedDuringBridgeDoesNotRemoveCurrentEmissionDelivery.yaml + - id: triggeredChannelAddedDuringDrainDoesNotAffectCurrentEventButCanAffectLaterEvent + category: DispatchSnapshot + path: dispatch-snapshot/triggeredChannelAddedDuringDrainDoesNotAffectCurrentEventButCanAffectLaterEvent.yaml + - id: T007_document_update_channel_added_by_patch_receives_same_update + category: DocumentUpdate + path: document-update/T007_document_update_channel_added_by_patch_receives_same_update.yaml + - id: T008_document_update_channel_removed_by_patch_does_not_receive_same_update + category: DocumentUpdate + path: document-update/T008_document_update_channel_removed_by_patch_does_not_receive_same_update.yaml + - id: documentUpdateNullSentinelsAreRuntimePayloadOnly + category: DocumentUpdate + path: document-update/documentUpdateNullSentinelsAreRuntimePayloadOnly.yaml + - id: handlerCallingEmitThenPatchStillAppliesPatchBeforeEmission + category: Effects + path: effects/handlerCallingEmitThenPatchStillAppliesPatchBeforeEmission.yaml + - id: handlerCallingTerminateThenPatchStillAppliesPatchBeforeTermination + category: Effects + path: effects/handlerCallingTerminateThenPatchStillAppliesPatchBeforeTermination.yaml + - id: handlerThrowsAfterBufferingPatchDiscardsOwnBuffer + category: Effects + path: effects/handlerThrowsAfterBufferingPatchDiscardsOwnBuffer.yaml + - id: T010_embedded_bridge_before_parent_fifo + category: Embedded + path: embedded/T010_embedded_bridge_before_parent_fifo.yaml + - id: T011_embedded_path_slash_fatal + category: Embedded + path: embedded/T011_embedded_path_slash_fatal.yaml + - id: T029_duplicate_embedded_paths_fatal + category: Embedded + path: embedded/T029_duplicate_embedded_paths_fatal.yaml + - id: T030_malformed_embedded_path_fatal + category: Embedded + path: embedded/T030_malformed_embedded_path_fatal.yaml + - id: T031_missing_embedded_path_skipped_and_marked_processed + category: Embedded + path: embedded/T031_missing_embedded_path_skipped_and_marked_processed.yaml + - id: T032_embedded_path_non_object_fatal + category: Embedded + path: embedded/T032_embedded_path_non_object_fatal.yaml + - id: T033_embedded_rereads_paths_after_each_child + category: Embedded + path: embedded/T033_embedded_rereads_paths_after_each_child.yaml + - id: T034_embedded_no_resurrection_after_remove_and_readd + category: Embedded + path: embedded/T034_embedded_no_resurrection_after_remove_and_readd.yaml + - id: T035_bridge_uses_processed_paths_insertion_order + category: Embedded + path: embedded/T035_bridge_uses_processed_paths_insertion_order.yaml + - id: T036_bridge_charges_only_when_delivered_to_matching_channel + category: Embedded + path: embedded/T036_bridge_charges_only_when_delivered_to_matching_channel.yaml + - id: T037_embedded_node_handler_runs_in_parent_scope_and_cannot_patch_inside_child + category: Embedded + path: embedded/T037_embedded_node_handler_runs_in_parent_scope_and_cannot_patch_inside_child.yaml + - id: processorEmittedEventsIncludeRuntimeTypeBlueIds + category: Events + path: events/processorEmittedEventsIncludeRuntimeTypeBlueIds.yaml + - id: T019_gas_boundary_per_patch + category: Gas + path: gas/T019_gas_boundary_per_patch.yaml + - id: T069_boundary_gas_per_patch_exact + category: Gas + path: gas/T069_boundary_gas_per_patch_exact.yaml + - id: T070_cascade_gas_only_for_participating_scopes_exact + category: Gas + path: gas/T070_cascade_gas_only_for_participating_scopes_exact.yaml + - id: T071_external_channel_attempt_gas_for_rejected_candidates_exact + category: Gas + path: gas/T071_external_channel_attempt_gas_for_rejected_candidates_exact.yaml + - id: T072_no_free_external_channel_prefiltering + category: Gas + path: gas/T072_no_free_external_channel_prefiltering.yaml + - id: T073_emit_gas_only_after_validation + category: Gas + path: gas/T073_emit_gas_only_after_validation.yaml + - id: T074_consume_gas_negative_fatal + category: Gas + path: gas/T074_consume_gas_negative_fatal.yaml + - id: T075_scope_entry_gas_uses_embedded_depth_not_pointer_depth + category: Gas + path: gas/T075_scope_entry_gas_uses_embedded_depth_not_pointer_depth.yaml + - id: T076_lazy_checkpoint_creation_costs_zero_gas + category: Gas + path: gas/T076_lazy_checkpoint_creation_costs_zero_gas.yaml + - id: T077_checkpoint_update_costs_configured_amount + category: Gas + path: gas/T077_checkpoint_update_costs_configured_amount.yaml + - id: T078_direct_write_termination_costs_configured_amount + category: Gas + path: gas/T078_direct_write_termination_costs_configured_amount.yaml + - id: T079_generalization_nearest_valid_child_type + category: Generalization + path: generalization/T079_generalization_nearest_valid_child_type.yaml + - id: T080_generalization_propagates_to_parent_type + category: Generalization + path: generalization/T080_generalization_propagates_to_parent_type.yaml + - id: T081_generalization_policy_floor_rejects_overgeneralization + category: Generalization + path: generalization/T081_generalization_policy_floor_rejects_overgeneralization.yaml + - id: T082_generalization_reject_mode_fatal_no_commit + category: Generalization + path: generalization/T082_generalization_reject_mode_fatal_no_commit.yaml + - id: T083_generalization_type_writes_emit_document_updates + category: Generalization + path: generalization/T083_generalization_type_writes_emit_document_updates.yaml + - id: T084_embedded_child_patch_cannot_generalize_parent_scope + category: Generalization + path: generalization/T084_embedded_child_patch_cannot_generalize_parent_scope.yaml + - id: T002_process_uninitialized_document_initializes_scope + category: Initialization + path: initialization/T002_process_uninitialized_document_initializes_scope.yaml + - id: T021_initialization_lifecycle_before_marker_write + category: Initialization + path: initialization/T021_initialization_lifecycle_before_marker_write.yaml + - id: T022_initialization_marker_patch_triggers_document_update + category: Initialization + path: initialization/T022_initialization_marker_patch_triggers_document_update.yaml + - id: T023_initialization_does_not_create_checkpoint + category: Initialization + path: initialization/T023_initialization_does_not_create_checkpoint.yaml + - id: T024_lifecycle_emitted_triggered_event_drains_only_in_phase5 + category: Initialization + path: initialization/T024_lifecycle_emitted_triggered_event_drains_only_in_phase5.yaml + - id: initializationContentBlueIdComputedBeforeInitializedMarker + category: Initialization + path: initialization/initializationContentBlueIdComputedBeforeInitializedMarker.yaml + - id: T003_must_understand_initial_unsupported_no_mutation + category: MustUnderstand + path: must-understand/T003_must_understand_initial_unsupported_no_mutation.yaml + - id: T004_unsupported_contract_in_terminated_scope_ignored + category: MustUnderstand + path: must-understand/T004_unsupported_contract_in_terminated_scope_ignored.yaml + - id: T005_runtime_unsupported_contract_after_patch_fatal + category: MustUnderstand + path: must-understand/T005_runtime_unsupported_contract_after_patch_fatal.yaml + - id: T025_initial_closure_includes_embedded_scopes + category: MustUnderstand + path: must-understand/T025_initial_closure_includes_embedded_scopes.yaml + - id: T026_unsupported_in_terminated_scope_ignored + category: MustUnderstand + path: must-understand/T026_unsupported_in_terminated_scope_ignored.yaml + - id: T027_invalid_terminated_marker_in_initial_closure_capability_failure + category: MustUnderstand + path: must-understand/T027_invalid_terminated_marker_in_initial_closure_capability_failure.yaml + - id: T028_runtime_unsupported_after_patch_fatal + category: MustUnderstand + path: must-understand/T028_runtime_unsupported_after_patch_fatal.yaml + - id: extensionRoleUnsupportedSubjectToMustUnderstand + category: MustUnderstand + path: must-understand/extensionRoleUnsupportedSubjectToMustUnderstand.yaml + - id: emitGasBytesUseRuntimeInsertionNormalization + category: Normalization + path: normalization/emitGasBytesUseRuntimeInsertionNormalization.yaml + - id: patchGasBytesUseRuntimeInsertionNormalization + category: Normalization + path: normalization/patchGasBytesUseRuntimeInsertionNormalization.yaml + - id: runtimeNodeInsertionRejectsRootBlueDirective + category: Normalization + path: normalization/runtimeNodeInsertionRejectsRootBlueDirective.yaml + - id: T001_dynamic_embedded_paths_mutation_allowed_only_for_paths + category: Patching + path: patching/T001_dynamic_embedded_paths_mutation_allowed_only_for_paths.yaml + - id: T001b_embedded_marker_type_patch_still_fatal + category: Patching + path: patching/T001b_embedded_marker_type_patch_still_fatal.yaml + - id: T001c_embedded_marker_whole_replace_still_fatal + category: Patching + path: patching/T001c_embedded_marker_whole_replace_still_fatal.yaml + - id: T006_patch_cascade_after_each_patch + category: Patching + path: patching/T006_patch_cascade_after_each_patch.yaml + - id: T016_reserved_key_patch_fatal + category: Patching + path: patching/T016_reserved_key_patch_fatal.yaml + - id: T041_patch_root_path_rejected + category: Patching + path: patching/T041_patch_root_path_rejected.yaml + - id: T042_patch_add_missing_intermediate_objects_materializes + category: Patching + path: patching/T042_patch_add_missing_intermediate_objects_materializes.yaml + - id: T043_patch_does_not_auto_materialize_arrays + category: Patching + path: patching/T043_patch_does_not_auto_materialize_arrays.yaml + - id: T044_patch_remove_missing_object_member_fatal + category: Patching + path: patching/T044_patch_remove_missing_object_member_fatal.yaml + - id: T045_patch_replace_object_member_upserts + category: Patching + path: patching/T045_patch_replace_object_member_upserts.yaml + - id: T046_patch_array_leading_zero_index_rejected + category: Patching + path: patching/T046_patch_array_leading_zero_index_rejected.yaml + - id: T047_patch_array_dash_only_allowed_for_add + category: Patching + path: patching/T047_patch_array_dash_only_allowed_for_add.yaml + - id: T048_ab_not_inside_a_for_patch_boundaries + category: Patching + path: patching/T048_ab_not_inside_a_for_patch_boundaries.yaml + - id: T049_reserved_initialized_path_patch_fatal + category: Patching + path: patching/T049_reserved_initialized_path_patch_fatal.yaml + - id: T050_reserved_checkpoint_descendant_patch_fatal + category: Patching + path: patching/T050_reserved_checkpoint_descendant_patch_fatal.yaml + - id: T051_contracts_whole_map_patch_preserving_reserved_subtrees_allowed + category: Patching + path: patching/T051_contracts_whole_map_patch_preserving_reserved_subtrees_allowed.yaml + - id: T052_contracts_whole_map_patch_changing_reserved_subtree_fatal + category: Patching + path: patching/T052_contracts_whole_map_patch_changing_reserved_subtree_fatal.yaml + - id: T053_parent_may_replace_embedded_child_root_containing_reserved_keys + category: Patching + path: patching/T053_parent_may_replace_embedded_child_root_containing_reserved_keys.yaml + - id: T054_parent_may_not_patch_inside_embedded_child_reserved_key + category: Patching + path: patching/T054_parent_may_not_patch_inside_embedded_child_reserved_key.yaml + - id: T017_pointer_ab_not_inside_a + category: Pointer + path: pointer/T017_pointer_ab_not_inside_a.yaml + - id: T038_pointer_empty_string_rejected + category: Pointer + path: pointer/T038_pointer_empty_string_rejected.yaml + - id: T039_pointer_bad_tilde_rejected + category: Pointer + path: pointer/T039_pointer_bad_tilde_rejected.yaml + - id: T040_pointer_trailing_slash_rejected + category: Pointer + path: pointer/T040_pointer_trailing_slash_rejected.yaml + - id: changingRuntimeTypeDescriptionChangesBlueId + category: Registry + path: registry/changingRuntimeTypeDescriptionChangesBlueId.yaml + - id: runtimeRegistryBlueIdsRecomputeFromPublishedPreprocessingEnvironment + category: Registry + path: registry/runtimeRegistryBlueIdsRecomputeFromPublishedPreprocessingEnvironment.yaml + - id: runtimeRegistryChannelEventCheckpointNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryChannelEventCheckpointNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryChannelNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryChannelNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryContractExecutionResultNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryContractExecutionResultNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryContractNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryContractNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryDocumentIdFieldsUseTextBlueIdStrings + category: Registry + path: registry/runtimeRegistryDocumentIdFieldsUseTextBlueIdStrings.yaml + - id: runtimeRegistryDocumentUpdateChannelNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryDocumentUpdateChannelNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryDocumentUpdateEventNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryDocumentUpdateEventNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryEmbeddedNodeChannelNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryEmbeddedNodeChannelNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryFatalErrorEventNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryFatalErrorEventNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryHandlerNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryHandlerNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryJsonPatchEntryNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryJsonPatchEntryNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryLifecycleEventChannelNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryLifecycleEventChannelNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryMarkerNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryMarkerNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryProcessEmbeddedNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryProcessEmbeddedNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryProcessingInitializedMarkerNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryProcessingInitializedMarkerNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryProcessingInitiatedEventNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryProcessingInitiatedEventNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryProcessingTerminatedEventNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryProcessingTerminatedEventNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryProcessingTerminatedMarkerNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryProcessingTerminatedMarkerNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryTriggeredEventChannelNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryTriggeredEventChannelNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryTypeGeneralizationPolicyNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryTypeGeneralizationPolicyNodeHashesToPublishedBlueId.yaml + - id: runtimeRegistryTypeGeneralizationRuleNodeHashesToPublishedBlueId + category: Registry + path: registry/runtimeRegistryTypeGeneralizationRuleNodeHashesToPublishedBlueId.yaml + - id: T014_root_graceful_termination + category: Termination + path: termination/T014_root_graceful_termination.yaml + - id: T015_root_fatal_termination_event_order + category: Termination + path: termination/T015_root_fatal_termination_event_order.yaml + - id: T055_termination_marker_direct_write_does_not_cascade + category: Termination + path: termination/T055_termination_marker_direct_write_does_not_cascade.yaml + - id: T056_graceful_root_termination_ends_run + category: Termination + path: termination/T056_graceful_root_termination_ends_run.yaml + - id: T057_root_fatal_appends_terminated_then_fatal_event + category: Termination + path: termination/T057_root_fatal_appends_terminated_then_fatal_event.yaml + - id: T058_fatal_error_not_lifecycle_delivered + category: Termination + path: termination/T058_fatal_error_not_lifecycle_delivered.yaml + - id: T059_termination_reentrancy_no_duplicate_marker_or_event + category: Termination + path: termination/T059_termination_reentrancy_no_duplicate_marker_or_event.yaml + - id: T060_post_termination_emit_and_patch_noop + category: Termination + path: termination/T060_post_termination_emit_and_patch_noop.yaml + - id: T061_child_termination_lifecycle_bridges_to_parent + category: Termination + path: termination/T061_child_termination_lifecycle_bridges_to_parent.yaml + - id: T062_non_root_fatal_does_not_escalate_to_root_by_default + category: Termination + path: termination/T062_non_root_fatal_does_not_escalate_to_root_by_default.yaml + - id: T063_fatal_during_root_termination_lifecycle_appends_exactly_one_fatal + category: Termination + path: termination/T063_fatal_during_root_termination_lifecycle_appends_exactly_one_fatal.yaml + - id: terminationDirectWriteMalformedContractsFallbackOrTerminationError + category: Termination + path: termination/terminationDirectWriteMalformedContractsFallbackOrTerminationError.yaml + - id: T009_triggered_fifo_not_drained_during_cascade + category: TriggeredFIFO + path: triggered-fifo/T009_triggered_fifo_not_drained_during_cascade.yaml + - id: T020_emit_invalid_event_fatal_before_gas + category: TriggeredFIFO + path: triggered-fifo/T020_emit_invalid_event_fatal_before_gas.yaml diff --git a/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T003_must_understand_initial_unsupported_no_mutation.yaml b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T003_must_understand_initial_unsupported_no_mutation.yaml new file mode 100644 index 0000000..6228fef --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T003_must_understand_initial_unsupported_no_mutation.yaml @@ -0,0 +1,20 @@ +id: T003_must_understand_initial_unsupported_no_mutation +category: MustUnderstand +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + name: Unsupported + contracts: + unknown: + type: + name: Unsupported Contract +event: + value: external +expectedCapabilityFailure: true +expectedNoDocumentMutation: true +expectedTotalGas: 0 +expectedRootEventCount: 0 +expectedDocumentPathExists: + - /contracts/unknown +expectedFailureReasonContains: Unsupported diff --git a/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T004_unsupported_contract_in_terminated_scope_ignored.yaml b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T004_unsupported_contract_in_terminated_scope_ignored.yaml new file mode 100644 index 0000000..d50e632 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T004_unsupported_contract_in_terminated_scope_ignored.yaml @@ -0,0 +1,34 @@ +id: T004_unsupported_contract_in_terminated_scope_ignored +category: MustUnderstand +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + terminated: + type: + blueId: "GBDBthfshBFr4GQKUU1fmy4GnPL7q2y3as4deUWpuBtu" + cause: graceful + unsupported: + type: + name: Unsupported Contract + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child +event: + value: external +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" +expectedDocumentPathExists: + - /child/contracts/terminated + - /child/contracts/unsupported + - /contracts/initialized +expectedAbsentDocumentPaths: + - /child/contracts/initialized + - /child/contracts/checkpoint diff --git a/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T005_runtime_unsupported_contract_after_patch_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T005_runtime_unsupported_contract_after_patch_fatal.yaml new file mode 100644 index 0000000..d41c5b4 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T005_runtime_unsupported_contract_after_patch_fatal.yaml @@ -0,0 +1,30 @@ +id: T005_runtime_unsupported_contract_after_patch_fatal +category: MustUnderstand +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + addUnsupported: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: add + path: /contracts/runtimeUnsupported + val: + type: + name: Unsupported Contract +event: + kind: runtime +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPathExists: + - /contracts/runtimeUnsupported + - /contracts/terminated +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal diff --git a/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T025_initial_closure_includes_embedded_scopes.yaml b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T025_initial_closure_includes_embedded_scopes.yaml new file mode 100644 index 0000000..242caed --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T025_initial_closure_includes_embedded_scopes.yaml @@ -0,0 +1,24 @@ +id: T025_initial_closure_includes_embedded_scopes +category: MustUnderstand +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + unsupported: + type: + name: UnsupportedContract + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child +event: + kind: closure +expectedCapabilityFailure: true +expectedNoDocumentMutation: true +expectedTotalGas: 0 +expectedRootEventCount: 0 +expectedFailureReasonContains: Unsupported diff --git a/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T026_unsupported_in_terminated_scope_ignored.yaml b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T026_unsupported_in_terminated_scope_ignored.yaml new file mode 100644 index 0000000..4a2173b --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T026_unsupported_in_terminated_scope_ignored.yaml @@ -0,0 +1,43 @@ +id: T026_unsupported_in_terminated_scope_ignored +category: MustUnderstand +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + terminated: + type: + blueId: "GBDBthfshBFr4GQKUU1fmy4GnPL7q2y3as4deUWpuBtu" + cause: + value: graceful + unsupported: + type: + name: UnsupportedContract + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /parentProcessed + val: + value: true +event: + kind: terminated-child +expectedCapabilityFailure: false +expectedDocumentPaths: + /parentProcessed: + value: true +expectedAbsentDocumentPaths: + - /child/contracts/initialized + - /child/contracts/checkpoint diff --git a/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T027_invalid_terminated_marker_in_initial_closure_capability_failure.yaml b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T027_invalid_terminated_marker_in_initial_closure_capability_failure.yaml new file mode 100644 index 0000000..0d20cfd --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T027_invalid_terminated_marker_in_initial_closure_capability_failure.yaml @@ -0,0 +1,24 @@ +id: T027_invalid_terminated_marker_in_initial_closure_capability_failure +category: MustUnderstand +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + terminated: + type: + name: WrongTerminatedMarker + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child +event: + kind: invalid-terminated +expectedCapabilityFailure: true +expectedNoDocumentMutation: true +expectedTotalGas: 0 +expectedRootEventCount: 0 +expectedFailureReasonContains: terminated diff --git a/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T028_runtime_unsupported_after_patch_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T028_runtime_unsupported_after_patch_fatal.yaml new file mode 100644 index 0000000..3f9b527 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/T028_runtime_unsupported_after_patch_fatal.yaml @@ -0,0 +1,31 @@ +id: T028_runtime_unsupported_after_patch_fatal +category: MustUnderstand +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + patcher: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /contracts/unsupported + val: + type: + name: UnsupportedRuntimeContract +event: + kind: runtime-unsupported +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPathExists: + - /contracts/terminated + - /contracts/unsupported +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal +expectedFailureReasonContains: Unsupported diff --git a/src/test/resources/blue-contracts-1.0/fixtures/must-understand/extensionRoleUnsupportedSubjectToMustUnderstand.yaml b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/extensionRoleUnsupportedSubjectToMustUnderstand.yaml new file mode 100644 index 0000000..5ef3cba --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/must-understand/extensionRoleUnsupportedSubjectToMustUnderstand.yaml @@ -0,0 +1,19 @@ +id: extensionRoleUnsupportedSubjectToMustUnderstand +category: MustUnderstand +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + extension: + type: + blueId: 6WrVQoSpKHUUg5HPrwjkVV6pxe4sdkyGnakMs8ayEGeF + extensionRoleType: + blueId: 5mJpMfGEFHBPr5sWN9qNSi7JPNhDuwmSrXnLQSz9T9r8 +event: + kind: must-understand +expectedStatus: capability-failure +expectedErrorCategory: UnsupportedContract +expectedNoDocumentMutation: true +assertions: + - A subtype of Contract that is not Channel, Handler, or Marker is unsupported unless the processor declares exact support. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/normalization/emitGasBytesUseRuntimeInsertionNormalization.yaml b/src/test/resources/blue-contracts-1.0/fixtures/normalization/emitGasBytesUseRuntimeInsertionNormalization.yaml new file mode 100644 index 0000000..0a8ca2f --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/normalization/emitGasBytesUseRuntimeInsertionNormalization.yaml @@ -0,0 +1,46 @@ +id: emitGasBytesUseRuntimeInsertionNormalization +category: Normalization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + emitter: + type: + blueId: 3rHWt14WhTvmBBQ6Cr1Mb263KuxSdwqvb2jD7oPbkNL3 + channel: incoming +event: + kind: emit-bare-scalar +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: emit-bare-scalar + accepted: true + payload: + kind: emit-bare-scalar + handlers: + - contract: /contracts/emitter + calls: + - when: + channelKey: incoming + result: + triggeredEvents: + - emitted-scalar +expectedStatus: success +expectedRootEventSuffix: + - value: emitted-scalar +expectedRuntimeInsertionNormalizedValues: + - eventIndexFromEnd: 0 + selectedDocumentForm: + value: emitted-scalar + type: + blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC +expectedGasByteView: + emittedEventIndex: 0 + representation: selected-document-form-after-runtime-insertion-normalization diff --git a/src/test/resources/blue-contracts-1.0/fixtures/normalization/patchGasBytesUseRuntimeInsertionNormalization.yaml b/src/test/resources/blue-contracts-1.0/fixtures/normalization/patchGasBytesUseRuntimeInsertionNormalization.yaml new file mode 100644 index 0000000..9c064a0 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/normalization/patchGasBytesUseRuntimeInsertionNormalization.yaml @@ -0,0 +1,49 @@ +id: patchGasBytesUseRuntimeInsertionNormalization +category: Normalization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + patcher: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + kind: patch-bare-scalar +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: patch-bare-scalar + accepted: true + payload: + kind: patch-bare-scalar + handlers: + - contract: /contracts/patcher + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /normalizedScalar + val: hello +expectedStatus: success +expectedDocumentPaths: + /normalizedScalar: + value: hello +expectedRuntimeInsertionNormalizedValues: + - path: /normalizedScalar + selectedDocumentForm: + value: hello + type: + blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC +expectedGasByteView: + patchValuePath: /normalizedScalar + representation: selected-document-form-after-runtime-insertion-normalization diff --git a/src/test/resources/blue-contracts-1.0/fixtures/normalization/runtimeNodeInsertionRejectsRootBlueDirective.yaml b/src/test/resources/blue-contracts-1.0/fixtures/normalization/runtimeNodeInsertionRejectsRootBlueDirective.yaml new file mode 100644 index 0000000..9cda5ab --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/normalization/runtimeNodeInsertionRejectsRootBlueDirective.yaml @@ -0,0 +1,46 @@ +id: runtimeNodeInsertionRejectsRootBlueDirective +category: Normalization +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + patcher: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + kind: patch-root-blue-directive +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: patch-root-blue-directive + accepted: true + payload: + kind: patch-root-blue-directive + handlers: + - contract: /contracts/patcher + calls: + - when: + channelKey: incoming + result: + patches: + - op: replace + path: /bad + val: + blue: + type: Text + value: should-not-insert +expectedStatus: runtime-fatal +expectedErrorCategory: InvalidPatchValue +expectedAbsentDocumentPaths: + - /bad +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T001_dynamic_embedded_paths_mutation_allowed_only_for_paths.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T001_dynamic_embedded_paths_mutation_allowed_only_for_paths.yaml new file mode 100644 index 0000000..5a2a4cb --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T001_dynamic_embedded_paths_mutation_allowed_only_for_paths.yaml @@ -0,0 +1,151 @@ +id: T001_dynamic_embedded_paths_mutation_allowed_only_for_paths +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + embedded: + type: + blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q + paths: + - /a + - /b + watchAProcessed: + type: + blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o + path: /a/processed + rewriteEmbeddedPaths: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: watchAProcessed + a: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + markA: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming + b: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + markB: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming + c: + contracts: + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + markC: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming +event: + kind: embedded-reread +mockRuntime: + channels: + - contract: /a/contracts/incoming + calls: + - when: + event: + kind: embedded-reread + accepted: true + payload: + child: /a + - contract: /b/contracts/incoming + calls: + - when: + event: + kind: embedded-reread + accepted: true + payload: + child: /b + - contract: /c/contracts/incoming + calls: + - when: + event: + kind: embedded-reread + accepted: true + payload: + child: /c + handlers: + - contract: /a/contracts/markA + calls: + - when: + channelKey: incoming + payload: + child: /a + result: + patches: + - op: replace + path: /a/processed + val: true + - contract: /contracts/rewriteEmbeddedPaths + calls: + - when: + channelKey: watchAProcessed + payload: + path: /a/processed + result: + patches: + - op: replace + path: /contracts/embedded/paths + val: + items: + - /a + - /c + - contract: /b/contracts/markB + calls: + - when: + channelKey: incoming + payload: + child: /b + result: + patches: + - op: replace + path: /b/processed + val: true + - contract: /c/contracts/markC + calls: + - when: + channelKey: incoming + payload: + child: /c + result: + patches: + - op: replace + path: /c/processed + val: true +expectedStatus: success +expectedEmbeddedDeliveryOrder: + - /a + - /c +expectedDocumentUpdateOrder: + - /a/processed + - /contracts/embedded/paths + - /c/processed +expectedDocumentPaths: + /a/processed: + value: true + /c/processed: + value: true +expectedAbsentDocumentPaths: + - /b/processed +expectedDocumentPathValues: + - path: /contracts/embedded/paths + value: + items: + - value: /a + - value: /c +assertions: + - /a is processed during root Phase 1 before the root external phase. + - /a/processed triggers a root Document Update handler during root Phase 1. + - The root handler writes only /contracts/embedded/paths under the narrow reserved-key exception. + - Root Phase 1 re-reads embedded paths after /a, skips already-processed /a, and processes /c. + - /b is not processed after the path list changes. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T001b_embedded_marker_type_patch_still_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T001b_embedded_marker_type_patch_still_fatal.yaml new file mode 100644 index 0000000..9e659d2 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T001b_embedded_marker_type_patch_still_fatal.yaml @@ -0,0 +1,56 @@ +id: T001b_embedded_marker_type_patch_still_fatal +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + embedded: + type: + blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q + paths: + - /a + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + badPatch: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming + a: {} +event: + kind: patch-embedded-type +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: patch-embedded-type + accepted: true + payload: + kind: patch-embedded-type + handlers: + - contract: /contracts/badPatch + calls: + - when: + channelKey: incoming + payload: + kind: patch-embedded-type + result: + patches: + - op: replace + path: /contracts/embedded/type + val: + blueId: 6zqbYGDGrMv5ReuEsjyzyyjjuqVnqDZxtY7RsPXdBTNy +expectedStatus: runtime-fatal +expectedErrorCategory: ReservedKeyWrite +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal +expectedDocumentPathValues: + - path: /contracts/embedded/type + value: + blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q +assertions: + - The embedded paths exception does not permit writing contracts/embedded/type. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T001c_embedded_marker_whole_replace_still_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T001c_embedded_marker_whole_replace_still_fatal.yaml new file mode 100644 index 0000000..3c5a828 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T001c_embedded_marker_whole_replace_still_fatal.yaml @@ -0,0 +1,58 @@ +id: T001c_embedded_marker_whole_replace_still_fatal +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + embedded: + type: + blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q + paths: + - /a + incoming: + type: + blueId: C37UoAfTNUnoxkB2CdEE7BfHJwYqTNiWzQb5xuRMkBzm + badPatch: + type: + blueId: 2TwRC3EdLXk4gqwyyVWy52h5BQ5ntrkmpcrhrTxsGAs1 + channel: incoming + a: {} +event: + kind: replace-embedded-marker +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: + kind: replace-embedded-marker + accepted: true + payload: + kind: replace-embedded-marker + handlers: + - contract: /contracts/badPatch + calls: + - when: + channelKey: incoming + payload: + kind: replace-embedded-marker + result: + patches: + - op: replace + path: /contracts/embedded + val: + paths: + - /c +expectedStatus: runtime-fatal +expectedErrorCategory: ReservedKeyWrite +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal +expectedDocumentPathValues: + - path: /contracts/embedded/paths + value: + items: + - value: /a +assertions: + - The embedded paths exception does not permit replacing or removing the Process Embedded marker. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T006_patch_cascade_after_each_patch.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T006_patch_cascade_after_each_patch.yaml new file mode 100644 index 0000000..d106728 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T006_patch_cascade_after_each_patch.yaml @@ -0,0 +1,64 @@ +id: T006_patch_cascade_after_each_patch +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + docUpdate: + type: + blueId: "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o" + path: /a + afterFirst: + channel: docUpdate + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /afterFirstCascade + val: + value: true + - op: replace + path: /orderLog + val: + items: + - value: patch-1 + - value: cascade-1 + patcher: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /a + val: + value: one + - op: remove + path: /afterFirstCascade + - op: replace + path: /orderLog + val: + items: + - value: patch-1 + - value: cascade-1 + - value: patch-2 +event: + kind: patch +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPaths: + /a: + value: one +expectedDocumentPathValues: + - path: /orderLog + value: + items: + - value: patch-1 + - value: cascade-1 + - value: patch-2 +expectedAbsentDocumentPaths: + - /afterFirstCascade + - /contracts/terminated diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T016_reserved_key_patch_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T016_reserved_key_patch_fatal.yaml new file mode 100644 index 0000000..475b58a --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T016_reserved_key_patch_fatal.yaml @@ -0,0 +1,28 @@ +id: T016_reserved_key_patch_fatal +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + patcher: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /contracts/initialized + val: + value: forbidden +event: + kind: reserved +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPathExists: + - /contracts/terminated +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T041_patch_root_path_rejected.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T041_patch_root_path_rejected.yaml new file mode 100644 index 0000000..5f89a23 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T041_patch_root_path_rejected.yaml @@ -0,0 +1,25 @@ +id: T041_patch_root_path_rejected +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: / + val: + value: root +event: + kind: root-patch +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedFailureReasonContains: forbidden diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T042_patch_add_missing_intermediate_objects_materializes.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T042_patch_add_missing_intermediate_objects_materializes.yaml new file mode 100644 index 0000000..82d7279 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T042_patch_add_missing_intermediate_objects_materializes.yaml @@ -0,0 +1,25 @@ +id: T042_patch_add_missing_intermediate_objects_materializes +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: add + path: /missing/nested/result + val: + value: made +event: + kind: materialize +expectedCapabilityFailure: false +expectedDocumentPaths: + /missing/nested/result: + value: made diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T043_patch_does_not_auto_materialize_arrays.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T043_patch_does_not_auto_materialize_arrays.yaml new file mode 100644 index 0000000..e4cb7f1 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T043_patch_does_not_auto_materialize_arrays.yaml @@ -0,0 +1,26 @@ +id: T043_patch_does_not_auto_materialize_arrays +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: add + path: /items/0/value + val: + value: bad +event: + kind: no-array-materialize +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedAbsentDocumentPaths: + - /items/0/value diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T044_patch_remove_missing_object_member_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T044_patch_remove_missing_object_member_fatal.yaml new file mode 100644 index 0000000..e1abc50 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T044_patch_remove_missing_object_member_fatal.yaml @@ -0,0 +1,32 @@ +id: T044_patch_remove_missing_object_member_fatal +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + existing: + value: keep + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: remove + path: /missing +event: + kind: remove-missing +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedDocumentPaths: + /existing: + value: keep + /contracts/terminated/cause: + value: fatal +expectedAbsentDocumentPaths: + - /missing +expectedFailureReasonContains: Path does not exist diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T045_patch_replace_object_member_upserts.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T045_patch_replace_object_member_upserts.yaml new file mode 100644 index 0000000..2adbffb --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T045_patch_replace_object_member_upserts.yaml @@ -0,0 +1,25 @@ +id: T045_patch_replace_object_member_upserts +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /upserted + val: + value: yes +event: + kind: upsert +expectedCapabilityFailure: false +expectedDocumentPaths: + /upserted: + value: yes diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T046_patch_array_leading_zero_index_rejected.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T046_patch_array_leading_zero_index_rejected.yaml new file mode 100644 index 0000000..0754339 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T046_patch_array_leading_zero_index_rejected.yaml @@ -0,0 +1,28 @@ +id: T046_patch_array_leading_zero_index_rejected +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + list: + items: + - value: first + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /list/01 + val: + value: bad +event: + kind: leading-zero +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedFailureReasonContains: index diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T047_patch_array_dash_only_allowed_for_add.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T047_patch_array_dash_only_allowed_for_add.yaml new file mode 100644 index 0000000..4b85f6f --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T047_patch_array_dash_only_allowed_for_add.yaml @@ -0,0 +1,28 @@ +id: T047_patch_array_dash_only_allowed_for_add +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + list: + items: + - value: first + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /list/- + val: + value: bad +event: + kind: dash-replace +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedFailureReasonContains: '-' diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T048_ab_not_inside_a_for_patch_boundaries.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T048_ab_not_inside_a_for_patch_boundaries.yaml new file mode 100644 index 0000000..d2729ab --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T048_ab_not_inside_a_for_patch_boundaries.yaml @@ -0,0 +1,34 @@ +id: T048_ab_not_inside_a_for_patch_boundaries +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + a: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /ab/value + val: + value: bad + ab: {} + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /a +event: + kind: ab-boundary +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /a/contracts/terminated +expectedAbsentDocumentPaths: + - /ab/value diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T049_reserved_initialized_path_patch_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T049_reserved_initialized_path_patch_fatal.yaml new file mode 100644 index 0000000..85cb042 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T049_reserved_initialized_path_patch_fatal.yaml @@ -0,0 +1,26 @@ +id: T049_reserved_initialized_path_patch_fatal +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /contracts/initialized + val: + type: + blueId: "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q" +event: + kind: reserved-initialized +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedFailureReasonContains: initialized diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T050_reserved_checkpoint_descendant_patch_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T050_reserved_checkpoint_descendant_patch_fatal.yaml new file mode 100644 index 0000000..9e334d0 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T050_reserved_checkpoint_descendant_patch_fatal.yaml @@ -0,0 +1,25 @@ +id: T050_reserved_checkpoint_descendant_patch_fatal +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /contracts/checkpoint/lastEvents/x + val: + value: bad +event: + kind: reserved-checkpoint +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedFailureReasonContains: checkpoint diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T051_contracts_whole_map_patch_preserving_reserved_subtrees_allowed.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T051_contracts_whole_map_patch_preserving_reserved_subtrees_allowed.yaml new file mode 100644 index 0000000..6d2d946 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T051_contracts_whole_map_patch_preserving_reserved_subtrees_allowed.yaml @@ -0,0 +1,46 @@ +id: T051_contracts_whole_map_patch_preserving_reserved_subtrees_allowed +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + initialized: + type: + blueId: "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q" + documentId: + value: existing + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /contracts + val: + initialized: + type: + blueId: "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q" + documentId: + value: existing + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /changed + val: + value: true +event: + kind: contracts-preserve +expectedCapabilityFailure: false +expectedDocumentPaths: + /contracts/initialized/documentId: + value: existing diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T052_contracts_whole_map_patch_changing_reserved_subtree_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T052_contracts_whole_map_patch_changing_reserved_subtree_fatal.yaml new file mode 100644 index 0000000..f3bb111 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T052_contracts_whole_map_patch_changing_reserved_subtree_fatal.yaml @@ -0,0 +1,34 @@ +id: T052_contracts_whole_map_patch_changing_reserved_subtree_fatal +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + initialized: + type: + blueId: "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q" + documentId: + value: existing + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /contracts + val: + initialized: + type: + blueId: "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q" + documentId: + value: changed +event: + kind: contracts-change +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedFailureReasonContains: preserve diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T053_parent_may_replace_embedded_child_root_containing_reserved_keys.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T053_parent_may_replace_embedded_child_root_containing_reserved_keys.yaml new file mode 100644 index 0000000..adb22bd --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T053_parent_may_replace_embedded_child_root_containing_reserved_keys.yaml @@ -0,0 +1,37 @@ +id: T053_parent_may_replace_embedded_child_root_containing_reserved_keys +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + initialized: + type: + blueId: "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q" + documentId: + value: child-old + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /child + val: + value: replaced +event: + kind: replace-child-root +expectedCapabilityFailure: false +expectedDocumentPaths: + /child: + value: replaced diff --git a/src/test/resources/blue-contracts-1.0/fixtures/patching/T054_parent_may_not_patch_inside_embedded_child_reserved_key.yaml b/src/test/resources/blue-contracts-1.0/fixtures/patching/T054_parent_may_not_patch_inside_embedded_child_reserved_key.yaml new file mode 100644 index 0000000..7312c04 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/patching/T054_parent_may_not_patch_inside_embedded_child_reserved_key.yaml @@ -0,0 +1,37 @@ +id: T054_parent_may_not_patch_inside_embedded_child_reserved_key +category: Patching +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + initialized: + type: + blueId: "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q" + documentId: + value: child-old + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + handler: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /child/contracts/initialized/documentId + val: + value: bad +event: + kind: child-reserved +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedFailureReasonContains: embedded scope diff --git a/src/test/resources/blue-contracts-1.0/fixtures/pointer/T017_pointer_ab_not_inside_a.yaml b/src/test/resources/blue-contracts-1.0/fixtures/pointer/T017_pointer_ab_not_inside_a.yaml new file mode 100644 index 0000000..f351689 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/pointer/T017_pointer_ab_not_inside_a.yaml @@ -0,0 +1,6 @@ +id: T017_pointer_ab_not_inside_a +category: Pointer +operation: pointerDescendant +path: /ab +ancestor: /a +expectedDescendantOrEqual: false diff --git a/src/test/resources/blue-contracts-1.0/fixtures/pointer/T038_pointer_empty_string_rejected.yaml b/src/test/resources/blue-contracts-1.0/fixtures/pointer/T038_pointer_empty_string_rejected.yaml new file mode 100644 index 0000000..da59bfa --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/pointer/T038_pointer_empty_string_rejected.yaml @@ -0,0 +1,6 @@ +id: T038_pointer_empty_string_rejected +category: Pointer +operation: pointerValidation +pointer: "" +expectedValid: false +expectedFailureReasonContains: empty diff --git a/src/test/resources/blue-contracts-1.0/fixtures/pointer/T039_pointer_bad_tilde_rejected.yaml b/src/test/resources/blue-contracts-1.0/fixtures/pointer/T039_pointer_bad_tilde_rejected.yaml new file mode 100644 index 0000000..30d26a8 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/pointer/T039_pointer_bad_tilde_rejected.yaml @@ -0,0 +1,6 @@ +id: T039_pointer_bad_tilde_rejected +category: Pointer +operation: pointerValidation +pointer: /bad~2path +expectedValid: false +expectedFailureReasonContains: escape diff --git a/src/test/resources/blue-contracts-1.0/fixtures/pointer/T040_pointer_trailing_slash_rejected.yaml b/src/test/resources/blue-contracts-1.0/fixtures/pointer/T040_pointer_trailing_slash_rejected.yaml new file mode 100644 index 0000000..3fbe2db --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/pointer/T040_pointer_trailing_slash_rejected.yaml @@ -0,0 +1,6 @@ +id: T040_pointer_trailing_slash_rejected +category: Pointer +operation: pointerValidation +pointer: /trailing/ +expectedValid: false +expectedFailureReasonContains: trailing diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/T001_registry_runtime_type_blueids.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/T001_registry_runtime_type_blueids.yaml new file mode 100644 index 0000000..bad42c8 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/T001_registry_runtime_type_blueids.yaml @@ -0,0 +1,26 @@ +id: T001_registry_runtime_type_blueids +category: Registry +operation: registryRuntimeTypeBlueIds +registryKind: Blue Contracts runtime type registry +semanticDescriptionIdentityBearing: true +expectedRuntimeBlueIds: + CONTRACT: "6WrVQoSpKHUUg5HPrwjkVV6pxe4sdkyGnakMs8ayEGeF" + CHANNEL: "4FAZ94JPExNM4pn2ZhtdHa4CVP7uASmLNVrBy7aCG1p5" + HANDLER: "7X46P3Q6FJrogqKrBXTALpqzkieyyiQeatnqLvWzAPXE" + MARKER: "6zqbYGDGrMv5ReuEsjyzyyjjuqVnqDZxtY7RsPXdBTNy" + JSON_PATCH_ENTRY: "61W96XosAp3DrEC7PuqLYtmF2A6ETpqH6qF2DgYwDq4c" + CONTRACT_EXECUTION_RESULT: "AMtAXPmvumgz1GxKUU9uv3ncXiKMENvqq8AaLvD5LXhv" + PROCESS_EMBEDDED: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + PROCESSING_INITIALIZED_MARKER: "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q" + PROCESSING_TERMINATED_MARKER: "GBDBthfshBFr4GQKUU1fmy4GnPL7q2y3as4deUWpuBtu" + CHANNEL_EVENT_CHECKPOINT: "9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1" + TYPE_GENERALIZATION_POLICY: "Fbenow6tanFHkWzKiDD8fGxminQswQ1FecMRakaCx2WX" + TYPE_GENERALIZATION_RULE: "7Vnmk8StjwY7e9mBNpACrn8oh3KZ7yQBjnXe5bLDWn4D" + DOCUMENT_UPDATE_CHANNEL: "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o" + TRIGGERED_EVENT_CHANNEL: "5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ" + LIFECYCLE_EVENT_CHANNEL: "2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ" + EMBEDDED_NODE_CHANNEL: "H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i" + DOCUMENT_UPDATE: "7HEaG1SpBdsbVHsrwRTZSZGmpJUWHfFoEzecYWpjo1vm" + DOCUMENT_PROCESSING_INITIATED: "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + DOCUMENT_PROCESSING_TERMINATED: "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" + DOCUMENT_PROCESSING_FATAL_ERROR: "AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC" diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/changingRuntimeTypeDescriptionChangesBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/changingRuntimeTypeDescriptionChangesBlueId.yaml new file mode 100644 index 0000000..fdb58db --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/changingRuntimeTypeDescriptionChangesBlueId.yaml @@ -0,0 +1,11 @@ +id: changingRuntimeTypeDescriptionChangesBlueId +category: Registry +operation: changingRegistryDescriptionChangesBlueId +registryKind: Blue Contracts runtime type registry +registryKey: DocumentUpdateChannel +registryPath: registry/blue-contracts-1.0/DocumentUpdateChannel.blue +expectedOriginalBlueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o +mutation: + field: description + append: " " +expectBlueIdChanged: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryBlueIdsRecomputeFromPublishedPreprocessingEnvironment.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryBlueIdsRecomputeFromPublishedPreprocessingEnvironment.yaml new file mode 100644 index 0000000..f8ff189 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryBlueIdsRecomputeFromPublishedPreprocessingEnvironment.yaml @@ -0,0 +1,11 @@ +id: runtimeRegistryBlueIdsRecomputeFromPublishedPreprocessingEnvironment +category: Registry +operation: runtimeRegistryPreprocessingEnvironmentReproducible +registryKind: Blue Contracts runtime type registry +preprocessingEnvironment: + coreRegistry: blue-language-1.0 + runtimeRegistry: blue-contracts-1.0 +assertions: + - The registry source nodes can be preprocessed using only the published core and runtime registry bindings. + - Every preprocessed runtime registry node hashes to the BlueId published in the runtime registry manifest. + - Implementations do not rely on implementation-local alias maps to reproduce runtime registry BlueIds. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryChannelEventCheckpointNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryChannelEventCheckpointNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..670e320 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryChannelEventCheckpointNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryChannelEventCheckpointNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: ChannelEventCheckpoint +registryPath: registry/blue-contracts-1.0/ChannelEventCheckpoint.blue +expectedBlueId: 9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1 +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryChannelNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryChannelNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..876bb8e --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryChannelNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryChannelNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: Channel +registryPath: registry/blue-contracts-1.0/Channel.blue +expectedBlueId: 4FAZ94JPExNM4pn2ZhtdHa4CVP7uASmLNVrBy7aCG1p5 +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryContractExecutionResultNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryContractExecutionResultNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..46f44ee --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryContractExecutionResultNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryContractExecutionResultNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: ContractExecutionResult +registryPath: registry/blue-contracts-1.0/ContractExecutionResult.blue +expectedBlueId: AMtAXPmvumgz1GxKUU9uv3ncXiKMENvqq8AaLvD5LXhv +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryContractNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryContractNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..7fa07f8 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryContractNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryContractNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: Contract +registryPath: registry/blue-contracts-1.0/Contract.blue +expectedBlueId: 6WrVQoSpKHUUg5HPrwjkVV6pxe4sdkyGnakMs8ayEGeF +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentIdFieldsUseTextBlueIdStrings.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentIdFieldsUseTextBlueIdStrings.yaml new file mode 100644 index 0000000..ceeeee0 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentIdFieldsUseTextBlueIdStrings.yaml @@ -0,0 +1,15 @@ +id: runtimeRegistryDocumentIdFieldsUseTextBlueIdStrings +category: Registry +operation: registryFieldUsesTextBlueIdString +registryKind: Blue Contracts runtime type registry +fields: + - registryKey: ProcessingInitializedMarker + registryPath: registry/blue-contracts-1.0/ProcessingInitializedMarker.blue + fieldPath: /documentId + expectedType: Text + expectedDescriptionContains: BlueId string + - registryKey: DocumentProcessingInitiated + registryPath: registry/blue-contracts-1.0/DocumentProcessingInitiated.blue + fieldPath: /documentId + expectedType: Text + expectedDescriptionContains: BlueId string diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentUpdateChannelNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentUpdateChannelNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..7e5934d --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentUpdateChannelNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryDocumentUpdateChannelNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: DocumentUpdateChannel +registryPath: registry/blue-contracts-1.0/DocumentUpdateChannel.blue +expectedBlueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentUpdateEventNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentUpdateEventNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..7afb8f8 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryDocumentUpdateEventNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryDocumentUpdateEventNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: DocumentUpdate +registryPath: registry/blue-contracts-1.0/DocumentUpdate.blue +expectedBlueId: 7HEaG1SpBdsbVHsrwRTZSZGmpJUWHfFoEzecYWpjo1vm +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryEmbeddedNodeChannelNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryEmbeddedNodeChannelNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..d7d2103 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryEmbeddedNodeChannelNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryEmbeddedNodeChannelNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: EmbeddedNodeChannel +registryPath: registry/blue-contracts-1.0/EmbeddedNodeChannel.blue +expectedBlueId: H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryFatalErrorEventNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryFatalErrorEventNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..bf94b77 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryFatalErrorEventNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryFatalErrorEventNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: DocumentProcessingFatalError +registryPath: registry/blue-contracts-1.0/DocumentProcessingFatalError.blue +expectedBlueId: AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryHandlerNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryHandlerNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..a01b313 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryHandlerNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryHandlerNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: Handler +registryPath: registry/blue-contracts-1.0/Handler.blue +expectedBlueId: 7X46P3Q6FJrogqKrBXTALpqzkieyyiQeatnqLvWzAPXE +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryJsonPatchEntryNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryJsonPatchEntryNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..e3a6af3 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryJsonPatchEntryNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryJsonPatchEntryNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: JsonPatchEntry +registryPath: registry/blue-contracts-1.0/JsonPatchEntry.blue +expectedBlueId: 61W96XosAp3DrEC7PuqLYtmF2A6ETpqH6qF2DgYwDq4c +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryLifecycleEventChannelNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryLifecycleEventChannelNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..826009d --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryLifecycleEventChannelNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryLifecycleEventChannelNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: LifecycleEventChannel +registryPath: registry/blue-contracts-1.0/LifecycleEventChannel.blue +expectedBlueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryMarkerNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryMarkerNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..165bf7f --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryMarkerNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryMarkerNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: Marker +registryPath: registry/blue-contracts-1.0/Marker.blue +expectedBlueId: 6zqbYGDGrMv5ReuEsjyzyyjjuqVnqDZxtY7RsPXdBTNy +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessEmbeddedNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessEmbeddedNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..0ff6dbb --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessEmbeddedNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryProcessEmbeddedNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: ProcessEmbedded +registryPath: registry/blue-contracts-1.0/ProcessEmbedded.blue +expectedBlueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingInitializedMarkerNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingInitializedMarkerNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..fe04b5e --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingInitializedMarkerNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryProcessingInitializedMarkerNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: ProcessingInitializedMarker +registryPath: registry/blue-contracts-1.0/ProcessingInitializedMarker.blue +expectedBlueId: 6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingInitiatedEventNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingInitiatedEventNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..4da7dff --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingInitiatedEventNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryProcessingInitiatedEventNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: DocumentProcessingInitiated +registryPath: registry/blue-contracts-1.0/DocumentProcessingInitiated.blue +expectedBlueId: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingTerminatedEventNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingTerminatedEventNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..aeba4e8 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingTerminatedEventNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryProcessingTerminatedEventNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: DocumentProcessingTerminated +registryPath: registry/blue-contracts-1.0/DocumentProcessingTerminated.blue +expectedBlueId: 4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingTerminatedMarkerNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingTerminatedMarkerNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..f61b15a --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryProcessingTerminatedMarkerNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryProcessingTerminatedMarkerNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: ProcessingTerminatedMarker +registryPath: registry/blue-contracts-1.0/ProcessingTerminatedMarker.blue +expectedBlueId: GBDBthfshBFr4GQKUU1fmy4GnPL7q2y3as4deUWpuBtu +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTriggeredEventChannelNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTriggeredEventChannelNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..b45ed89 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTriggeredEventChannelNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryTriggeredEventChannelNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: TriggeredEventChannel +registryPath: registry/blue-contracts-1.0/TriggeredEventChannel.blue +expectedBlueId: 5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTypeGeneralizationPolicyNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTypeGeneralizationPolicyNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..ddd156d --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTypeGeneralizationPolicyNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryTypeGeneralizationPolicyNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: TypeGeneralizationPolicy +registryPath: registry/blue-contracts-1.0/TypeGeneralizationPolicy.blue +expectedBlueId: Fbenow6tanFHkWzKiDD8fGxminQswQ1FecMRakaCx2WX +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTypeGeneralizationRuleNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTypeGeneralizationRuleNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..92a3d91 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/registry/runtimeRegistryTypeGeneralizationRuleNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,8 @@ +id: runtimeRegistryTypeGeneralizationRuleNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Contracts runtime type registry +registryKey: TypeGeneralizationRule +registryPath: registry/blue-contracts-1.0/TypeGeneralizationRule.blue +expectedBlueId: 7Vnmk8StjwY7e9mBNpACrn8oh3KZ7yQBjnXe5bLDWn4D +semanticDescriptionIdentityBearing: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T014_root_graceful_termination.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T014_root_graceful_termination.yaml new file mode 100644 index 0000000..cc3b530 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T014_root_graceful_termination.yaml @@ -0,0 +1,39 @@ +id: T014_root_graceful_termination +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + terminator: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + termination: graceful + terminationReason: done +event: + kind: terminate +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + - "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" +expectedRootEventPathValues: + - index: 1 + path: /cause + value: + value: graceful + - index: 1 + path: /reason + value: + value: done +expectedDocumentPathExists: + - /contracts/terminated +expectedDocumentPaths: + /contracts/terminated/cause: + value: graceful + /contracts/terminated/reason: + value: done diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T015_root_fatal_termination_event_order.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T015_root_fatal_termination_event_order.yaml new file mode 100644 index 0000000..fcc43a9 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T015_root_fatal_termination_event_order.yaml @@ -0,0 +1,44 @@ +id: T015_root_fatal_termination_event_order +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + terminator: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + termination: fatal + terminationReason: failed +event: + kind: terminate +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + - "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" + - "AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC" +expectedRootEventPathValues: + - index: 1 + path: /cause + value: + value: fatal + - index: 1 + path: /reason + value: + value: failed + - index: 2 + path: /reason + value: + value: failed +expectedDocumentPathExists: + - /contracts/terminated +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal + /contracts/terminated/reason: + value: failed diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T055_termination_marker_direct_write_does_not_cascade.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T055_termination_marker_direct_write_does_not_cascade.yaml new file mode 100644 index 0000000..7477683 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T055_termination_marker_direct_write_does_not_cascade.yaml @@ -0,0 +1,36 @@ +id: T055_termination_marker_direct_write_does_not_cascade +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + terminator: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: graceful + terminationReason: done + docUpdate: + type: + blueId: "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o" + path: /contracts/terminated + duHandler: + channel: docUpdate + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /terminationCascaded + val: + value: true +event: + kind: term-direct +expectedCapabilityFailure: false +expectedDocumentPathExists: + - /contracts/terminated +expectedAbsentDocumentPaths: + - /terminationCascaded diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T056_graceful_root_termination_ends_run.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T056_graceful_root_termination_ends_run.yaml new file mode 100644 index 0000000..e28311e --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T056_graceful_root_termination_ends_run.yaml @@ -0,0 +1,25 @@ +id: T056_graceful_root_termination_ends_run +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + terminator: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: graceful + terminationReason: done +event: + kind: graceful +expectedCapabilityFailure: false +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + - "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" +expectedDocumentPaths: + /contracts/terminated/cause: + value: graceful diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T057_root_fatal_appends_terminated_then_fatal_event.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T057_root_fatal_appends_terminated_then_fatal_event.yaml new file mode 100644 index 0000000..8774a77 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T057_root_fatal_appends_terminated_then_fatal_event.yaml @@ -0,0 +1,23 @@ +id: T057_root_fatal_appends_terminated_then_fatal_event +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + terminator: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: fatal + terminationReason: fatal-root +event: + kind: fatal-root +expectedCapabilityFailure: false +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + - "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" + - "AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC" diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T058_fatal_error_not_lifecycle_delivered.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T058_fatal_error_not_lifecycle_delivered.yaml new file mode 100644 index 0000000..dff8bcd --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T058_fatal_error_not_lifecycle_delivered.yaml @@ -0,0 +1,40 @@ +id: T058_fatal_error_not_lifecycle_delivered +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + terminator: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: fatal + terminationReason: fatal-only-outbox + life: + type: + blueId: "2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ" + fatalLifecycleProbe: + channel: life + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + event: + type: + blueId: AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC + patches: + - op: replace + path: /fatalLifecycleDelivered + val: + value: true +event: + kind: fatal-lifecycle +expectedCapabilityFailure: false +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + - "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" + - "AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC" +expectedDocumentPathExists: + - /contracts/terminated diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T059_termination_reentrancy_no_duplicate_marker_or_event.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T059_termination_reentrancy_no_duplicate_marker_or_event.yaml new file mode 100644 index 0000000..3e4e563 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T059_termination_reentrancy_no_duplicate_marker_or_event.yaml @@ -0,0 +1,31 @@ +id: T059_termination_reentrancy_no_duplicate_marker_or_event +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + first: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: graceful + terminationReason: first + second: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: graceful + terminationReason: second +event: + kind: reentrant +expectedCapabilityFailure: false +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + - "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" +expectedDocumentPaths: + /contracts/terminated/reason: + value: first diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T060_post_termination_emit_and_patch_noop.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T060_post_termination_emit_and_patch_noop.yaml new file mode 100644 index 0000000..10209be --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T060_post_termination_emit_and_patch_noop.yaml @@ -0,0 +1,35 @@ +id: T060_post_termination_emit_and_patch_noop +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + aTerminator: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: graceful + terminationReason: stop + zAfter: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /afterTerminationPatch + val: + value: true + triggeredEvents: + - value: after +event: + kind: post-term +expectedCapabilityFailure: false +expectedAbsentDocumentPaths: + - /afterTerminationPatch +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + - "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T061_child_termination_lifecycle_bridges_to_parent.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T061_child_termination_lifecycle_bridges_to_parent.yaml new file mode 100644 index 0000000..6063fbf --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T061_child_termination_lifecycle_bridges_to_parent.yaml @@ -0,0 +1,45 @@ +id: T061_child_termination_lifecycle_bridges_to_parent +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + c: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + t: + channel: c + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: graceful + terminationReason: child-done + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child + bridge: + type: + blueId: "H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i" + childPath: /child + h: + channel: bridge + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + event: + type: + blueId: 4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK + patches: + - op: replace + path: /childTerminationBridged + val: + value: true +event: + kind: child-term +expectedCapabilityFailure: false +expectedDocumentPaths: + /childTerminationBridged: + value: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T062_non_root_fatal_does_not_escalate_to_root_by_default.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T062_non_root_fatal_does_not_escalate_to_root_by_default.yaml new file mode 100644 index 0000000..b990782 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T062_non_root_fatal_does_not_escalate_to_root_by_default.yaml @@ -0,0 +1,45 @@ +id: T062_non_root_fatal_does_not_escalate_to_root_by_default +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + child: + contracts: + c: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + t: + channel: c + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: fatal + terminationReason: child-fatal + contracts: + embedded: + type: + blueId: "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q" + paths: + - /child + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + parent: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + patches: + - op: replace + path: /parentStillRan + val: + value: true +event: + kind: child-fatal +expectedCapabilityFailure: false +expectedDocumentPaths: + /child/contracts/terminated/cause: + value: fatal + /parentStillRan: + value: true +expectedAbsentDocumentPaths: + - /contracts/terminated diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/T063_fatal_during_root_termination_lifecycle_appends_exactly_one_fatal.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/T063_fatal_during_root_termination_lifecycle_appends_exactly_one_fatal.yaml new file mode 100644 index 0000000..ee11499 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/T063_fatal_during_root_termination_lifecycle_appends_exactly_one_fatal.yaml @@ -0,0 +1,34 @@ +id: T063_fatal_during_root_termination_lifecycle_appends_exactly_one_fatal +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: "9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi" + terminator: + channel: channel + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + termination: fatal + terminationReason: lifecycle-fatal + life: + type: + blueId: "2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ" + failingLife: + channel: life + type: + blueId: "HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4" + event: + type: + blueId: 4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK + failure: beforeEffects +event: + kind: fatal-during-termination +expectedCapabilityFailure: false +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + - "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" + - "AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC" diff --git a/src/test/resources/blue-contracts-1.0/fixtures/termination/terminationDirectWriteMalformedContractsFallbackOrTerminationError.yaml b/src/test/resources/blue-contracts-1.0/fixtures/termination/terminationDirectWriteMalformedContractsFallbackOrTerminationError.yaml new file mode 100644 index 0000000..7fa04ac --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/termination/terminationDirectWriteMalformedContractsFallbackOrTerminationError.yaml @@ -0,0 +1,23 @@ +id: terminationDirectWriteMalformedContractsFallbackOrTerminationError +category: Termination +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: malformed-scalar-contracts-container +event: + kind: force-root-fatal +mockRuntime: + forcedFatal: + scope: / + reason: malformed contracts prevents ordinary termination write +expectedStatus: runtime-fatal +expectedErrorCategories: [TerminationError] +expectedTerminationFallback: + maxAttempts: 1 + targetPath: /contracts +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal +assertions: + - If the fallback also fails, the conformance result reports TerminationError instead of looping. diff --git a/src/test/resources/blue-contracts-1.0/fixtures/triggered-fifo/T009_triggered_fifo_not_drained_during_cascade.yaml b/src/test/resources/blue-contracts-1.0/fixtures/triggered-fifo/T009_triggered_fifo_not_drained_during_cascade.yaml new file mode 100644 index 0000000..61182fb --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/triggered-fifo/T009_triggered_fifo_not_drained_during_cascade.yaml @@ -0,0 +1,76 @@ +id: T009_triggered_fifo_not_drained_during_cascade +category: TriggeredFIFO +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + triggered: + type: + blueId: "5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ" + docUpdate: + type: + blueId: "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o" + path: /patched + cascadeHandler: + channel: docUpdate + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /orderLog + val: + items: + - value: external-patch + - value: document-update-cascade + triggeredEvents: + - value: queued-during-cascade + handler: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /patched + val: + value: true + - op: replace + path: /orderLog + val: + items: + - value: external-patch + fifoHandler: + channel: triggered + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /fifoDrained + val: + value: true + - op: replace + path: /orderLog + val: + items: + - value: external-patch + - value: document-update-cascade + - value: fifo-drain +event: + kind: fifo +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedDocumentPaths: + /patched: + value: true + /fifoDrained: + value: true +expectedDocumentPathValues: + - path: /orderLog + value: + items: + - value: external-patch + - value: document-update-cascade + - value: fifo-drain diff --git a/src/test/resources/blue-contracts-1.0/fixtures/triggered-fifo/T020_emit_invalid_event_fatal_before_gas.yaml b/src/test/resources/blue-contracts-1.0/fixtures/triggered-fifo/T020_emit_invalid_event_fatal_before_gas.yaml new file mode 100644 index 0000000..ec16b68 --- /dev/null +++ b/src/test/resources/blue-contracts-1.0/fixtures/triggered-fifo/T020_emit_invalid_event_fatal_before_gas.yaml @@ -0,0 +1,41 @@ +id: T020_emit_invalid_event_fatal_before_gas +category: TriggeredFIFO +operation: processDocument +processorCapabilities: + - blue-contracts-fixture-scripted-runtime-v1 +initialDocument: + contracts: + channel: + type: + blueId: 9XJaukZBmGUkFJ5TD3mrEnj98A6UfXXhzXGtwTJapmZi + triggered: + type: + blueId: "5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ" + emitter: + channel: channel + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + emitInvalidEvent: true + triggeredObserver: + channel: triggered + type: + blueId: HvDTdkzXnW9fRL7NrifNu63TMdQbXUcyihfiPy4CARw4 + patches: + - op: replace + path: /localTriggeredHandlerRan + val: + value: true +event: + kind: invalid-emit +expectedCapabilityFailure: false +expectedTotalGasMin: 1 +expectedRootEventTypes: + - "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL" + - "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK" + - "AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC" +expectedDocumentPaths: + /contracts/terminated/cause: + value: fatal +expectedAbsentDocumentPaths: + - /localTriggeredHandlerRan +expectedFailureReasonContains: Invalid emitted event diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_1e0.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_1e0.yaml index 79d2c2e..ec3277d 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_1e0.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_1e0.yaml @@ -3,4 +3,4 @@ category: BlueId operation: calculateBlueId description: exponent notation 1e0 is inferred as Double input: 1e0 -expectedNodeBlueId: "3SeoqNFjgk6TJV2DzMBGv1wkzcgUzTL9s8BVKhmnXUqL" +expectedNodeBlueId: 8BCB5pcQa5PAHvBa2FSzEWe9MZ5YEirsuymGaE2BM3MA diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_negative_zero.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_negative_zero.yaml new file mode 100644 index 0000000..d9d7bbb --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_negative_zero.yaml @@ -0,0 +1,14 @@ +id: B_double_negative_zero +category: BlueId +operation: calculateBlueId +description: Double negative zero canonicalizes to numeric payload 0 while retaining + Double type +input: + type: + blueId: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ + value: -0.0 +expectedNodeBlueId: EMyry1HoEveRhdWaq554hVrrciRW1731ik3bXLLyN36U +alsoEquivalentTo: + type: + blueId: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ + value: 0.0 diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_overflow_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_overflow_rejected.yaml new file mode 100644 index 0000000..4e352ba --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_double_overflow_rejected.yaml @@ -0,0 +1,9 @@ +id: B_double_overflow_rejected +category: BlueId +operation: parseBlueIdInput +description: Double overflow is rejected as a non-finite numeric token +expectError: true +input: + type: + blueId: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ + value: 1e999 diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_list.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_list.yaml index a6a182b..2f1e3cb 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_list.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_list.yaml @@ -3,4 +3,4 @@ category: BlueId operation: calculateBlueId description: empty list remains hashable as list content input: [] -expectedNodeBlueId: "8m5xsnaKVx5FSBtpcf3tNWKQcE795gf51XbNUTrjxmDK" +expectedNodeBlueId: 4mfDwwrpfVGMwKn82vsr4rVX484P8DAMy5RNEVXqsy9h diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_object_list_element_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_object_list_element_rejected.yaml index f9168f6..b0894cd 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_object_list_element_rejected.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_object_list_element_rejected.yaml @@ -5,6 +5,6 @@ description: direct BlueId Input rejects empty-object list elements expectError: true input: items: - - A - - {} - - B + - A + - {} + - B diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_placeholder.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_placeholder.yaml index 6394fdc..d06ef00 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_placeholder.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_empty_placeholder.yaml @@ -4,7 +4,7 @@ operation: calculateBlueId description: exact empty placeholder hashes as positional list content input: items: - - value: A - - $empty: true - - value: B -expectedNodeBlueId: "Eeg3z8vs8hxseHrd3vssoYsXCvPSYq2cWjqp1ZaqtYTX" + - value: A + - $empty: true + - value: B +expectedNodeBlueId: 8maN5MoWw9kJrGp4y4gwa7R3N53xv5RuPzVKnP1CmKgf diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_integer_1_vs_double_1_0.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_integer_1_vs_double_1_0.yaml index ca4a6be..c9ce40a 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_integer_1_vs_double_1_0.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_integer_1_vs_double_1_0.yaml @@ -3,5 +3,5 @@ category: BlueId operation: calculateBlueId description: integer 1 and double 1.0 have distinct BlueIds input: 1.0 -expectedNodeBlueId: "3SeoqNFjgk6TJV2DzMBGv1wkzcgUzTL9s8BVKhmnXUqL" +expectedNodeBlueId: 8BCB5pcQa5PAHvBa2FSzEWe9MZ5YEirsuymGaE2BM3MA alsoDifferentFrom: 1 diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_large_integer_quoted_explicit_integer.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_large_integer_quoted_explicit_integer.yaml index 50f8b65..9d010b9 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_large_integer_quoted_explicit_integer.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_large_integer_quoted_explicit_integer.yaml @@ -1,9 +1,10 @@ id: B_large_integer_quoted_explicit_integer category: BlueId operation: calculateBlueId -description: large integers use quoted canonical decimal strings with explicit Integer type +description: large integers use quoted canonical decimal strings with explicit Integer + type input: type: - blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 - value: "9007199254740992" -expectedNodeBlueId: "ERvwYbBgPotMM2EMpdJ5sieCht9TGYaCmsFzeEH1hU1H" + blueId: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq + value: '9007199254740992' +expectedNodeBlueId: wXgWtTwBfUZdEeHyA3DnsuiwxczE57E6HvUBNRs1Pyz diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_list_sugar_equivalence.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_list_sugar_equivalence.yaml index 13accec..2a93b35 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_list_sugar_equivalence.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_list_sugar_equivalence.yaml @@ -3,10 +3,10 @@ category: BlueId operation: calculateBlueId description: surface list and wrapped items list are equivalent input: - - A - - B -expectedNodeBlueId: "HGLd6gvx6YHX2cizkCMnrTPhTngpTeRnSzApLfrGYDWf" +- A +- B +expectedNodeBlueId: 49W45qY1LKK66NNRDF8k3W75zd8mj48Fh9wBs31ZNceB alsoEquivalentTo: items: - - A - - B + - A + - B diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_malformed_empty_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_malformed_empty_rejected.yaml index 168a39c..7c6e1e3 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_malformed_empty_rejected.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_malformed_empty_rejected.yaml @@ -5,4 +5,4 @@ description: malformed empty placeholders are rejected expectError: true input: items: - - $empty: false + - $empty: false diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_null_list_element_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_null_list_element_rejected.yaml index 37239af..4faaf1d 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_null_list_element_rejected.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_null_list_element_rejected.yaml @@ -5,6 +5,6 @@ description: direct BlueId Input rejects null list elements expectError: true input: items: - - A - - null - - B + - A + - null + - B diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_object_field_null_removal.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_object_field_null_removal.yaml index c34aef5..847c8a5 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_object_field_null_removal.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_object_field_null_removal.yaml @@ -4,5 +4,5 @@ operation: calculateBlueId description: object field null is removed during direct BlueId cleaning input: x: null -expectedNodeBlueId: "5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK" +expectedNodeBlueId: 5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK alsoEquivalentTo: {} diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_payload_only_scalar_typed_identity.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_payload_only_scalar_typed_identity.yaml new file mode 100644 index 0000000..9f869f9 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_payload_only_scalar_typed_identity.yaml @@ -0,0 +1,11 @@ +id: B_payload_only_scalar_typed_identity +category: BlueId +operation: calculateBlueId +description: payload-only scalar hashing uses typed scalar identity form, not raw + JSON scalar hashing +input: 1 +expectedNodeBlueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz +alsoDifferentFrom: + type: + blueId: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ + value: 1.0 diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_pos_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_pos_rejected.yaml index fd66d9a..0993df7 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_pos_rejected.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_pos_rejected.yaml @@ -3,7 +3,8 @@ category: BlueId operation: calculateBlueId description: direct BlueId Input rejects $pos overlays expectError: true +expectedErrorCategory: InvalidBlueIdInput input: items: - - $pos: 0 - value: A + - $pos: 0 + value: A diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_previous_invalid_blueid_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_previous_invalid_blueid_rejected.yaml index 42010e4..c7a71ee 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_previous_invalid_blueid_rejected.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_previous_invalid_blueid_rejected.yaml @@ -3,8 +3,9 @@ category: BlueId operation: calculateBlueId description: previous anchors must use plain valid BlueIds expectError: true +expectedErrorCategory: ListControlViolation input: items: - - $previous: - blueId: not-a-real-blueid - - A + - $previous: + blueId: not-a-real-blueid + - A diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_replace_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_replace_rejected.yaml index 7ee3852..b61ed60 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_replace_rejected.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_replace_rejected.yaml @@ -5,6 +5,6 @@ description: direct BlueId Input rejects $replace overlays expectError: true input: items: - - $pos: 0 - $replace: - value: A + - $pos: 0 + $replace: + value: A diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_empty_object.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_empty_object.yaml index b5c49eb..2e35fec 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_empty_object.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_empty_object.yaml @@ -3,4 +3,4 @@ category: BlueId operation: calculateBlueId description: root empty object remains hashable input: {} -expectedNodeBlueId: "5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK" +expectedNodeBlueId: 5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_list.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_list.yaml index f86eee7..3a1ddc3 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_list.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_list.yaml @@ -3,6 +3,6 @@ category: BlueId operation: calculateBlueId description: root list is valid BlueId Input input: - - A - - B -expectedNodeBlueId: "HGLd6gvx6YHX2cizkCMnrTPhTngpTeRnSzApLfrGYDWf" +- A +- B +expectedNodeBlueId: 49W45qY1LKK66NNRDF8k3W75zd8mj48Fh9wBs31ZNceB diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_pure_reference.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_pure_reference.yaml index a40d222..ab35c19 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_pure_reference.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_pure_reference.yaml @@ -3,5 +3,5 @@ category: BlueId operation: calculateBlueId description: root pure reference hashes to its plain BlueId input: - blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K -expectedNodeBlueId: "DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K" + blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC +expectedNodeBlueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_scalar.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_scalar.yaml index d002460..8da893a 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_scalar.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_root_scalar.yaml @@ -3,4 +3,4 @@ category: BlueId operation: calculateBlueId description: root scalar is valid BlueId Input input: hello -expectedNodeBlueId: "HE4QGLZWbsarW3S32hi6eMrBNaQB2cpuS4WuFHZGiDA6" +expectedNodeBlueId: 7CUvDJwdfytCjadRG1KLL2GrttK2LfdNvEA8HycjMCcv diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_scalar_sugar_equivalence.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_scalar_sugar_equivalence.yaml index 7e8ce1b..3f5087b 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_scalar_sugar_equivalence.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_scalar_sugar_equivalence.yaml @@ -3,6 +3,6 @@ category: BlueId operation: calculateBlueId description: scalar sugar and wrapped scalar are equivalent input: 1 -expectedNodeBlueId: "GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF" +expectedNodeBlueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz alsoEquivalentTo: value: 1 diff --git a/src/test/resources/blue-language-1.0/fixtures/blueid/B_type_alias_rejected_in_direct_blueid_input.yaml b/src/test/resources/blue-language-1.0/fixtures/blueid/B_type_alias_rejected_in_direct_blueid_input.yaml index 9fa2c8a..71c9cb7 100644 --- a/src/test/resources/blue-language-1.0/fixtures/blueid/B_type_alias_rejected_in_direct_blueid_input.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/blueid/B_type_alias_rejected_in_direct_blueid_input.yaml @@ -3,6 +3,7 @@ category: BlueId operation: calculateBlueId description: direct BlueId input rejects unresolved aliases in type-position fields expectError: true +expectedErrorCategory: InvalidBlueIdInput input: type: Integer value: 1 diff --git a/src/test/resources/blue-language-1.0/fixtures/circular/C_circular_reference_set_ids.yaml b/src/test/resources/blue-language-1.0/fixtures/circular/C_circular_reference_set_ids.yaml index 42076e0..8893b8c 100644 --- a/src/test/resources/blue-language-1.0/fixtures/circular/C_circular_reference_set_ids.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/circular/C_circular_reference_set_ids.yaml @@ -3,12 +3,12 @@ category: Circular operation: calculateCircularSetBlueIds description: circular sets produce final MASTER member BlueIds documents: - - name: A - next: - blueId: this#1 - - name: B - next: - blueId: this#0 +- name: A + next: + blueId: this#1 +- name: B + next: + blueId: this#0 expectedBlueIds: - - "C18ETfS2A7MNmBGo67MYaQrRL9TrUSGwvvEu6KoMqC2R#0" - - "C18ETfS2A7MNmBGo67MYaQrRL9TrUSGwvvEu6KoMqC2R#1" +- C18ETfS2A7MNmBGo67MYaQrRL9TrUSGwvvEu6KoMqC2R#0 +- C18ETfS2A7MNmBGo67MYaQrRL9TrUSGwvvEu6KoMqC2R#1 diff --git a/src/test/resources/blue-language-1.0/fixtures/circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml index 6e5e41c..455a755 100644 --- a/src/test/resources/blue-language-1.0/fixtures/circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml @@ -1,12 +1,12 @@ id: C_duplicate_preliminary_ids_deterministic_or_rejected category: Circular operation: calculateCircularSetBlueIds -description: duplicate preliminary IDs use original index as a deterministic tie-break +description: duplicate preliminary cyclic members without identity-bearing disambiguators + are rejected documents: - - next: - blueId: this#1 - - next: - blueId: this#0 -expectedBlueIds: - - "APkJUKnY7fY7dcCpuq16jHSoQQ6hkgUmRJWHXu6GK6Da#0" - - "APkJUKnY7fY7dcCpuq16jHSoQQ6hkgUmRJWHXu6GK6Da#1" +- next: + blueId: this#1 +- next: + blueId: this#0 +expectError: true +expectedErrorCategory: CircularSetError diff --git a/src/test/resources/blue-language-1.0/fixtures/circular/C_three_document_cycle_stable_order.yaml b/src/test/resources/blue-language-1.0/fixtures/circular/C_three_document_cycle_stable_order.yaml index b06e48b..13f35e4 100644 --- a/src/test/resources/blue-language-1.0/fixtures/circular/C_three_document_cycle_stable_order.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/circular/C_three_document_cycle_stable_order.yaml @@ -3,16 +3,16 @@ category: Circular operation: calculateCircularSetBlueIds description: three-document circular set returns final IDs in original input order documents: - - name: A - next: - blueId: this#1 - - name: B - next: - blueId: this#2 - - name: C - next: - blueId: this#0 +- name: A + next: + blueId: this#1 +- name: B + next: + blueId: this#2 +- name: C + next: + blueId: this#0 expectedBlueIds: - - "8vSb4nj6vqUPCySQMaVQKbaHKANXpPhucCrEA6hCxHCf#1" - - "8vSb4nj6vqUPCySQMaVQKbaHKANXpPhucCrEA6hCxHCf#2" - - "8vSb4nj6vqUPCySQMaVQKbaHKANXpPhucCrEA6hCxHCf#0" +- 8vSb4nj6vqUPCySQMaVQKbaHKANXpPhucCrEA6hCxHCf#1 +- 8vSb4nj6vqUPCySQMaVQKbaHKANXpPhucCrEA6hCxHCf#2 +- 8vSb4nj6vqUPCySQMaVQKbaHKANXpPhucCrEA6hCxHCf#0 diff --git a/src/test/resources/blue-language-1.0/fixtures/circular/C_zero_blueid_rejected_in_final_input.yaml b/src/test/resources/blue-language-1.0/fixtures/circular/C_zero_blueid_rejected_in_final_input.yaml index 05d05a8..b00ae20 100644 --- a/src/test/resources/blue-language-1.0/fixtures/circular/C_zero_blueid_rejected_in_final_input.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/circular/C_zero_blueid_rejected_in_final_input.yaml @@ -4,4 +4,4 @@ operation: calculateBlueId description: ZERO_BLUEID placeholders are rejected by finalized Node BlueId input expectError: true input: - blueId: "00000000000000000000000000000000000000000000" + blueId: '00000000000000000000000000000000000000000000' diff --git a/src/test/resources/blue-language-1.0/fixtures/lint/L_no_profile_era_language_conformance_terms.yaml b/src/test/resources/blue-language-1.0/fixtures/lint/L_no_profile_era_language_conformance_terms.yaml new file mode 100644 index 0000000..11013dc --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/lint/L_no_profile_era_language_conformance_terms.yaml @@ -0,0 +1,45 @@ +id: L_no_profile_era_language_conformance_terms +category: DocumentationLint +operation: lintPublishableDocumentation +description: publishable Blue Language 1.0 files use the single conformance target + and canonical section 1 heading +publishableFiles: +- language/1.0/spec.md +requiredHeadings: +- '## 1. Scope, Goals, Versioning, and Conformance' +forbiddenJoinedTerms: +- tokens: + - Conformance + - Profiles + joiner: ' ' +- tokens: + - BlueId + - Profile + joiner: ' ' +- tokens: + - Resolver + - Profile + joiner: ' ' +- tokens: + - Language + - Full + - Profile + joiner: ' ' +- tokens: + - profile + - relative + joiner: '-' +- tokens: + - preprocessing + - profile + joiner: ' ' +- tokens: + - profile + - supported + joiner: '-' +- tokens: + - profile + - specific + joiner: '-' +matchRule: Join tokens with the listed joiner and reject any case-sensitive match + in publishableFiles. diff --git a/src/test/resources/blue-language-1.0/fixtures/manifest.yaml b/src/test/resources/blue-language-1.0/fixtures/manifest.yaml index 9f81e9a..17158ca 100644 --- a/src/test/resources/blue-language-1.0/fixtures/manifest.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/manifest.yaml @@ -1,177 +1,234 @@ -specVersion: "1.0" -fixturePackageIdentity: "sha256:85e8690260c1b861431b42e2bad77d2959fe7253e12d9950f387fb0a9c0e9399" +specVersion: '1.0' +fixturePackageIdentity: sha256:3387cb4b6626fc56cec91d584b2df7f37c229e396dee990750ac50e762a1bc1d fixtures: - - id: B_scalar_sugar_equivalence - category: BlueId - path: blueid/B_scalar_sugar_equivalence.yaml - - id: B_list_sugar_equivalence - category: BlueId - path: blueid/B_list_sugar_equivalence.yaml - - id: B_root_scalar - category: BlueId - path: blueid/B_root_scalar.yaml - - id: B_root_list - category: BlueId - path: blueid/B_root_list.yaml - - id: B_root_empty_object - category: BlueId - path: blueid/B_root_empty_object.yaml - - id: B_root_pure_reference - category: BlueId - path: blueid/B_root_pure_reference.yaml - - id: B_root_null_rejected - category: BlueId - path: blueid/B_root_null_rejected.yaml - - id: B_plain_blueid_validation - category: BlueId - path: blueid/B_plain_blueid_validation.yaml - - id: B_empty_list - category: BlueId - path: blueid/B_empty_list.yaml - - id: B_object_field_null_removal - category: BlueId - path: blueid/B_object_field_null_removal.yaml - - id: B_empty_placeholder - category: BlueId - path: blueid/B_empty_placeholder.yaml - - id: B_null_list_element_rejected - category: BlueId - path: blueid/B_null_list_element_rejected.yaml - - id: B_empty_object_list_element_rejected - category: BlueId - path: blueid/B_empty_object_list_element_rejected.yaml - - id: B_malformed_empty_rejected - category: BlueId - path: blueid/B_malformed_empty_rejected.yaml - - id: B_large_integer_quoted_explicit_integer - category: BlueId - path: blueid/B_large_integer_quoted_explicit_integer.yaml - - id: B_unquoted_large_integer_rejected - category: BlueId - path: blueid/B_unquoted_large_integer_rejected.yaml - - id: B_integer_1_vs_double_1_0 - category: BlueId - path: blueid/B_integer_1_vs_double_1_0.yaml - - id: B_double_1e0 - category: BlueId - path: blueid/B_double_1e0.yaml - - id: B_invalid_this_placeholder_rejected - category: BlueId - path: blueid/B_invalid_this_placeholder_rejected.yaml - - id: B_type_alias_rejected_in_direct_blueid_input - category: BlueId - path: blueid/B_type_alias_rejected_in_direct_blueid_input.yaml - - id: B_previous_invalid_blueid_rejected - category: BlueId - path: blueid/B_previous_invalid_blueid_rejected.yaml - - id: B_pos_rejected - category: BlueId - path: blueid/B_pos_rejected.yaml - - id: B_replace_rejected - category: BlueId - path: blueid/B_replace_rejected.yaml - - id: R_blue_imports_type_itemType_keyType_valueType - category: Resolution - path: resolver/R_blue_imports_type_itemType_keyType_valueType.yaml - - id: R_source_null_list_to_empty - category: Resolution - path: resolver/R_source_null_list_to_empty.yaml - - id: R_source_empty_object_list_to_empty - category: Resolution - path: resolver/R_source_empty_object_list_to_empty.yaml - - id: R_blue_imports - category: Resolution - path: resolver/R_blue_imports.yaml - - id: R_schema_value_shapes - category: Schema - path: resolver/R_schema_value_shapes.yaml - - id: R_schema_large_integer_minimum_with_type_alias - category: Schema - path: resolver/R_schema_large_integer_minimum_with_type_alias.yaml - - id: R_enum_integer_vs_double - category: Schema - path: resolver/R_enum_integer_vs_double.yaml - - id: R_canonical_overlay_no_previous_no_pos - category: Canonicalization - path: resolver/R_canonical_overlay_no_previous_no_pos.yaml - - id: R_inherited_append_only_policy - category: Resolution - path: resolver/R_inherited_append_only_policy.yaml - - id: R_inherited_item_type - category: Resolution - path: resolver/R_inherited_item_type.yaml - - id: R_inherited_keyType_valueType - category: Resolution - path: resolver/R_inherited_keyType_valueType.yaml - - id: R_provider_reference_canonicalizes_back - category: Canonicalization - path: resolver/R_provider_reference_canonicalizes_back.yaml - - id: R_type_aliases_removed_from_canonical_overlay - category: Canonicalization - path: resolver/R_type_aliases_removed_from_canonical_overlay.yaml - - id: R_contracts_merge_as_content - category: Resolution - path: resolver/R_contracts_merge_as_content.yaml - - id: R_top_level_type_name_description_not_inherited - category: Resolution - path: resolver/R_top_level_type_name_description_not_inherited.yaml - - id: R_type_derived_field_removed - category: Canonicalization - path: resolver/R_type_derived_field_removed.yaml - - id: R_instance_field_kept - category: Canonicalization - path: resolver/R_instance_field_kept.yaml - - id: R_provider_reference_with_overlay_keeps_overlay - category: Canonicalization - path: resolver/R_provider_reference_with_overlay_keeps_overlay.yaml - - id: R_contracts_canonicalization_deterministic - category: Canonicalization - path: resolver/R_contracts_canonicalization_deterministic.yaml - - id: R_child_field_labels_materialize_until_overridden - category: Resolution - path: resolver/R_child_field_labels_materialize_until_overridden.yaml - - id: R_canonicalization_deterministic_for_same_resolved_view - category: Canonicalization - path: resolver/R_canonicalization_deterministic_for_same_resolved_view.yaml - - id: F_provider_wrong_blueid_rejected - category: Provider - path: provider/F_provider_wrong_blueid_rejected.yaml - - id: F_provider_missing_content_fails - category: Provider - path: provider/F_provider_missing_content_fails.yaml - - id: F_expand_preserves_node_blueid - category: Provider - path: provider/F_expand_preserves_node_blueid.yaml - - id: F_expand_nested_reference_preserves_node_blueid - category: Provider - path: provider/F_expand_nested_reference_preserves_node_blueid.yaml - - id: F_expand_wrong_nested_provider_content_fails - category: Provider - path: provider/F_expand_wrong_nested_provider_content_fails.yaml - - id: F_expand_missing_nested_content_fails - category: Provider - path: provider/F_expand_missing_nested_content_fails.yaml - - id: F_collapse_preserves_node_blueid - category: Provider - path: provider/F_collapse_preserves_node_blueid.yaml - - id: F_collapse_nested_subtree_preserves_node_blueid - category: Provider - path: provider/F_collapse_nested_subtree_preserves_node_blueid.yaml - - id: F_collapse_does_not_produce_mixed_blueid - category: Provider - path: provider/F_collapse_does_not_produce_mixed_blueid.yaml - - id: C_circular_reference_set_ids - category: Circular - path: circular/C_circular_reference_set_ids.yaml - - id: C_this_placeholder_rejected_outside_cyclic_api - category: Circular - path: circular/C_this_placeholder_rejected_outside_cyclic_api.yaml - - id: C_zero_blueid_rejected_in_final_input - category: Circular - path: circular/C_zero_blueid_rejected_in_final_input.yaml - - id: C_three_document_cycle_stable_order - category: Circular - path: circular/C_three_document_cycle_stable_order.yaml - - id: C_duplicate_preliminary_ids_deterministic_or_rejected - category: Circular - path: circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml +- id: L_no_profile_era_language_conformance_terms + category: DocumentationLint + path: lint/L_no_profile_era_language_conformance_terms.yaml +- id: coreRegistryTextNodeHashesToPublishedBlueId + category: Registry + path: registry/coreRegistryTextNodeHashesToPublishedBlueId.yaml +- id: coreRegistryIntegerNodeHashesToPublishedBlueId + category: Registry + path: registry/coreRegistryIntegerNodeHashesToPublishedBlueId.yaml +- id: coreRegistryDoubleNodeHashesToPublishedBlueId + category: Registry + path: registry/coreRegistryDoubleNodeHashesToPublishedBlueId.yaml +- id: coreRegistryBooleanNodeHashesToPublishedBlueId + category: Registry + path: registry/coreRegistryBooleanNodeHashesToPublishedBlueId.yaml +- id: coreRegistryDictionaryNodeHashesToPublishedBlueId + category: Registry + path: registry/coreRegistryDictionaryNodeHashesToPublishedBlueId.yaml +- id: coreRegistryListNodeHashesToPublishedBlueId + category: Registry + path: registry/coreRegistryListNodeHashesToPublishedBlueId.yaml +- id: changingCoreTypeDescriptionChangesBlueId + category: Registry + path: registry/changingCoreTypeDescriptionChangesBlueId.yaml +- id: B_scalar_sugar_equivalence + category: BlueId + path: blueid/B_scalar_sugar_equivalence.yaml +- id: B_list_sugar_equivalence + category: BlueId + path: blueid/B_list_sugar_equivalence.yaml +- id: B_root_scalar + category: BlueId + path: blueid/B_root_scalar.yaml +- id: B_root_list + category: BlueId + path: blueid/B_root_list.yaml +- id: B_root_empty_object + category: BlueId + path: blueid/B_root_empty_object.yaml +- id: B_root_pure_reference + category: BlueId + path: blueid/B_root_pure_reference.yaml +- id: B_root_null_rejected + category: BlueId + path: blueid/B_root_null_rejected.yaml +- id: B_plain_blueid_validation + category: BlueId + path: blueid/B_plain_blueid_validation.yaml +- id: B_empty_list + category: BlueId + path: blueid/B_empty_list.yaml +- id: B_object_field_null_removal + category: BlueId + path: blueid/B_object_field_null_removal.yaml +- id: B_empty_placeholder + category: BlueId + path: blueid/B_empty_placeholder.yaml +- id: B_null_list_element_rejected + category: BlueId + path: blueid/B_null_list_element_rejected.yaml +- id: B_empty_object_list_element_rejected + category: BlueId + path: blueid/B_empty_object_list_element_rejected.yaml +- id: B_malformed_empty_rejected + category: BlueId + path: blueid/B_malformed_empty_rejected.yaml +- id: B_large_integer_quoted_explicit_integer + category: BlueId + path: blueid/B_large_integer_quoted_explicit_integer.yaml +- id: B_unquoted_large_integer_rejected + category: BlueId + path: blueid/B_unquoted_large_integer_rejected.yaml +- id: B_integer_1_vs_double_1_0 + category: BlueId + path: blueid/B_integer_1_vs_double_1_0.yaml +- id: B_double_1e0 + category: BlueId + path: blueid/B_double_1e0.yaml +- id: B_invalid_this_placeholder_rejected + category: BlueId + path: blueid/B_invalid_this_placeholder_rejected.yaml +- id: B_type_alias_rejected_in_direct_blueid_input + category: BlueId + path: blueid/B_type_alias_rejected_in_direct_blueid_input.yaml +- id: B_previous_invalid_blueid_rejected + category: BlueId + path: blueid/B_previous_invalid_blueid_rejected.yaml +- id: B_pos_rejected + category: BlueId + path: blueid/B_pos_rejected.yaml +- id: B_replace_rejected + category: BlueId + path: blueid/B_replace_rejected.yaml +- id: R_blue_imports_type_itemType_keyType_valueType + category: Resolution + path: resolver/R_blue_imports_type_itemType_keyType_valueType.yaml +- id: R_source_null_list_to_empty + category: Resolution + path: resolver/R_source_null_list_to_empty.yaml +- id: R_source_empty_object_list_to_empty + category: Resolution + path: resolver/R_source_empty_object_list_to_empty.yaml +- id: R_blue_imports + category: Resolution + path: resolver/R_blue_imports.yaml +- id: R_schema_value_shapes + category: Schema + path: resolver/R_schema_value_shapes.yaml +- id: R_schema_large_integer_minimum_with_type_alias + category: Schema + path: resolver/R_schema_large_integer_minimum_with_type_alias.yaml +- id: R_schema_integer_multiple_of_lcm_merge + category: Schema + path: resolver/R_schema_integer_multiple_of_lcm_merge.yaml +- id: R_enum_integer_vs_double + category: Schema + path: resolver/R_enum_integer_vs_double.yaml +- id: R_canonical_overlay_no_previous_no_pos + category: Canonicalization + path: resolver/R_canonical_overlay_no_previous_no_pos.yaml +- id: R_inherited_append_only_policy + category: Resolution + path: resolver/R_inherited_append_only_policy.yaml +- id: R_inherited_item_type + category: Resolution + path: resolver/R_inherited_item_type.yaml +- id: R_inherited_keyType_valueType + category: Resolution + path: resolver/R_inherited_keyType_valueType.yaml +- id: R_provider_reference_canonicalizes_back + category: Canonicalization + path: resolver/R_provider_reference_canonicalizes_back.yaml +- id: R_type_aliases_removed_from_canonical_overlay + category: Canonicalization + path: resolver/R_type_aliases_removed_from_canonical_overlay.yaml +- id: R_contracts_merge_as_content + category: Resolution + path: resolver/R_contracts_merge_as_content.yaml +- id: R_top_level_type_name_description_not_inherited + category: Resolution + path: resolver/R_top_level_type_name_description_not_inherited.yaml +- id: R_type_derived_field_removed + category: Canonicalization + path: resolver/R_type_derived_field_removed.yaml +- id: R_instance_field_kept + category: Canonicalization + path: resolver/R_instance_field_kept.yaml +- id: R_provider_reference_with_overlay_keeps_overlay + category: Canonicalization + path: resolver/R_provider_reference_with_overlay_keeps_overlay.yaml +- id: R_contracts_canonicalization_deterministic + category: Canonicalization + path: resolver/R_contracts_canonicalization_deterministic.yaml +- id: R_child_field_labels_materialize_until_overridden + category: Resolution + path: resolver/R_child_field_labels_materialize_until_overridden.yaml +- id: R_canonicalization_deterministic_for_same_resolved_view + category: Canonicalization + path: resolver/R_canonicalization_deterministic_for_same_resolved_view.yaml +- id: F_provider_wrong_blueid_rejected + category: Provider + path: provider/F_provider_wrong_blueid_rejected.yaml +- id: F_provider_missing_content_fails + category: Provider + path: provider/F_provider_missing_content_fails.yaml +- id: F_expand_preserves_node_blueid + category: Provider + path: provider/F_expand_preserves_node_blueid.yaml +- id: F_expand_nested_reference_preserves_node_blueid + category: Provider + path: provider/F_expand_nested_reference_preserves_node_blueid.yaml +- id: F_expand_wrong_nested_provider_content_fails + category: Provider + path: provider/F_expand_wrong_nested_provider_content_fails.yaml +- id: F_expand_missing_nested_content_fails + category: Provider + path: provider/F_expand_missing_nested_content_fails.yaml +- id: F_collapse_preserves_node_blueid + category: Provider + path: provider/F_collapse_preserves_node_blueid.yaml +- id: F_collapse_nested_subtree_preserves_node_blueid + category: Provider + path: provider/F_collapse_nested_subtree_preserves_node_blueid.yaml +- id: F_collapse_does_not_produce_mixed_blueid + category: Provider + path: provider/F_collapse_does_not_produce_mixed_blueid.yaml +- id: C_circular_reference_set_ids + category: Circular + path: circular/C_circular_reference_set_ids.yaml +- id: C_this_placeholder_rejected_outside_cyclic_api + category: Circular + path: circular/C_this_placeholder_rejected_outside_cyclic_api.yaml +- id: C_zero_blueid_rejected_in_final_input + category: Circular + path: circular/C_zero_blueid_rejected_in_final_input.yaml +- id: C_three_document_cycle_stable_order + category: Circular + path: circular/C_three_document_cycle_stable_order.yaml +- id: C_duplicate_preliminary_ids_deterministic_or_rejected + category: Circular + path: circular/C_duplicate_preliminary_ids_deterministic_or_rejected.yaml +- id: B_double_negative_zero + category: BlueId + path: blueid/B_double_negative_zero.yaml +- id: B_double_overflow_rejected + category: BlueId + path: blueid/B_double_overflow_rejected.yaml +- id: B_payload_only_scalar_typed_identity + category: BlueId + path: blueid/B_payload_only_scalar_typed_identity.yaml +- id: R_source_recursive_empty_object_list_to_empty + category: Resolution + path: resolver/R_source_recursive_empty_object_list_to_empty.yaml +- id: R_core_type_compatibility_nominal_by_blueid + category: Resolution + path: resolver/R_core_type_compatibility_nominal_by_blueid.yaml +- id: R_view_path_root_is_empty_string + category: Resolution + path: resolver/R_view_path_root_is_empty_string.yaml +- id: R_schema_enum_order_and_duplicates_canonical + category: Schema + path: resolver/R_schema_enum_order_and_duplicates_canonical.yaml +- id: R_schema_double_multiple_of_exact + category: Schema + path: resolver/R_schema_double_multiple_of_exact.yaml +- id: R_schema_double_multiple_of_rejects_decimal_approximation + category: Schema + path: resolver/R_schema_double_multiple_of_rejects_decimal_approximation.yaml +- id: R_schema_wrong_kind_keywords_rejected + category: Schema + path: resolver/R_schema_wrong_kind_keywords_rejected.yaml diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_does_not_produce_mixed_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_does_not_produce_mixed_blueid.yaml index 5f947bc..c51c1fd 100644 --- a/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_does_not_produce_mixed_blueid.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_does_not_produce_mixed_blueid.yaml @@ -6,5 +6,5 @@ source: child: 1 label: ok expectedCollapsed: - blueId: 4uy5Y7Kisu3mMtuFEeM1WPZAyrUYyGcJ5XiriEoEvvyt -expectedNodeBlueId: "4uy5Y7Kisu3mMtuFEeM1WPZAyrUYyGcJ5XiriEoEvvyt" + blueId: 75nruUkNVy7nvCEH2VwtuYAHm9qZGNPPD4xYitjTrmR8 +expectedNodeBlueId: 75nruUkNVy7nvCEH2VwtuYAHm9qZGNPPD4xYitjTrmR8 diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_nested_subtree_preserves_node_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_nested_subtree_preserves_node_blueid.yaml index 0050ade..9482515 100644 --- a/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_nested_subtree_preserves_node_blueid.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_nested_subtree_preserves_node_blueid.yaml @@ -1,9 +1,10 @@ id: F_collapse_nested_subtree_preserves_node_blueid category: Provider operation: collapse -description: collapse returns a pure reference for nested materialized content while preserving root Node BlueId +description: collapse returns a pure reference for nested materialized content while + preserving root Node BlueId source: child: 1 expectedCollapsed: - blueId: 7YbCaeXGknitZpoejQMKbfVEBiVtxNGSnSEHvo9ubN6o -expectedNodeBlueId: "7YbCaeXGknitZpoejQMKbfVEBiVtxNGSnSEHvo9ubN6o" + blueId: H7rCFBMhmR3S869fq2fyLthZSBwU8W5TywaaEF13mmZ9 +expectedNodeBlueId: H7rCFBMhmR3S869fq2fyLthZSBwU8W5TywaaEF13mmZ9 diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_preserves_node_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_preserves_node_blueid.yaml index 9ead719..fd3a17c 100644 --- a/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_preserves_node_blueid.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_collapse_preserves_node_blueid.yaml @@ -1,8 +1,9 @@ id: F_collapse_preserves_node_blueid category: Provider operation: collapse -description: collapse returns a pure-reference form preserving known content Node BlueId +description: collapse returns a pure-reference form preserving known content Node + BlueId source: 1 expectedCollapsed: - blueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF -expectedNodeBlueId: "GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF" + blueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz +expectedNodeBlueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_missing_nested_content_fails.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_missing_nested_content_fails.yaml index 6a06279..9720e5d 100644 --- a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_missing_nested_content_fails.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_missing_nested_content_fails.yaml @@ -5,5 +5,5 @@ description: nested expansion fails deterministically when provider content is m expectError: true source: child: - blueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF + blueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz provider: [] diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_nested_reference_preserves_node_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_nested_reference_preserves_node_blueid.yaml index ddba180..2a0e3ea 100644 --- a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_nested_reference_preserves_node_blueid.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_nested_reference_preserves_node_blueid.yaml @@ -1,13 +1,14 @@ id: F_expand_nested_reference_preserves_node_blueid category: Provider operation: expand -description: provider-backed expansion materializes nested pure references and preserves Node BlueId +description: provider-backed expansion materializes nested pure references and preserves + Node BlueId source: child: - blueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF + blueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz provider: - - requestedBlueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF - node: 1 +- requestedBlueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz + node: 1 expectedExpanded: child: 1 -expectedNodeBlueId: "7YbCaeXGknitZpoejQMKbfVEBiVtxNGSnSEHvo9ubN6o" +expectedNodeBlueId: H7rCFBMhmR3S869fq2fyLthZSBwU8W5TywaaEF13mmZ9 diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_preserves_node_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_preserves_node_blueid.yaml index 94d520d..707e0bd 100644 --- a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_preserves_node_blueid.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_preserves_node_blueid.yaml @@ -3,9 +3,9 @@ category: Provider operation: expand description: provider-backed expansion preserves Node BlueId source: - blueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF + blueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz provider: - - requestedBlueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF - node: 1 +- requestedBlueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz + node: 1 expectedExpanded: 1 -expectedNodeBlueId: "GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF" +expectedNodeBlueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_wrong_nested_provider_content_fails.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_wrong_nested_provider_content_fails.yaml index aac969f..ee54517 100644 --- a/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_wrong_nested_provider_content_fails.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_expand_wrong_nested_provider_content_fails.yaml @@ -1,11 +1,12 @@ id: F_expand_wrong_nested_provider_content_fails category: Provider operation: expand -description: nested expansion rejects provider content whose BlueId does not match the requested reference +description: nested expansion rejects provider content whose BlueId does not match + the requested reference expectError: true source: child: - blueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF + blueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz provider: - - requestedBlueId: GrtpYnzZDc1tg6HJEgwvSj93vwHYUrQM7hLkMUTwzZbF - returnedNode: 2 +- requestedBlueId: GhNUbi6oXA1HArr2uTqwpcgegPv8kxUuj11riBtoMJXz + returnedNode: 2 diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_missing_content_fails.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_missing_content_fails.yaml index aedf5c0..d5ca44f 100644 --- a/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_missing_content_fails.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_missing_content_fails.yaml @@ -5,5 +5,5 @@ description: missing provider content fails resolution expectError: true source: type: - blueId: HE4QGLZWbsarW3S32hi6eMrBNaQB2cpuS4WuFHZGiDA6 + blueId: 7CUvDJwdfytCjadRG1KLL2GrttK2LfdNvEA8HycjMCcv provider: [] diff --git a/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_wrong_blueid_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_wrong_blueid_rejected.yaml index 202d6fb..eeb86f3 100644 --- a/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_wrong_blueid_rejected.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/provider/F_provider_wrong_blueid_rejected.yaml @@ -3,10 +3,11 @@ category: Provider operation: resolve description: provider content with the wrong computed BlueId is rejected expectError: true +expectedErrorCategory: ProviderBlueIdMismatch source: type: - blueId: HE4QGLZWbsarW3S32hi6eMrBNaQB2cpuS4WuFHZGiDA6 + blueId: 7CUvDJwdfytCjadRG1KLL2GrttK2LfdNvEA8HycjMCcv provider: - - requestedBlueId: HE4QGLZWbsarW3S32hi6eMrBNaQB2cpuS4WuFHZGiDA6 - returnedNode: - value: actual +- requestedBlueId: 7CUvDJwdfytCjadRG1KLL2GrttK2LfdNvEA8HycjMCcv + returnedNode: + value: actual diff --git a/src/test/resources/blue-language-1.0/fixtures/registry/changingCoreTypeDescriptionChangesBlueId.yaml b/src/test/resources/blue-language-1.0/fixtures/registry/changingCoreTypeDescriptionChangesBlueId.yaml new file mode 100644 index 0000000..755fca9 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/registry/changingCoreTypeDescriptionChangesBlueId.yaml @@ -0,0 +1,9 @@ +id: changingCoreTypeDescriptionChangesBlueId +category: Registry +operation: changingRegistryDescriptionChangesBlueId +registryKind: Blue Language core type registry +registryKey: Text +mutation: + field: description + append: ' ' +expectBlueIdChanged: true diff --git a/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryBooleanNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryBooleanNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..2e10bee --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryBooleanNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,7 @@ +id: coreRegistryBooleanNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Language core type registry +registryKey: Boolean +semanticDescriptionIdentityBearing: true +expectedPublishedBlueId: AwvXD961fmnmqcSQhjMA7r15HpVh39cefb6ZTyUz2Fm2 diff --git a/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryDictionaryNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryDictionaryNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..2dfb8ad --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryDictionaryNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,7 @@ +id: coreRegistryDictionaryNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Language core type registry +registryKey: Dictionary +semanticDescriptionIdentityBearing: true +expectedPublishedBlueId: Efkz9D1ARMM7rU43w3rDNVqat1naS6qXKCqP4eHin3yG diff --git a/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryDoubleNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryDoubleNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..8df2404 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryDoubleNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,7 @@ +id: coreRegistryDoubleNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Language core type registry +registryKey: Double +semanticDescriptionIdentityBearing: true +expectedPublishedBlueId: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ diff --git a/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryIntegerNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryIntegerNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..d4a178b --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryIntegerNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,7 @@ +id: coreRegistryIntegerNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Language core type registry +registryKey: Integer +semanticDescriptionIdentityBearing: true +expectedPublishedBlueId: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq diff --git a/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryListNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryListNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..9d07766 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryListNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,7 @@ +id: coreRegistryListNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Language core type registry +registryKey: List +semanticDescriptionIdentityBearing: true +expectedPublishedBlueId: 8DSFoWG9MqRSUhStqoPLrwVQiYByRh18NWbDEarN8MKF diff --git a/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryTextNodeHashesToPublishedBlueId.yaml b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryTextNodeHashesToPublishedBlueId.yaml new file mode 100644 index 0000000..363bc5a --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/registry/coreRegistryTextNodeHashesToPublishedBlueId.yaml @@ -0,0 +1,7 @@ +id: coreRegistryTextNodeHashesToPublishedBlueId +category: Registry +operation: registryNodeHashesToPublishedBlueId +registryKind: Blue Language core type registry +registryKey: Text +semanticDescriptionIdentityBearing: true +expectedPublishedBlueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_canonical_overlay_no_previous_no_pos.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_canonical_overlay_no_previous_no_pos.yaml index 56759bb..bcf3a48 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_canonical_overlay_no_previous_no_pos.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_canonical_overlay_no_previous_no_pos.yaml @@ -1,17 +1,18 @@ id: R_canonical_overlay_no_previous_no_pos category: Canonicalization operation: canonicalize -description: canonical overlay serializes final list content without $previous or $pos controls +description: canonical overlay serializes final list content without $previous or + $pos controls source: type: - blueId: 6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY + blueId: 8DSFoWG9MqRSUhStqoPLrwVQiYByRh18NWbDEarN8MKF items: - - $pos: 0 - value: A - - value: B + - $pos: 0 + value: A + - value: B expectedCanonicalOverlay: type: - blueId: 6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY + blueId: 8DSFoWG9MqRSUhStqoPLrwVQiYByRh18NWbDEarN8MKF items: - - value: A - - value: B + - value: A + - value: B diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_child_field_labels_materialize_until_overridden.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_child_field_labels_materialize_until_overridden.yaml index 139f8cc..6e000c5 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_child_field_labels_materialize_until_overridden.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_child_field_labels_materialize_until_overridden.yaml @@ -16,11 +16,11 @@ expectedResolved: name: Type child description: Type child description type: - blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC value: inherited child: name: Type child description: Type child description type: - blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC value: inherited diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_canonicalization_deterministic.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_canonicalization_deterministic.yaml index 60703b6..e0b5faa 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_canonicalization_deterministic.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_contracts_canonicalization_deterministic.yaml @@ -1,7 +1,8 @@ id: R_contracts_canonicalization_deterministic category: Canonicalization operation: canonicalize -description: inherited and instance contracts canonicalize deterministically as reserved content +description: inherited and instance contracts canonicalize deterministically as reserved + content source: type: contracts: diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_core_type_compatibility_nominal_by_blueid.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_core_type_compatibility_nominal_by_blueid.yaml new file mode 100644 index 0000000..e9dfcf0 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_core_type_compatibility_nominal_by_blueid.yaml @@ -0,0 +1,11 @@ +id: R_core_type_compatibility_nominal_by_blueid +category: Resolution +operation: resolve +description: canonical core type compatibility is nominal by registry BlueId; a structurally + similar non-registry type is not interchangeable with Integer +expectError: true +source: + type: + type: + blueId: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq + value: 1 diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_enum_integer_vs_double.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_enum_integer_vs_double.yaml index a0c32d7..a413f0b 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_enum_integer_vs_double.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_enum_integer_vs_double.yaml @@ -5,10 +5,10 @@ description: enum accepts scalar integer and double entries as distinct scalar f source: schema: enum: - - 1 - - 1.0 + - 1 + - 1.0 expectedParsed: schema: enum: - - 1 - - 1.0 + - 1 + - 1.0 diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_append_only_policy.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_append_only_policy.yaml index 2d91e81..4fa555b 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_append_only_policy.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_append_only_policy.yaml @@ -1,15 +1,16 @@ id: R_inherited_append_only_policy category: Resolution operation: resolve -description: inherited append-only merge policy rejects positional overlays when child omits mergePolicy +description: inherited append-only merge policy rejects positional overlays when child + omits mergePolicy expectError: true source: type: type: - blueId: 6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY + blueId: 8DSFoWG9MqRSUhStqoPLrwVQiYByRh18NWbDEarN8MKF mergePolicy: append-only items: - - value: A + - value: A items: - - $pos: 0 - value: B + - $pos: 0 + value: B diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_item_type.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_item_type.yaml index 38a2da7..2fc2649 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_item_type.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_item_type.yaml @@ -6,8 +6,8 @@ expectError: true source: type: type: - blueId: 6aehfNAxHLC1PHHoDr3tYtFH3RWNbiWdFancJ1bypXEY + blueId: 8DSFoWG9MqRSUhStqoPLrwVQiYByRh18NWbDEarN8MKF itemType: - blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 + blueId: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq items: - - value: not an integer + - value: not an integer diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_keyType_valueType.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_keyType_valueType.yaml index e03845f..e86c636 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_keyType_valueType.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_inherited_keyType_valueType.yaml @@ -1,15 +1,16 @@ id: R_inherited_keyType_valueType category: Resolution operation: resolve -description: inherited dictionary keyType and valueType remain effective when child omits them +description: inherited dictionary keyType and valueType remain effective when child + omits them expectError: true source: type: type: - blueId: G7fBT9PSod1RfHLHkpafAGBDVAJMrMhAMY51ERcyXNrj + blueId: Efkz9D1ARMM7rU43w3rDNVqat1naS6qXKCqP4eHin3yG keyType: - blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 + blueId: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq valueType: - blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 + blueId: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq notAnIntegerKey: value: not an integer diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_canonicalizes_back.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_canonicalizes_back.yaml index dd9dd38..aa2bd9b 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_canonicalizes_back.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_canonicalizes_back.yaml @@ -1,12 +1,13 @@ id: R_provider_reference_canonicalizes_back category: Canonicalization operation: canonicalize -description: materialized type reference canonicalizes back to the pure reference used by the source +description: materialized type reference canonicalizes back to the pure reference + used by the source source: type: - blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC value: materialized expectedCanonicalOverlay: type: - blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC value: materialized diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_with_overlay_keeps_overlay.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_with_overlay_keeps_overlay.yaml index ec41dc6..cb50a78 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_with_overlay_keeps_overlay.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_provider_reference_with_overlay_keeps_overlay.yaml @@ -1,14 +1,15 @@ id: R_provider_reference_with_overlay_keeps_overlay category: Canonicalization operation: canonicalize -description: provider materialization canonicalizes back to a pure reference plus instance overlay +description: provider materialization canonicalizes back to a pure reference plus + instance overlay source: type: blueId: 5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK local: overlay provider: - - requestedBlueId: 5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK - node: {} +- requestedBlueId: 5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK + node: {} expectedCanonicalOverlay: type: blueId: 5ajuwjHoLj33yG5t5UFsJtUb3vnRaJQEMPqSLz6VyoHK diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_double_multiple_of_exact.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_double_multiple_of_exact.yaml new file mode 100644 index 0000000..4dd1f2b --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_double_multiple_of_exact.yaml @@ -0,0 +1,17 @@ +id: R_schema_double_multiple_of_exact +category: Schema +operation: resolve +description: Double multipleOf uses exact rational arithmetic over IEEE 754 binary64 + values +source: + type: + blueId: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ + schema: + multipleOf: 0.5 + value: 1.5 +expectedResolved: + type: + blueId: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ + schema: + multipleOf: 0.5 + value: 1.5 diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_double_multiple_of_rejects_decimal_approximation.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_double_multiple_of_rejects_decimal_approximation.yaml new file mode 100644 index 0000000..44a6d68 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_double_multiple_of_rejects_decimal_approximation.yaml @@ -0,0 +1,12 @@ +id: R_schema_double_multiple_of_rejects_decimal_approximation +category: Schema +operation: resolve +description: Double multipleOf uses exact binary64 rational arithmetic, not decimal epsilon behavior +expectError: true +expectedErrorCategory: SchemaViolation +source: + type: + blueId: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ + schema: + multipleOf: 0.1 + value: 0.3 diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_enum_order_and_duplicates_canonical.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_enum_order_and_duplicates_canonical.yaml new file mode 100644 index 0000000..51e787d --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_enum_order_and_duplicates_canonical.yaml @@ -0,0 +1,26 @@ +id: R_schema_enum_order_and_duplicates_canonical +category: Schema +operation: resolve +description: enum order and duplicate entries normalize to a duplicate-free canonical + effective schema +source: + type: + schema: + enum: + - B + - A + - A + schema: + enum: + - A + - B +expectedResolved: + type: + schema: + enum: + - value: A + - value: B + schema: + enum: + - value: A + - value: B diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_integer_multiple_of_lcm_merge.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_integer_multiple_of_lcm_merge.yaml new file mode 100644 index 0000000..ca807b1 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_integer_multiple_of_lcm_merge.yaml @@ -0,0 +1,17 @@ +id: R_schema_integer_multiple_of_lcm_merge +category: Schema +operation: resolve +description: inherited and instance integer multipleOf constraints merge deterministically + using LCM +source: + type: + schema: + multipleOf: 4 + schema: + multipleOf: 6 +expectedResolved: + type: + schema: + multipleOf: 4 + schema: + multipleOf: 12 diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_large_integer_minimum_with_type_alias.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_large_integer_minimum_with_type_alias.yaml index 12791fe..82e726c 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_large_integer_minimum_with_type_alias.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_large_integer_minimum_with_type_alias.yaml @@ -6,10 +6,10 @@ source: schema: minimum: type: Integer - value: "9007199254740992" + value: '9007199254740992' expectedPreprocessed: schema: minimum: type: - blueId: 5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1 - value: "9007199254740992" + blueId: E2LM6qgzWG9ttagq2xTmiZkgYEAgkYedFCmU9v7NnVEq + value: '9007199254740992' diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_value_shapes.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_value_shapes.yaml index 2ba0652..57e1602 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_value_shapes.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_value_shapes.yaml @@ -3,6 +3,7 @@ category: Schema operation: parseSource description: schema keyword value shapes are strict expectError: true +expectedErrorCategory: SchemaVocabularyError source: schema: required: diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_wrong_kind_keywords_rejected.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_wrong_kind_keywords_rejected.yaml new file mode 100644 index 0000000..2e6fe78 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_schema_wrong_kind_keywords_rejected.yaml @@ -0,0 +1,10 @@ +id: R_schema_wrong_kind_keywords_rejected +category: Schema +operation: resolve +description: schema keywords applied to incompatible value kinds fail deterministically +expectError: true +expectedErrorCategory: SchemaViolation +source: + schema: + minLength: 1 + value: 123 diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_empty_object_list_to_empty.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_empty_object_list_to_empty.yaml index f504d7d..4e816ff 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_empty_object_list_to_empty.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_empty_object_list_to_empty.yaml @@ -4,11 +4,11 @@ operation: preprocess description: source list empty object normalizes to exact empty placeholder source: items: - - A - - {} - - B + - A + - {} + - B expectedPreprocessed: items: - - value: A - - $empty: true - - value: B + - value: A + - $empty: true + - value: B diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_null_list_to_empty.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_null_list_to_empty.yaml index 61b6ca7..7cfbe28 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_null_list_to_empty.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_null_list_to_empty.yaml @@ -4,11 +4,11 @@ operation: preprocess description: source list null normalizes to exact empty placeholder source: items: - - A - - null - - B + - A + - null + - B expectedPreprocessed: items: - - value: A - - $empty: true - - value: B + - value: A + - $empty: true + - value: B diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_recursive_empty_object_list_to_empty.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_recursive_empty_object_list_to_empty.yaml new file mode 100644 index 0000000..2916f89 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_source_recursive_empty_object_list_to_empty.yaml @@ -0,0 +1,15 @@ +id: R_source_recursive_empty_object_list_to_empty +category: Resolution +operation: preprocess +description: source list element that recursively cleans to an empty object normalizes + to exact empty placeholder +source: + items: + - A + - x: null + - B +expectedPreprocessed: + items: + - value: A + - $empty: true + - value: B diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_top_level_type_name_description_not_inherited.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_top_level_type_name_description_not_inherited.yaml index 80e427d..56fccc7 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_top_level_type_name_description_not_inherited.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_top_level_type_name_description_not_inherited.yaml @@ -1,20 +1,21 @@ id: R_top_level_type_name_description_not_inherited category: Resolution operation: resolve -description: root name and description from an inline type are not inherited onto the instance root +description: root name and description from an inline type are not inherited onto + the instance root source: type: name: Base Type description: Base description - inherited: yes + inherited: true own: child expectedResolved: type: name: Base Type description: Base description inherited: - value: yes + value: true inherited: - value: yes + value: true own: value: child diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_type_aliases_removed_from_canonical_overlay.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_type_aliases_removed_from_canonical_overlay.yaml index 8df0e00..245891f 100644 --- a/src/test/resources/blue-language-1.0/fixtures/resolver/R_type_aliases_removed_from_canonical_overlay.yaml +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_type_aliases_removed_from_canonical_overlay.yaml @@ -6,10 +6,10 @@ source: blue: imports: Label: - blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC type: Label value: hello expectedCanonicalOverlay: type: - blueId: DLRQwz7MQeCrzjy9bohPNwtCxKEBbKaMK65KBrwjfG6K + blueId: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC value: hello diff --git a/src/test/resources/blue-language-1.0/fixtures/resolver/R_view_path_root_is_empty_string.yaml b/src/test/resources/blue-language-1.0/fixtures/resolver/R_view_path_root_is_empty_string.yaml new file mode 100644 index 0000000..c9d6946 --- /dev/null +++ b/src/test/resources/blue-language-1.0/fixtures/resolver/R_view_path_root_is_empty_string.yaml @@ -0,0 +1,25 @@ +id: R_view_path_root_is_empty_string +category: Resolution +operation: assertViewPath +description: Blue Language view paths use RFC 6901 root as the empty string; slash + selects an empty-key member +document: + "": empty-key + regular: + items: + - first + - second + a/b: + c~d: escaped +assertions: +- path: '' + expectedRoot: true +- path: / + expectedNode: + value: empty-key +- path: /regular/items/0 + expectedNode: + value: first +- path: /a~1b/c~0d + expectedNode: + value: escaped diff --git a/src/test/resources/contract/1.0/spec.md b/src/test/resources/contract/1.0/spec.md new file mode 100644 index 0000000..26e6a95 --- /dev/null +++ b/src/test/resources/contract/1.0/spec.md @@ -0,0 +1,3634 @@ +# Blue Contracts and Processor Specification 1.0 + +> **Positioning.** Blue contracts are Blue's form of **smart contracts**: deterministic, content-addressed runtime declarations attached to Blue documents. They react to events, invoke supported channel and handler implementations, and update document state only through the processor rules defined by this specification. Unlike blockchain-specific smart contracts, Blue contracts do not imply any particular consensus protocol, ledger, account model, token model, authorization system, network transport, or persistence layer. + +> **Scope.** This document defines Blue runtime contract processing: contracts, channels, handlers, markers, active scopes, embedded document processing, lifecycle events, JSON patch execution, update cascades, event FIFOs, embedded-event bridging, checkpoints, termination, gas accounting, and processor conformance. It does **not** define the Blue content language, BlueId, type resolution, schema, canonicalization, expansion, or minimization. Those are defined by the separate **Blue Language Specification 1.0**. + +Where this document references runtime types such as **Contract**, **Channel**, **Handler**, **Marker**, **Document Update Channel**, **Triggered Event Channel**, **Lifecycle Event Channel**, **Embedded Node Channel**, **Process Embedded**, **Channel Event Checkpoint**, **Type Generalization Policy**, **Type Generalization Rule**, and processor-emitted events, their canonical type definitions and canonical BlueIds are supplied by the canonical Blue runtime type registry. + +Appendix A defines the normative runtime semantics of those core runtime types and shows their intended registry source nodes. The canonical registry is the authority for exact node content and BlueIds. + +Canonical runtime type nodes are identity-bearing Blue content. Their `description` fields define runtime semantics and affect BlueId. Editing a canonical runtime description changes the runtime type identity and therefore must be treated as a registry/versioning change, not as ordinary documentation editing. + +## Conventions + +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, **MAY**, and **OPTIONAL** are to be interpreted as normative requirement levels. + +Sections marked **normative** define required behavior for conforming Blue Contracts and Processor 1.0 implementations. Sections marked **informative** explain intent, examples, or implementation guidance. + +The term **Blue Language** means Blue Language Specification 1.0 unless another version is explicitly named. + +--- + +## 0. Overview + +Blue contracts are smart contracts for Blue documents: they make a Blue document executable in a deterministic, content-addressed way. + +A processor is a deterministic state-transition function: + +```text +PROCESS(document, event) -> (new_doc, triggered_events, total_gas) +``` + +- **document** is a Blue processing document: a valid Blue document after Blue Language preprocessing, suitable for runtime interpretation. +- **event** is a Blue node delivered by an external feeder or by a calling environment. +- **new_doc** is the updated document after all processing performed by this invocation. +- **triggered_events** is the root-scope outbox for this invocation, including root-scope triggered events and root lifecycle events, whether or not they were handled locally. +- **total_gas** is a deterministic tally of abstract gas units consumed during the invocation. + +Contracts live under a node's `contracts` map. They are ordinary Blue content for identity purposes, but a Blue processor gives supported contract types runtime meaning. + +A processor run is organized around **active scopes**. The root document is always an active scope. Additional active scopes are declared by a scope's **Process Embedded** marker. Each active scope has its own local contracts, lifecycle, checkpoint, triggered-event FIFO, and termination state. + +Runtime execution follows this shape: + +```text +PROCESS(root, event) + -> process embedded child scopes first + -> initialize this scope if needed + -> match channels for the incoming event + -> run handlers in deterministic order + -> apply patches immediately + -> after every patch, deliver Document Update cascades bottom-up + -> bridge child emissions to the parent + -> drain this scope's Triggered FIFO exactly once + -> return updated document, root outbox, total gas +``` + +Several separations are fundamental: + +| Boundary | Meaning | +|---|---| +| **Language vs processor** | The Blue Language parses, resolves, canonicalizes, and hashes content. The processor executes supported contracts. | +| **Feeder vs processor** | The feeder collects and orders external events. The processor deterministically handles one delivered event. | +| **Scope vs embedded child** | A parent may add, replace, or remove an embedded child root, but may not patch inside the child's embedded domain. | +| **Effect buffering vs application** | Handlers and supported channels may request patches, emissions, gas, and termination during execution, but those requests are buffered and applied only through the normalized result order. | +| **Patch vs Direct Write** | Handler/channel patches cause Document Update cascades. Processor Direct Writes update reserved runtime state without cascades. | +| **Capability failure vs runtime fatal** | Unsupported contract capabilities detected before execution produce no mutation. Deterministic errors during a run terminate a scope. | + +This specification is intentionally deterministic. Contract execution MUST NOT depend on wall-clock time, CPU speed, random sources, network latency, operating-system scheduling, or hidden mutable state. + +--- + +## 1. Scope, Goals, Versioning, and Conformance + +### 1.1 Goal + +Blue Contracts and Processor 1.0 defines a deterministic processor model for Blue documents with: + +- scope-local contracts; +- deterministic channel and handler ordering; +- explicit, isolated document mutation through JSON-patch entries; +- immediate bottom-up Document Update cascades after every successful patch; +- per-scope Triggered FIFOs with exactly one drain per scope per invocation; +- embedded child scopes and parent-side event bridging; +- first-run initialization lifecycle; +- channel checkpoints for external-event idempotency; +- graceful and fatal termination semantics; +- deterministic gas accounting. + +### 1.2 Out of scope + +The following are not defined by this specification: + +- external event collection, consensus, scheduling, delivery guarantees, or retries; +- authorization, authentication, signatures, encryption, or access-control policy; +- network, storage, or provider protocols; +- user-interface semantics; +- contract programming languages or bytecode formats; +- non-deterministic operations such as timers, random numbers, ambient clocks, or network reads inside handlers; +- Blue Language content identity and BlueId algorithms. + +A profile MAY define contract languages, authorization, signing, or deployment protocols, but those profiles MUST preserve the deterministic observable behavior defined here. + +### 1.3 Versioning + +This document defines **Blue Contracts and Processor 1.0**. + +A runtime contract type is identified by its BlueId in the canonical Blue runtime type registry. A processor MUST declare which Blue Contracts and Processor version it implements and which external contract type BlueIds it supports. + +Blue Contracts 1.x revisions MUST preserve the observable behavior of valid Blue Contracts 1.0 documents. Any incompatible change to event ordering, patch semantics, termination semantics, gas formulas, or processor-managed type semantics requires a new major processor version. + +### 1.4 Conformance + +A conforming Blue Contracts and Processor 1.0 implementation MUST implement all normative requirements in this specification. + +A conforming processor MUST support: + +- root-scope processing; +- active embedded scopes declared by **Process Embedded**; +- all processor-managed channel families in §5; +- all required runtime markers in Appendix A; +- deterministic contract discovery and must-understand capability checks; +- deterministic sorting by `(order, key)`; +- patch application and Document Update cascades; +- post-patch type soundness validation and dynamic type generalization; +- Triggered FIFO behavior; +- embedded-event bridging; +- lifecycle delivery; +- checkpoint lazy creation and update for external channels; +- termination semantics; +- gas accounting formulas; +- the Blue Contracts 1.0 conformance suite. + +A tool that implements only a subset may be useful, but it MUST NOT describe itself as a conforming Blue Contracts and Processor 1.0 implementation. + +### 1.5 Runtime registry dependency + +The canonical Blue runtime type registry is part of the Blue Contracts 1.0 release surface. Its entries for processor-managed contracts and events are content-addressed and versioned with this specification. + +A conforming processor MUST use the registry BlueIds for runtime contract type recognition. A different registry binding does not produce portable Blue Contracts 1.0 behavior. + +Canonical runtime registry nodes are self-describing Blue content. + +A runtime registry node's `name` and `description` fields are identity-bearing content under the Blue Language. A canonical runtime registry entry SHOULD include a concise normative `description` that defines the semantics of the runtime type. Changing that semantic description changes the node's BlueId and therefore defines a different runtime type. + +Non-normative examples, rationale, translations, tutorial material, implementation notes, and editorial commentary MUST NOT be included in canonical runtime registry nodes unless intentionally made identity-bearing. Such material belongs in the prose specification, registry documentation, or examples outside the canonical node. + +The registry file is the authority for exact string content of canonical runtime nodes. Code blocks in this specification should be generated from, or kept equivalent to, the registry entries used to calculate the published BlueIds. + +The canonical runtime registry entry for each processor-managed type MUST include: + +- the exact registry source node; +- the exact preprocessed/canonical node used for BlueId calculation, or a deterministic rule for producing it; +- the node's calculated BlueId; +- the Blue Contracts and Processor version that publishes it; +- the conformance fixture package identity that verifies it. + +A conforming processor MUST verify, at release or test time, that every bundled runtime type node hashes to the published registry BlueId. + +Canonical runtime registry source nodes MUST be reproducible under one of these release-defined modes: + +1. all cross-references are exact `blueId` references in the registry source; or +2. the registry manifest defines the exact preprocessing environment used to replace both Blue Language core aliases and Blue runtime registry aliases. + +A runtime registry release MUST publish enough information for an independent implementation to calculate every runtime type BlueId from the registry source nodes. Implementations MUST NOT rely on implementation-local alias maps to reproduce runtime registry BlueIds. + +--- + +## 2. Runtime Document Model and Processing Inputs + +### 2.1 Processing Document (normative) + +The normative `PROCESS` function operates on a **Processing Document**: a Blue Language Preprocessed Document used as the mutable **Selected Document View**. The document MUST NOT contain the root `blue` preprocessing directive or unresolved authoring aliases. + +A Processing Document is not required to be a fully Resolved View before `PROCESS` begins. Contract entries are resolved on demand during contract discovery and execution, using the resolved contract views required by §2.5. + +A Blue document whose root is not an object is valid Blue content, but it is not a processable Blue Contracts document under this specification because the root active scope is an object scope. A conforming `PROCESS` implementation MUST reject such input as an invalid Processing Document before runtime begins, with no mutation, no lifecycle events, and zero gas. + +A higher-level API MAY accept Blue Source Documents and apply Blue Language preprocessing before invoking `PROCESS`. Such preprocessing is outside the runtime run: + +- it consumes no gas under this specification; +- it does not emit lifecycle events; +- it does not trigger Document Update cascades; +- it is not a handler/channel mutation. + +### 2.2 Event input (normative) + +The normative `PROCESS(document, event)` function receives a **Processing Event**: a Blue node after Blue Language preprocessing. It MUST NOT contain a root `blue` directive or unresolved authoring aliases. + +The input `event` is not wrapped in a processor envelope by this specification. + +The processor MUST treat the input event as read-only. External channels may adapt it into channelized payloads for handlers, but the original event node is the event stored in channel checkpoints unless a concrete channel type explicitly defines a different checkpoint subject. + +A higher-level API MAY accept Source-event syntax and preprocess it before calling `PROCESS`. This preprocessing is outside the runtime run, consumes no gas, and emits no lifecycle or Triggered events. + +### 2.3 Runtime views (normative) + +A processor may use different internal views of the same document: + +| View | Purpose | +|---|---| +| **Selected Document View** | The mutable document tree patched by runtime operations. | +| **Resolved Contract View** | The Blue Language resolved view of contract entries, used to identify supported contract types and effective fields. | +| **Snapshot View** | A read-only snapshot used in Document Update `before` and `after` payloads. | + +Only the selected document view is mutated. Contract resolution, provider expansion, and type-materialization are view operations unless explicitly represented by a patch or Direct Write. + +### 2.4 Scopes (normative) + +A **scope** is an absolute runtime pointer to an object node in the selected document. The root scope is `/`. + +An active scope is either: + +- the root scope `/`; or +- a child root declared by the nearest active ancestor's **Process Embedded** marker and processed by the algorithm in §7. + +Contracts are scope-local. A contract under one scope's `contracts` map is not inherited by parent scopes, child scopes, embedded scopes, or referenced nodes. + +A `contracts` map on a node that is not an active scope is ordinary Blue content and is not executed during this processor invocation. + +### 2.5 Contract discovery and type recognition (normative) + +When a processor is about to execute a scope, it MUST discover the scope's `contracts` map, if present, and perform **Contract Recognition Resolution** for each contract entry. + +Contract Recognition Resolution MUST resolve the contract entry's effective type chain far enough to identify: + +- the effective contract type BlueId; +- whether the effective type is a subtype of **Contract**, **Channel**, **Handler**, or **Marker**; +- processor-relevant effective fields such as `order`, `channel`, `event`, `path`, `childPath`, `paths`, `lastEvents`, `cause`, `reason`, and any fields required by the concrete supported contract type. + +Contract Recognition Resolution MUST use Blue Language provider verification for referenced type content. The resolved contract entry and all consulted effective fields MUST be valid under Blue Language resolution and schema rules. + +A processor MUST NOT resolve unrelated document subtrees merely for discovery, and MUST NOT execute contracts during discovery. + +If a supported concrete contract type requires additional fields to decide acceptance, matching, or processor behavior, those fields are part of that contract type's required recognition view. + +If a contract entry's type cannot be resolved because required provider content is unavailable or fails BlueId verification, the scope MUST enter fatal termination unless the failure is detected during the pre-execution capability check in §2.6. + +A contract entry whose effective type is not a subtype of **Contract** is inert content unless it appears under a processor-reserved key. If it appears under a processor-reserved key, it is incompatible and causes runtime fatal termination (§3.6, §11.2). + +### 2.5.1 Runtime Contract Discovery View (normative) + +Blue Contracts 1.0 discovers runtime contracts from the selected document's materialized scope-local `contracts` map only. + +A contract entry is runtime-discoverable only when it is present as a materialized entry under `JOIN_SCOPE_PATH(scope, "/contracts/")` in the Selected Document View at the point of discovery. Contract entries that would appear only by resolving the scope node's own type chain are Blue Language content, but they are not executed by the Blue Contracts 1.0 core runtime unless they have been materialized into the Selected Document View by preprocessing, by an explicit runtime patch, or by a profile that explicitly extends this rule. + +Once a materialized contract entry is discovered, the entry itself is resolved using Contract Recognition Resolution (§2.5) to determine its effective contract type BlueId and processor-relevant effective fields. + +Processor-managed runtime markers at reserved keys are always selected-document state. They MUST NOT be inherited from a scope type. If Contract Recognition Resolution would expose a type-derived reserved processor marker that is not materialized in the Selected Document View, the processor ignores it for runtime state. If such a marker is materialized under a non-reserved key, §3.6 duplicate/incorrect-key rules apply. + +### 2.6 Must-understand capability check (normative) + +Before mutating the document or delivering lifecycle events, a processor MUST perform a must-understand check for the contract entries that are in the initial active processing closure. + +The initial active processing closure consists of: + +1. the root scope; +2. embedded object scopes reachable by reading **Process Embedded** markers from existing scopes before any runtime patches have been applied. + +The initial active processing closure excludes: + +- missing embedded child paths; +- non-object child roots, which are invalid embedded scopes if selected during runtime traversal; +- scopes with a valid pre-existing **Processing Terminated Marker**, except that the terminated marker itself MUST be recognizable enough to prove the scope is inactive. + +Unsupported contracts inside a pre-existing terminated inactive scope do not cause must-understand failure because that scope is not active for this invocation. + +If any contract in that initial active processing closure has a contract type BlueId that the processor does not support, the processor MUST return a must-understand capability failure and MUST NOT: + +- mutate the document; +- create markers; +- deliver lifecycle events; +- emit triggered events; +- consume gas. + +If an unsupported contract type is introduced or discovered only after runtime mutation has begun, the processor MUST treat it as a deterministic runtime fatal at the scope where it is discovered. + +### 2.7 Provider requirements (normative) + +A processor MAY use a Blue Language provider to resolve contract types or compute scope Content BlueIds. Provider use MUST follow Blue Language provider verification rules. + +If required provider content is unavailable during processing, the affected scope MUST terminate fatally. If the failure is detected during the initial must-understand capability check, the result is a capability failure instead. + +### 2.8 Existing terminated markers (normative) + +If a scope contains a valid **Processing Terminated Marker** at `contracts/terminated` before `_PROCESS` begins for that scope, the scope is inactive. The processor MUST NOT initialize it, match channels, run handlers, bridge from it, or drain its FIFO during this invocation. + +Entering `_PROCESS` for an existing terminated scope still incurs the scope-entry charge, because the processor has entered and recognized the scope. No initialization, channel matching, lifecycle delivery, bridging, FIFO drain, or checkpoint work occurs for that inactive scope. + +A parent may replace or remove an embedded child root containing a terminated marker, subject to the boundary rules in §4. A parent replacing the child root may thereby install a fresh child scope for a later invocation. + +--- + +## 3. Contracts and Runtime Capabilities + +### 3.1 `contracts` map (normative) + +Every active scope MAY contain a `contracts` object: + +```yaml +contracts: + : +``` + +The map key is the contract's scope-local runtime key. It participates in deterministic ordering and handler binding. + +Contracts are Blue nodes and are identity-bearing content under the Blue Language. Runtime execution does not change that language fact. + +Contracts are ordinary mutable document content except for reserved processor keys. A handler may add, replace, or remove non-reserved contract entries subject to boundary rules, Blue Language validity, and must-understand discovery rules. Such mutations do not execute immediately merely because they were written; they affect only later contract-discovery points defined by this specification. + +### 3.1.1 Contract-map key grammar (normative) + +A contract-map key is the object member name under a scope's `contracts` map. For Blue Contracts 1.0, a contract-map key MUST: + +- be a non-empty Text string; +- be representable as a Blue ordinary child-field key; +- not equal any Blue Language reserved key; +- not equal any Blue Language reserved-invalid key; +- not contain an empty runtime-pointer segment when escaped and used in a runtime pointer; +- be addressable by a Blue Runtime Pointer after RFC 6901 segment escaping. + +Keys may contain `/` or `~`; those characters are escaped only when constructing runtime pointers. The stored object key remains the raw key string. + +Invalid contract-map keys are deterministic runtime fatals when discovered in an active scope, or capability failures when detected during the initial must-understand check. + +### 3.2 Contract roles (normative) + +A contract entry MUST have one of these runtime roles, determined by its effective type: + +| Role | Meaning | +|---|---| +| **Channel** | Event entry point. It decides whether an event is accepted at a scope and may adapt it into a channelized payload. | +| **Handler** | Deterministic logic bound to exactly one channel key in the same scope. | +| **Marker** | Informational state or policy. Markers do not run contract logic, but the processor obeys supported marker semantics. | + +A concrete contract type MAY be external to this specification. If a processor claims to support it, it MUST implement that type's deterministic semantics exactly. + +Blue Contracts 1.0 core recognizes only Channel, Handler, and Marker roles. A contract whose effective type is a subtype of Contract but not a subtype of one of these roles is an extension-role contract. If the processor does not declare support for that exact extension role type BlueId, the contract is unsupported and subject to must-understand/fatal rules. Extension roles MUST NOT be treated as inert merely because they are not Channel, Handler, or Marker. + +### 3.3 Channels (normative) + +A channel evaluates an incoming event in a scope and produces either: + +- no delivery; or +- one channelized delivery payload for handlers bound to that channel. + +A channel MAY: + +- accept or reject events according to its type semantics; +- adapt or reshape an accepted event into a channelized payload; +- read and, where allowed by this specification, cause processor updates to the scope's **Channel Event Checkpoint**; +- call `consumeGas(units: Integer)` through the processor interface; +- invoke `terminate(cause, reason?)`. + +A channel MUST NOT directly mutate the selected document. The only processor state a channel can affect is through permitted processor operations specified here. + +Processor-managed channels are fed only by the processor. External events MUST NOT directly enter **Document Update**, **Triggered Event**, **Lifecycle Event**, or **Embedded Node** channels. + +### 3.4 Handlers (normative) + +A handler is bound to exactly one channel in the same scope by its `channel` field, whose value is the channel's contract-map key. + +A handler MAY: + +- request document changes by returning a list of **Json Patch Entry** objects; +- emit Blue event nodes; +- call `consumeGas(units: Integer)`; +- invoke `terminate(cause, reason?)`. + +No other side effects are permitted. + +A handler MUST be deterministic. Given the same document snapshot, channelized payload, contract content, and allowed context, it MUST return the same result. + +### 3.4.1 Contract execution context (normative) + +A handler/channel execution context exposes at most: + +- executing scope pointer; +- current selected document view, read-only; +- channelized payload, read-only; +- original `PROCESS` event, read-only; +- current contract entry resolved content, read-only; +- current channel entry resolved content, read-only when applicable; +- processor version; +- supported external contract type IDs; +- deterministic gas interface; +- effect-buffering methods for allowed result effects. + +It MUST NOT expose wall-clock time, randomness, network access, hidden mutable state, object identity, or host process state unless a supported extension explicitly defines deterministic semantics. + +### 3.5 Markers (normative) + +Markers carry runtime state or policy. They do not run logic. + +Processor-managed marker slots are: + +- **Process Embedded** at `contracts/embedded`; +- **Type Generalization Policy** at `contracts/generalization`, when present; +- **Processing Initialized Marker** at `contracts/initialized`; +- **Processing Terminated Marker** at `contracts/terminated`; +- **Channel Event Checkpoint** at `contracts/checkpoint`. + +A processor MAY support additional marker types. Unsupported marker types in an active scope are subject to must-understand rules because markers are contract types. + +### 3.6 Reserved processor keys (normative) + +The following keys are reserved under a scope's `contracts` map: + +| Key | Required type | +|---|---| +| `embedded` | Process Embedded | +| `generalization` | Type Generalization Policy | +| `initialized` | Processing Initialized Marker | +| `terminated` | Processing Terminated Marker | +| `checkpoint` | Channel Event Checkpoint | + +If any reserved key exists with an incompatible type or invalid shape, the scope MUST terminate fatally, except when detected during the initial must-understand check, in which case the processor returns capability failure with no mutation. + +Each processor-managed marker type listed above MUST appear at most once per scope and only at its reserved key. A marker of one of these types under any key other than its reserved key is a deterministic runtime fatal. + +### 3.7 Reserved-key write protection (normative) + +Handlers and channels MUST NOT patch any reserved key path or its descendants: + +```text +/.../contracts/embedded +/.../contracts/generalization +/.../contracts/initialized +/.../contracts/terminated +/.../contracts/checkpoint +``` + +Attempting to `add`, `replace`, or `remove` such a path is a deterministic runtime fatal at the executing scope. + +Exception: a handler or channel executing in scope `S` MAY patch `JOIN_SCOPE_PATH(S, "/contracts/embedded/paths")` and its list elements, provided the resulting `contracts/embedded` marker remains a valid Process Embedded marker. The patch MUST NOT replace or remove `contracts/embedded` as a whole, MUST NOT change `contracts/embedded/type`, and MUST NOT write any other field under `contracts/embedded` unless this specification explicitly defines it. + +This exception exists because Process Embedded `paths` is a scope-local processing policy, not a processor-generated lifecycle/checkpoint state. It is what makes dynamic embedded traversal and the no-resurrection rule observable. + +Patches to `contracts/generalization`, `contracts/initialized`, `contracts/terminated`, and `contracts/checkpoint` remain forbidden to handlers and channels. + +Processor writes to reserved keys are permitted only as specified in this document. + +A handler or channel patch MUST NOT target the executing scope's `contracts` map as a whole if the effect would add, replace, remove, or change any reserved processor key or reserved-key descendant in that same scope. In particular, a patch at `JOIN_SCOPE_PATH(scope, "/contracts")` is a deterministic runtime fatal unless every existing reserved processor key and reserved-key descendant in that scope is preserved as the same selected-document Blue node after the Blue Language node normalization required for selected-document insertion and canonical comparison. + +For this rule, equality is semantic Blue-node equality of the selected-document subtree, not source serialization byte equality. Implementations MUST NOT compare YAML or JSON source bytes. + +The ancestor-write exemption applies only when the patch target is a declared embedded child root being replaced or removed as a whole by its parent. In that case, reserved processor keys inside the replaced child subtree are child-scope state and the operation is governed by the embedded boundary rules in §4. + +### 3.8 Read-only inputs (normative) + +Contracts MUST treat delivered event objects, document snapshots, and context objects as read-only. + +All document changes MUST occur only through explicit **Json Patch Entry** operations returned to the processor. + +A processor MAY enforce read-only inputs by cloning, freezing, capability-safe references, or contract sandboxing. Observable behavior MUST be as if contracts cannot mutate delivered payload objects. + +### 3.9 Deterministic ordering (normative) + +Whenever multiple channels or handlers are eligible at a scope, the processor MUST sort them by: + +1. effective `order` value, ascending; missing `order` is `0`; +2. contract-map key, lexicographic by Unicode code point. + +This ordering applies to: + +- external channel matching; +- Document Update channels; +- Triggered Event channel handlers; +- Lifecycle Event channels; +- Embedded Node channels; +- handlers within any channel. + +### 3.9.1 Dispatch snapshots (normative) + +For a single channel delivery, the processor determines the eligible handler list once, immediately before the first handler for that delivery is invoked. The list contains handler keys and resolved handler recognition views in `(order, key)` order. + +The dispatch snapshot includes the resolved executable contract content needed to execute each snapshotted handler or channel under its concrete runtime. If a prior handler mutates, replaces, or removes a later handler's or channel's contract entry during the same delivery or Phase 3 candidate loop, the later snapshotted contract still executes using its snapshotted resolved contract content. The ordinary selected document view supplied as document context remains the current post-mutation selected document at the time of execution. + +A dispatch snapshot freezes what contract is being called; it does not freeze ordinary document state read by that contract unless the concrete contract runtime defines a read snapshot. + +Mutations to `contracts` during that delivery do not add, remove, reorder, or alter handlers already snapshotted for that delivery. Such mutations affect only later contract-discovery points. + +External channel candidates for Phase 3 are snapshotted once at the beginning of Phase 3 for that scope. The snapshot contains candidate channel keys and resolved channel recognition views in `(order, key)` order. Mutations during Phase 3 do not add, remove, reorder, or alter candidates in the current Phase 3 loop, but later phases and later invocations observe the mutated selected document. + +Processor-managed channel discovery for each Document Update, Triggered, Lifecycle, or Embedded Node delivery is performed immediately before that delivery's channel routing begins and is then snapshotted for that delivery. + +For Triggered FIFO processing, processor-managed Triggered Event Channel discovery is performed separately for each dequeued FIFO event, immediately before routing that event. + +For Embedded Node bridging, Embedded Node Channel discovery is performed separately for each recorded child emission, immediately before routing that emission to the parent. + +For Document Update, discovery is performed separately for each Document Update payload created by each successful patch, generated generalization write, or processor-managed patch. + +For Lifecycle, discovery is performed separately for each lifecycle event. + +A scope termination or cut-off still stops remaining work even if the handler or channel was present in a dispatch snapshot. + +### 3.10 Same-scope binding (normative) + +Handlers MUST only bind to channels in the same scope. A handler whose `channel` field names no channel in the same scope is inert unless a profile declares it invalid. A handler MUST NOT bind to a parent, child, embedded, or referenced node's channel. + +A handler is eligible only for channelized deliveries produced by the channel it names. + +### 3.11 Contract result application order (normative) + +Handlers and supported channels may request effects during execution. The processor captures those requests into an effect buffer. The effects do not mutate the selected document, enqueue events, or terminate the scope until the processor applies the normalized result through `APPLY_CONTRACT_RESULT`. + +When a handler returns a result, or when a concrete supported channel type explicitly permits a channel result, the processor applies it in this order: + +1. add explicit gas consumed to `RUN.total_gas`; +2. apply patches in result order, each with immediate cascades and post-patch soundness validation; +3. record and enqueue emitted Triggered events in result order; +4. apply requested termination, if any. + +A host-language API may expose methods such as `emitEvent`, `applyPatch`, or `terminate`, but in Blue Contracts 1.0 core these calls are effect-buffering requests, not immediate side effects. + +External channel evaluation results MUST NOT contain handler-only effects such as document patches or Triggered events unless a concrete supported channel type explicitly extends the channel capability surface. In Blue Contracts 1.0 core, patches and Triggered emissions are handler effects. If a core external channel returns patches or Triggered events, the evaluating scope MUST terminate fatally. + +If a fatal error occurs while applying a result, remaining unapplied effects from that result are discarded after the currently failing operation completes its termination handling. + +### 3.11.1 Contract result normalization (normative) + +Before applying a contract result, the processor normalizes absent optional result fields as follows: + +- absent `gasConsumed` is `0`; +- absent `patches` is `[]`; +- absent `triggeredEvents` is `[]`; +- absent `termination` is `null`. + +If a present result field has an invalid shape, the executing scope MUST terminate fatally before any effects from that result are applied, except that handler/channel overhead already charged remains charged. + +For Blue Contracts 1.0 core external channels, result normalization does not grant handler-only effects. A normalized external-channel result containing non-empty `patches` or `triggeredEvents` remains fatal unless a supported profile explicitly extends channel capabilities. + +--- + +## 4. Active Scopes, Embedded Documents, and Isolation + +### 4.1 Process Embedded marker (normative) + +A **Process Embedded** marker under `contracts/embedded` declares embedded child scopes beneath the current scope: + +```yaml +contracts: + embedded: + type: Process Embedded + paths: + - /payment + - /shipping +``` + +Each path is a scope-relative absolute runtime pointer resolved against the current scope by `ABS(scope, path)` (§6.3). + +The processor reads this list dynamically during Phase 1 of `_PROCESS` (§7.3). + +### 4.2 Dynamic traversal (normative) + +When processing embedded children of a scope, the processor MUST: + +1. read the current effective `paths` list; +2. select the first path in list order that has not already been processed in this parent invocation; +3. process the child if its node exists; +4. mark the path as processed whether or not the node existed; +5. re-read `paths` before choosing the next child. + +Additions, removals, and reorderings of `paths` take effect for the next child selection. + +Once a child path has entered the parent invocation's `processed_paths` set, it MUST NOT be processed again in the same invocation, even if removed and re-added. This is the **no resurrection** rule. + +### 4.3 Embedded path validity (normative) + +A path in **Process Embedded** `paths` MUST: + +- be a valid runtime pointer beginning with `/`; +- not be `/`, because a scope cannot embed itself; +- resolve to an absolute pointer location within the current scope's pointer domain; +- be unique within the list. + +A malformed embedded path is a deterministic runtime fatal at the scope that declares it. + +If a valid embedded path does not exist in the selected document when selected for traversal, it is skipped and marked processed for this invocation. + +If a selected embedded path exists but its root node is not an object, it is not a valid embedded scope and causes deterministic runtime fatal termination at the declaring scope. + +### 4.4 Single selected document view (normative) + +All scopes patch the same selected document view. + +An embedded child patches its subtree in place. Parent and ancestor scopes observe those changes through Document Update cascades and subsequent reads. + +Referenced nodes remain compact unless a Blue Language expansion operation materializes them as part of a view operation. Expansion is not a runtime patch unless performed through an explicit patch. + +### 4.5 Boundary rule (normative) + +Let the executing scope be absolute pointer `S`. Let `E` be the set of embedded child root pointers declared by `S`'s current **Process Embedded** marker, resolved to absolute pointers. + +A patch issued while executing in scope `S` is permitted only if: + +1. `STRICTLY_INSIDE(patch.path, S)`, or, for a parent patch, `patch.path` is equal to a declared embedded child root; +2. `DESCENDANT_OR_EQUAL(patch.path, S)`; and +3. `STRICTLY_INSIDE(patch.path, X)` is false for every embedded child root `X` in `E`. + +Consequences: + +- A parent MAY add, replace, or remove an embedded child root as a whole. +- A parent MUST NOT patch inside an embedded child root. +- A child MAY patch strict descendants inside its own subtree. +- A child MUST NOT add, replace, or remove its own scope root. +- No contract at any scope may patch the document root `/`. + +Violations are deterministic runtime fatals at the executing scope. + +### 4.6 Self-root mutation forbidden (normative) + +While executing in scope `S`, a handler or channel MUST NOT target exactly `S` with `add`, `replace`, or `remove`. + +Only an ancestor may add, replace, or remove a child root. This prevents a scope from cutting or replacing the balloon that contains its own execution context. + +### 4.7 Root target forbidden (normative) + +No handler or channel may target the document root `/` with any patch operation. Replacing or removing the entire document is forbidden. + +A higher-level API MAY replace the entire document between invocations, but that is outside `PROCESS`. + +### 4.8 Balloon cut-off (normative) + +If an ancestor removes or replaces an active child scope root while that child is being processed, the child scope is cut off for the remainder of the current invocation. + +The currently executing channel or handler call is allowed to return. The processor completes the effect currently being applied, records any emissions already produced, and then performs no further work for that cut-off scope: + +- no additional handlers; +- no local FIFO drain; +- no further patches from that scope; +- no further emissions from that scope. + +Already recorded emissions remain in `RUN.emitted_by_scope[child]` and may be bridged to the parent if the parent has a matching **Embedded Node Channel**. + +Re-adding the same path later in the same parent invocation does not schedule it again because of the no-resurrection rule. + +--- + +## 5. Events and Processor-Managed Channels + +### 5.1 Event model (normative) + +Events are Blue nodes. They may be scalar, list, object, or pure reference nodes, subject to Blue Language validity. + +An event has no processor envelope unless a concrete channel type defines one as its event payload. + +Events delivered to contracts are read-only. + +A node passed to `emitEvent` MUST normalize successfully under `NORMALIZE_RUNTIME_NODE_FOR_INSERTION(node, event)` and be a valid Blue node for event delivery. If an emitted node is invalid under the Blue Language data model, the emitting scope MUST terminate fatally before the event is recorded, enqueued, bridged, or charged as a successful emission. + +Processor-emitted event instances MUST include a `type` field whose value is a pure reference to the canonical runtime event type BlueId. + +For example, a Document Update event instance has: + +```yaml +type: + blueId: +op: replace +path: /... +before: ... +after: ... +``` + +The same requirement applies to Document Processing Initiated, Document Processing Terminated, and Document Processing Fatal Error. + +### 5.2 Channelized payloads (normative) + +A **channelized payload** is the event object delivered by a channel to its handlers. + +For processor-managed channels, this specification defines the payload. For external channels, the channel type defines whether the payload is the original event, a projection of it, or a channel-specific wrapper. + +Handler event matching operates on the channelized payload, not on hidden processor state. + +Channelized payloads have the same immutability guarantees as input events, snapshots, and context objects. + +An external channel may return the original event, a newly constructed Blue node, a deterministic projection, or a wrapper, but any structure sharing MUST be unobservable to contracts. + +Portable external channel types SHOULD declare a payload type or payload schema BlueId. If omitted, payload shape is part of the concrete channel type's prose semantics and is not independently portable. + +If an accepted external channel delivery declares an effective `payloadType` or payload schema BlueId, the produced channelized payload MUST conform to that type or schema under Blue Language resolution rules. If the channel accepts but produces a non-conforming payload, the evaluating scope MUST terminate fatally. A channel MAY reject the event before producing a payload. + +### 5.3 Document Update Channel (normative) + +The processor MUST support **Document Update Channel**. + +A Document Update Channel is fed only by the processor after a successful patch. + +For each patch, the processor delivers one **Document Update** event per participating scope in the cascade, from origin scope to ancestors up to root. + +A Document Update Channel declares a scope-relative `path`. It matches when `DESCENDANT_OR_EQUAL(patch.path, ABS(scope, path))` is true. + +Payload fields: + +- `op`: `add`, `replace`, or `remove`; +- `path`: changed path relative to the receiving scope; +- `before`: snapshot at the changed path before the patch, or null if absent; +- `after`: snapshot at the changed path after the patch, or null for remove. + +All handlers at the same receiving scope for the same patch MUST see the same immutable payload object, except that handler-local context may differ. + +`before: null` and `after: null` in Document Update payloads are processor runtime absence sentinels. They are part of the delivered runtime payload. They indicate that the target did not exist before the patch or does not exist after a remove. + +Because Blue Language identity cleaning removes null object fields, processors MUST NOT rely on the BlueId of a Document Update event to preserve absence sentinels unless a concrete event type defines an identity-preserving wrapper. Handlers read these sentinels from the delivered payload before any BlueId cleaning step. + +A future version may replace these sentinels with explicit `beforePresent` and `afterPresent` booleans. Blue Contracts 1.0 uses null sentinels for delivery compatibility. + +### 5.4 Triggered Event Channel (normative) + +The processor MUST support **Triggered Event Channel**. + +Handlers emit Triggered events through `emitEvent(node)`. The processor records each emitted node under the emitting scope and enqueues it into that scope's persistent FIFO. + +A scope's Triggered FIFO is drained at most once per `_PROCESS` invocation for that scope, during Phase 5. It MUST NOT drain during Document Update cascades. + +If a scope has no Triggered Event Channel, emitted events are still recorded under that scope and may be bridged upward, but they are not locally delivered. + +### 5.5 Lifecycle Event Channel (normative) + +The processor MUST support **Lifecycle Event Channel**. + +Lifecycle events are processor-emitted nodes such as: + +- **Document Processing Initiated**; +- **Document Processing Terminated**. + +Lifecycle events are delivered at a scope through Lifecycle Event Channels in that scope. They are also recorded as bridgeable emissions for parent **Embedded Node Channel** handling. + +Lifecycle events themselves are not enqueued into the scope's Triggered FIFO. Lifecycle handlers may emit Triggered events, and those emitted events are enqueued normally. + +At root, lifecycle events recorded through `RECORD_BRIDGEABLE` are appended to the run's `triggered_events` outbox. + +### 5.6 Embedded Node Channel (normative) + +The processor MUST support **Embedded Node Channel**. + +An Embedded Node Channel in a parent scope bridges emissions from a processed child scope after the child finishes and after the parent handles the external event, but before the parent drains its Triggered FIFO. + +A channel declares `childPath`. It matches child emissions from the processed child whose path equals `ABS(parentScope, childPath)`. + +Bridgeable child emissions include: + +- Triggered events emitted by the child; +- lifecycle events recorded by the child. + +The child emissions are delivered to the parent's Embedded Node Channel handlers in the order they were recorded by the child. They are not automatically enqueued into the parent's Triggered FIFO; parent handlers may emit events if forwarding is desired. + +### 5.7 Processor-managed channels are not checkpoint-gated (normative) + +Document Update, Triggered Event, Lifecycle Event, and Embedded Node channels are never subject to Channel Event Checkpoint gating. + +Only external channels are checkpoint-gated (§10). + +--- + +## 6. Runtime Pointers and JSON Patch Semantics + +### 6.1 Blue Runtime Pointer (normative) + +This specification uses **Blue Runtime Pointer** strings for patch paths, channel paths, and scope paths. + +A Blue Runtime Pointer is a deterministic JSON-pointer-compatible path with these conventions: + +- `/` denotes the root of the current pointer domain; +- child pointers begin with `/` followed by one or more escaped path segments; +- the empty string is not a valid runtime pointer; +- segment escaping follows RFC 6901: `~0` represents `~`, and `~1` represents `/`; +- unescaped `/` separates path segments; +- the array append token `-` is valid only as a patch target segment where this specification permits it. + +Because `/` is the root pointer in this specification, a direct object key equal to the empty string is not addressable by Blue Runtime Pointer. Applications needing such keys must use an application-level escaped representation. + +Blue Runtime Pointers are not identical to Blue Language view paths. In Blue Contracts 1.0, `/` denotes the runtime document root and is not a patch target. In Blue Language view paths, the empty string `""` denotes the root under RFC 6901 semantics. Implementers MUST NOT reuse one parser for the other without an explicit mode. + +### 6.2 Pointer normalization (normative) + +Processors MUST normalize pointers before comparison: + +- no trailing slash except `/` itself; +- valid escape sequences only; +- no empty segments; +- no `.` or `..` path semantics; +- no percent-encoding or URI-fragment decoding unless performed by an external envelope before runtime. + +Malformed pointers are deterministic runtime fatals when used by a contract or marker. + +### 6.3 Helper functions (normative) + +`ABS(S, P)` is the absolute document pointer for a scope-relative pointer `P` declared at scope `S`. + +Examples: + +```text +ABS("/", "/a") = "/a" +ABS("/order", "/id") = "/order/id" +ABS("/order", "/") = "/order" +``` + +`JOIN_SCOPE_PATH(S, P)` is equivalent to `ABS(S, P)`, where `P` is a scope-relative runtime pointer beginning with `/`. Implementations MUST NOT construct runtime pointers by raw string concatenation, because root scope `/` would otherwise produce double slashes. + +`DESCENDANT_OR_EQUAL(A, B)` is true when normalized pointer `A` equals normalized pointer `B`, or when `B` is an ancestor of `A` by complete path segments. Implementations MUST NOT use raw string prefix tests; for example `/ab` is not inside `/a`. + +`STRICTLY_INSIDE(A, B)` is true when `DESCENDANT_OR_EQUAL(A, B)` and `A != B`. + +`escape_pointer_segment(text)` returns one RFC 6901-escaped runtime pointer segment. + +`relativize_pointer(S, A)` returns a pointer relative to scope `S` for an absolute pointer `A`. It returns `/` when `A == S`. + +Examples: + +```text +relativize_pointer("/", "/a/b") = "/a/b" +relativize_pointer("/a", "/a/b") = "/b" +relativize_pointer("/a/b", "/a/b") = "/" +``` + +`relativize_snapshot(S, node)` returns an immutable subtree snapshot as observed at scope `S`. + +### 6.4 Json Patch Entry validation (normative) + +Handlers return **Json Patch Entry** objects. A runtime patch entry MUST have this effective shape: + +```yaml +op: add | replace | remove +path: +val: # required for add/replace; absent for remove +``` + +Rules: + +- `op` and `path` are required. +- `op` MUST be one of `add`, `replace`, or `remove`. +- `path` MUST be an absolute Blue Runtime Pointer. +- `path` MUST NOT be `/`. +- `val` is required for `add` and `replace`. +- `val` MUST be absent for `remove`. +- Other RFC 6902 operations such as `move`, `copy`, and `test` are unsupported and cause deterministic runtime fatal termination. + +A malformed patch entry is a deterministic runtime fatal at the executing scope. + +Despite the historical name **Json Patch Entry**, this is not full RFC 6902. It uses RFC 6901-compatible runtime pointers but supports only `add`, `replace`, and `remove`, with Blue-specific upsert and auto-materialization rules. The canonical runtime type name remains `Json Patch Entry` for Blue Contracts 1.0 registry stability. + +### 6.4.1 Runtime node insertion normalization (normative) + +`NORMALIZE_RUNTIME_NODE_FOR_INSERTION(node, context)` converts a patch `val`, emitted event, checkpoint subject, or processor-created runtime node into the selected-document form used by `PROCESS`. + +The algorithm: + +1. rejects a root `blue` directive; +2. rejects unresolved authoring aliases unless a higher-level API has already preprocessed them outside runtime; +3. applies Blue Language wrapper normalization; +4. applies primitive scalar inference for bare scalars; +5. applies Source-list placeholder normalization for list elements, including recursive empty-object normalization; +6. validates reserved field shapes and payload-kind exclusivity; +7. rejects invalid Blue Language nodes. + +This algorithm does not perform full Blue Language resolution unless resolution is required for subsequent contract discovery, type soundness validation, event identity, checkpoint-subject identity, or Content BlueId calculation. + +### 6.5 Patch application order (normative) + +Patches returned by one handler are applied immediately in list order. Each successful patch triggers its full Document Update cascade before the next patch is applied. + +Patches resolve against the current selected document state after all prior patches, cascades, and Direct Writes in the same run. + +### 6.6 Object targets (normative) + +For object containers: + +- `add` inserts a new member or replaces an existing member; +- `replace` behaves as upsert; +- `remove` deletes an existing member; +- removing a non-existent member is a deterministic runtime fatal. + +Missing intermediate object containers are auto-materialized as empty objects when applying `add` or `replace`. Auto-created containers are part of the same patch operation; the Document Update event describes the final requested path, not each intermediate container. + +If an existing intermediate value is not an object when an object container is required, the patch is a deterministic runtime fatal. + +### 6.7 Array targets (normative) + +For array containers: + +- path segments used against arrays MUST be canonical non-negative decimal indices with no leading zeros, except the single digit `0`, or `-`; +- `add /items/- val` appends; +- `add /items/i val` inserts at index `i`, where `0 <= i <= length`; +- `replace /items/i val` overwrites an existing element, where `0 <= i < length`; +- `remove /items/i` deletes an existing element and shifts later elements left; +- `-` is invalid for `replace` and `remove`; +- `/items/01` is malformed for array addressing; +- out-of-range array indices are deterministic runtime fatals. + +The processor MUST NOT auto-materialize arrays. If an intermediate array is missing, a patch may create an object member containing an array as its `val`, but it cannot infer an array solely from a numeric path segment. + +### 6.8 Snapshots (normative) + +For every successful patch, the processor captures: + +- `before`: the snapshot at `patch.path` before mutation, or null if the target did not exist; +- `after`: the snapshot at `patch.path` after mutation, or null for `remove`. + +Snapshots delivered to handlers are immutable. A processor MAY clone, freeze, or use immutable persistent data structures. + +### 6.9 Patch validity and Blue Language validity (normative) + +A patch `val` MUST normalize successfully under `NORMALIZE_RUNTIME_NODE_FOR_INSERTION(val, patchValue)` before insertion into the selected document. If applying a patch would make the selected document invalid under the Blue Language data model, the patch is a deterministic runtime fatal. + +This specification does not require the processor to re-resolve the entire Blue Language document after every patch unless resolution is needed for post-patch type soundness validation, subsequent contract discovery, contract execution, event identity, checkpoint-subject identity, or Content BlueId calculation. + +### 6.10 Post-patch type soundness and dynamic generalization (normative) + +Every successful handler/channel patch and every processor-managed patch MUST leave the selected document as a valid, type-sound Blue document before any Document Update cascade for that patch is delivered. + +A processor MUST NOT expose a transient state that violates the effective type or schema constraints of the selected document. + +After applying a patch to a tentative copy of the selected document, the processor MUST run `RESTORE_TYPE_SOUNDNESS` for the affected path. The processor MAY implement this incrementally, but the observable result MUST be as if the affected subtree and all relevant ancestors were rechecked under Blue Language resolution and subtype rules. + +If type soundness can be restored by deterministic dynamic type generalization allowed by the effective generalization policy, the processor commits the patch and generated generalization writes atomically. If type soundness cannot be restored, the patch is a deterministic runtime fatal and the tentative patch is not committed. + +### 6.10.1 Dynamic type generalization (normative) + +Dynamic type generalization is the processor's deterministic repair mechanism for a patch that makes a node no longer conform to its current declared type but still conform to an ancestor type in that type's chain. + +Given a node `N` with current effective type `T`, the processor may generalize `N` by replacing its selected-document `type` with the nearest ancestor type `A` of `T` such that: + +1. `N` conforms to `A` under Blue Language resolution and schema rules; +2. `A` is permitted by the effective Type Generalization Policy; +3. replacing `T` with `A` does not violate an embedded-scope boundary rule; +4. all child and parent constraints remain type-sound after propagation. + +Generalization is unidirectional. A processor MUST NOT specialize a node to a more specific type as a result of a patch unless that specialization was explicitly requested by the patch and validates normally. + +The processor MUST choose the nearest valid permitted ancestor type. If no such ancestor exists, the patch fails with `GeneralizationNoValidType` or `GeneralizationRejected`. + +### 6.10.2 `RESTORE_TYPE_SOUNDNESS` algorithm (normative) + +For a patch whose requested path is `P`, the affected closure is: + +1. the node directly changed by the patch; +2. each ancestor node up to the executing scope root; +3. if the executing scope is the document root, ancestors continue to the document root, which is the same node; +4. if the patch was issued by an ancestor against a declared embedded child root as a whole, the affected closure includes that child root and the executing ancestor path as allowed by §4.5. + +A patch executing inside an embedded child scope MUST NOT generalize ancestor scopes outside that embedded scope. If the child change would require ancestor-scope generalization to restore global type soundness, the patch is a runtime fatal unless the ancestor itself issued the patch or a future profile explicitly permits cross-scope generalization. + +Algorithm: + +```text +function RESTORE_TYPE_SOUNDNESS(document, executingScope, changedPath): + candidate = tentative patched document + writes = [] + + for nodePath from deepest affected node upward to executingScope: + result = CHECK_NODE_CONFORMS(candidate, nodePath, currentType(nodePath)) + CHARGE_TYPE_SOUNDNESS_CHECK(nodePath) + if result conforms: + continue + + gen = NEAREST_VALID_GENERALIZATION(candidate, nodePath, policy(nodePath)) + if gen none: + fail + + replace nodePath/type with canonical reference to gen.type + append generated write nodePath/type to writes + + repeat upward validation until no new generalization writes are required + return candidate, writes +``` + +A processor MAY optimize this algorithm, but must produce the same selected document, generated writes, Document Update ordering, gas, and fatal behavior. + +### 6.10.3 Type Generalization Policy marker (normative) + +A scope MAY contain a Type Generalization Policy marker at `contracts/generalization`. If absent, the effective default is: + +```yaml +defaultMode: nearest-valid +rules: [] +``` + +The policy controls processor-generated type generalization in that scope. + +Fields: + +- `defaultMode`: `nearest-valid` or `reject`. Missing means `nearest-valid`. +- `rules`: optional List of rules. + +Each rule has: + +- `path`: scope-relative runtime pointer identifying the subtree governed by the rule; +- `mode`: `nearest-valid` or `reject`; +- `mustRemainSubtypeOf`: optional type reference. If present, any generalized type at that path MUST be equal to or a subtype of this type. + +Rule selection: + +- Normalize each rule path with `ABS(scope, rule.path)`. +- The most specific matching rule applies, where specificity is longest normalized path by complete segments. +- If two rules have the same normalized path, the later rule in list order wins. + +`mode: reject` means a patch that would require generalization at the governed path fails instead of generalizing. + +`contracts/generalization` is processor-managed. Handlers and channels MUST NOT patch it or its descendants unless a future profile explicitly allows policy mutation. It is read during post-patch soundness validation. + +### 6.10.4 Generalization writes, cascades, and gas (normative) + +A generated type generalization write is a processor-managed companion write to `/type`. It is not a handler/channel patch and does not pay boundary check gas. It is still an observable selected-document mutation. + +A patch and its generated generalization writes are committed atomically. If any required generalization fails, neither the requested patch nor any generated write is committed. + +After a successful commit, Document Update cascades are delivered in this order: + +1. the original requested patch path; +2. generated generalization writes in deepest-to-root order. + +Each generated type write produces its own Document Update cascade. Triggered FIFO is not drained until all cascades for the original patch and all generated generalization writes have completed. + +For gas: + +- post-patch type-soundness validation costs `5` gas per checked node; +- each generated generalization write costs the same as a processor-managed `replace` patch for the new `type` value, without handler/channel boundary check gas; +- each generated write's Document Update cascade charges cascade gas normally for participating scopes. + +### 6.10.5 Generalization examples (informative) + +Price example: + +```yaml +# Before +price: + type: { blueId: } + amount: 150 + currency: EUR + +# Patch +- op: replace + path: /price/currency + val: USD + +# After generalization +price: + type: { blueId: } + amount: 150 + currency: USD +``` + +Parent propagation: + +```text +If the root type European Product requires price: Price in EUR, and /price +generalizes to Price, the root must generalize to the nearest valid parent +type, such as Global Product, when policy permits it. +``` + +Policy floor: + +```yaml +contracts: + generalization: + type: Type Generalization Policy + rules: + - path: / + mode: nearest-valid + mustRemainSubtypeOf: { blueId: } +``` + +This allows generalization from `EU Bank Transfer PayNote` to `Bank Transfer PayNote`, but forbids generalization to plain `PayNote`. + +--- + +## 7. PROCESS Algorithm + +### 7.1 Run state (normative) + +A processor invocation maintains deterministic run state: + +```text +RUN.root_events = [] # returned triggered_events outbox +RUN.total_gas = 0 +RUN.emitted_by_scope = {} # scope -> recorded bridgeable nodes +RUN.fifo_by_scope = {} # scope -> FIFO of Triggered events +RUN.terminating_scopes = {} # scope -> true while termination is in progress +RUN.terminated_scopes = {} # scope -> true for current-run termination +RUN.cut_off_scopes = {} # scope -> true when removed/replaced by ancestor +RUN.stop_lifecycle_delivery = {} # scope -> true after fatal during termination lifecycle +RUN.root_fatal_error_appended = false +``` + +`RUN.emitted_by_scope[scope]` contains Triggered events and lifecycle events recorded at that scope. + +`RUN.fifo_by_scope[scope]` contains only Triggered events emitted at that scope. + +### 7.2 Top-level wrapper (normative) + +The top-level processor algorithm is: + +```text +function PROCESS(document, event): + assert document is a Processing Document + assert event is a Blue node + + RUN = new run state + + capability = CHECK_MUST_UNDERSTAND(document, root="/", event) + if capability fails: + return capability failure with unchanged document, no triggered_events, total_gas = 0 + + try: + document = _PROCESS(document, event, scope="/") + return (document, RUN.root_events, RUN.total_gas) + catch ROOT_GRACEFUL_TERMINATION: + return (document, RUN.root_events, RUN.total_gas) + catch ROOT_FATAL_TERMINATION: + return (document, RUN.root_events, RUN.total_gas) +``` + +A conforming API MAY represent capability failure as an error object or exception rather than the three-value success tuple. In all cases the observable requirements are no mutation, no events, and zero gas. + +### 7.3 Core `_PROCESS` routine (normative) + +```text +function _PROCESS(document, event, scope): + CHARGE_SCOPE_ENTRY(scope) + + if scope does not exist: + return document + + if has_existing_terminated_marker(document, scope): + return document + + VALIDATE_SCOPE_CONTRACTS_OR_FATAL(document, scope) + + scope_bucket = ensure_bucket(RUN.emitted_by_scope, scope) + scope_fifo = ensure_fifo(RUN.fifo_by_scope, scope) + + # PHASE 1 — Process embedded children dynamically + processed_paths = insertion_ordered_set() + loop: + paths = read_process_embedded_paths(document, scope) + next_rel = first path in paths where ABS(scope, path) not in processed_paths + if next_rel is None: + break + + child_scope = ABS(scope, next_rel) + processed_paths.add(child_scope) + + if node_exists(document, child_scope) and not is_object_node(document, child_scope): + document = ENTER_FATAL_TERMINATION(document, scope, "Embedded scope root is not an object: " + child_scope) + elif node_exists(document, child_scope): + document = _PROCESS(document, event, child_scope) + + if INACTIVE(scope): + break + + # Re-read paths after each child. No resurrection because processed_paths is retained. + + if INACTIVE(scope): + return document + + # PHASE 2 — Initialize this scope on first run + if not has_initialized_marker(document, scope): + document = INITIALIZE_SCOPE(document, scope) + + if INACTIVE(scope): + return document + + # PHASE 3 — Evaluate external channel candidates for the incoming event + external_channels = snapshot_sorted_external_channel_candidates(document, scope) + for ch in external_channels: + if INACTIVE(scope): + break + + delivery = EVALUATE_EXTERNAL_CHANNEL(document, scope, ch, event) + if INACTIVE(scope): + break + if delivery.rejected: + continue + + document = ENSURE_CHECKPOINT_FOR_ACCEPTED_DELIVERY(document, scope) + + if not CHECKPOINT_ALLOWS(document, scope, ch.key, event, delivery): + continue + + document = RUN_HANDLERS_FOR_DELIVERY(document, scope, ch, delivery.payload) + + if not INACTIVE(scope): + document = DIRECT_WRITE_CHECKPOINT_UPDATE(document, scope, ch, event, delivery) + + if INACTIVE(scope): + return document + + # PHASE 4 — Bridge processed child emissions into this scope + for child_scope in processed_paths in insertion order: + if not node_was_processed_or_attempted(child_scope): + continue + child_events = RUN.emitted_by_scope.get(child_scope, []) + if child_events is empty: + continue + + for ev in child_events in recorded order: + if INACTIVE(scope): + break + embedded_channels = snapshot_embedded_channels_now(document, scope, child_scope, ev) + if embedded_channels is empty: + continue + CHARGE_BRIDGE_CHILD_EMISSION(child_scope, scope, ev) + for ch in embedded_channels: + if INACTIVE(scope): + break + delivery = make_embedded_delivery(ch, ev) + document = RUN_HANDLERS_FOR_DELIVERY(document, scope, ch, delivery.payload) + + if INACTIVE(scope): + return document + + # PHASE 5 — Drain this scope's Triggered FIFO exactly once + if has_triggered_event_channel(document, scope): + document = DRAIN_TRIGGERED_QUEUE(document, scope) + + return document +``` + +`INACTIVE(scope)` is true when the scope is terminated, cut off, or no longer exists. + +Informative phase diagram: + +```text +Phase 1: process embedded children +Phase 2: initialize this scope if needed +Phase 3: evaluate external channel candidates +Phase 4: bridge child emissions +Phase 5: drain local Triggered FIFO +``` + +### 7.4 External channel evaluation (normative) + +For each candidate external channel, the processor: + +1. charges a channel match attempt (§12); +2. evaluates the channel's deterministic acceptance logic; +3. adds any explicit gas consumed by the channel; +4. handles channel-requested termination, if any; +5. if accepted, produces a channelized payload. + +External channel candidates are all supported external-channel contract entries in the current scope, sorted by `(order, key)`, before applying the channel's event acceptance logic. Processor-managed channels are excluded. A processor MUST NOT pre-filter candidate external channels by event acceptance in a way that avoids the channel match attempt charge. + +`snapshot_sorted_external_channel_candidates` returns the Phase 3 candidate snapshot defined by §3.9.1. It captures candidate keys and resolved candidate recognition/execution views. It does not pre-apply event acceptance. + +`EVALUATE_EXTERNAL_CHANNEL` performs acceptance or rejection for a candidate channel. A candidate that rejects still consumes the channel match attempt charge. + +In Blue Contracts 1.0 core, external channel evaluation may return only rejection or accepted delivery, explicit gas consumed, channelized payload for accepted delivery, and an optional termination request. Handler-only effects from external channel evaluation are fatal unless a supported profile explicitly extends channel capabilities. + +External channel evaluation MUST NOT mutate the selected document directly. + +### 7.5 Handler execution helper (normative) + +```text +function RUN_HANDLERS_FOR_DELIVERY(document, scope, channel, payload): + handlers = sort_by_order_then_key(find_handlers_for_channel(document, scope, channel.key, payload)) + for h in handlers: + if INACTIVE(scope): + break + + CHARGE_HANDLER_OVERHEAD() + result = execute_handler(h, context_for(scope, channel, payload)) + document = APPLY_CONTRACT_RESULT(document, scope, result) + if RUN.stop_lifecycle_delivery[scope]: + break + + return document +``` + +Handler event matchers, if present, are evaluated against the channelized payload according to the handler type's deterministic semantics. + +### 7.6 Contract result helper (normative) + +```text +function APPLY_CONTRACT_RESULT(document, scope, result): + result = NORMALIZE_CONTRACT_RESULT_OR_FATAL(scope, result) + if INACTIVE(scope): + return document + + VALIDATE_GAS_OR_FATAL(scope, result.gasConsumed) + if INACTIVE(scope): + return document + ADD_EXPLICIT_GAS(result.gasConsumed) + + for patch in result.patches: + if INACTIVE(scope): + break + VALIDATE_PATCH_OR_FATAL(scope, patch) + if INACTIVE(scope): + break + document = APPLY_PATCH_WITH_CASCADE(document, origin_scope=scope, patch=patch) + + for event in result.triggeredEvents: + if INACTIVE(scope): + break + EMIT_TO_SCOPE(scope, event) + + if result.termination is not null and not INACTIVE(scope): + if result.termination.cause == "graceful": + document = ENTER_GRACEFUL_TERMINATION(document, scope, result.termination.reason) + elif result.termination.cause == "fatal": + document = ENTER_FATAL_TERMINATION(document, scope, result.termination.reason) + else: + document = ENTER_FATAL_TERMINATION(document, scope, "Invalid termination cause: " + result.termination.cause) + + return document +``` + +`gasConsumed` MUST be a non-negative integer. Negative or non-integer gas consumption is a deterministic runtime fatal. + +### 7.7 Emit helper (normative) + +```text +function EMIT_TO_SCOPE(scope, node): + if INACTIVE(scope): + return + node = NORMALIZE_RUNTIME_NODE_FOR_INSERTION(node, event) + VALIDATE_EVENT_NODE_OR_FATAL(scope, node) + if INACTIVE(scope): + return + CHARGE_EMIT_EVENT(node) + RUN.emitted_by_scope[scope].append(node) + RUN.fifo_by_scope[scope].enqueue(node) + if scope == "/": + RUN.root_events.append(node) +``` + +Emitted nodes are recorded even if the scope lacks a Triggered Event Channel. Local delivery depends on Phase 5 and channel presence. + +### 7.8 Lifecycle record helper (normative) + +```text +function RECORD_BRIDGEABLE(scope, node): + RUN.emitted_by_scope[scope].append(node) + if scope == "/": + RUN.root_events.append(node) +``` + +Lifecycle nodes are bridgeable but are not enqueued in the scope's Triggered FIFO. + +### 7.9 Patch and cascade helper (normative) + +```text +function APPLY_PATCH_WITH_CASCADE(document, origin_scope, patch): + CHARGE_BOUNDARY_CHECK(patch) + if boundary_violation(document, origin_scope, patch): + document = ENTER_FATAL_TERMINATION(document, origin_scope, "Boundary violation at " + patch.path) + return document + + before = snapshot_at(document, patch.path) + CHARGE_PATCH_OP(patch) + tentative = apply_patch(copy(document), normalize_patch_value_if_present(patch)) + soundness = RESTORE_TYPE_SOUNDNESS(tentative, origin_scope, patch.path) + if soundness fails: + document = ENTER_FATAL_TERMINATION(document, origin_scope, soundness.error) + return document + document = soundness.document + after = snapshot_at(document, patch.path) + + document = DELIVER_DOCUMENT_UPDATE_CASCADE(document, origin_scope, patch.op, patch.path, before, after) + + for write in soundness.generatedTypeWrites in deepest_to_root_order: + document = APPLY_GENERATED_GENERALIZATION_CASCADE(document, origin_scope, write) + + UPDATE_CUT_OFF_SCOPES_AFTER_PATCH(document) + return document +``` + +Document Update cascades execute immediately. Triggered emissions produced during cascades are enqueued but not drained until the receiving scope's Phase 5. + +`DELIVER_DOCUMENT_UPDATE_CASCADE` performs the per-patch cascade described in §9.1-§9.3. Document Update channel discovery is performed independently for each cascade payload. + +`APPLY_GENERATED_GENERALIZATION_CASCADE` delivers the Document Update cascade for one generated `/type` write without charging handler/channel boundary-check gas. It uses the same snapshots, payload construction, type soundness, and cascade routing rules as a processor-managed `replace` patch. + +`APPLY_PROCESSOR_PATCH_WITH_CASCADE` has the same patch application, snapshot, Blue Language validity, patch operation gas, and Document Update cascade behavior as `APPLY_PATCH_WITH_CASCADE`. It bypasses handler/channel reserved-key write protection only for processor-authorized marker writes explicitly allowed by this specification. It does not charge handler/channel boundary-check gas unless §12 says otherwise. It MUST still reject invalid Blue nodes and malformed runtime pointers. + +### 7.10 Triggered FIFO drain helper (normative) + +```text +function DRAIN_TRIGGERED_QUEUE(document, scope): + fifo = RUN.fifo_by_scope[scope] + while fifo is not empty and not INACTIVE(scope): + event = fifo.dequeue() + CHARGE_DRAIN_FIFO(event) + channels = snapshot_triggered_channels_now(document, scope, event) + for ch in channels: + if INACTIVE(scope): + break + delivery = make_triggered_delivery(ch, event) + document = RUN_HANDLERS_FOR_DELIVERY(document, scope, ch, delivery.payload) + + return document +``` + +Events emitted during drain are appended to the tail of the same FIFO and processed deterministically during the same drain, unless the scope becomes inactive. + +### 7.11 Lifecycle delivery helper (normative) + +```text +function DELIVER_LIFECYCLE(document, scope, lifecycle_node): + CHARGE_LIFECYCLE_DELIVERY(scope, lifecycle_node) + RECORD_BRIDGEABLE(scope, lifecycle_node) + + channels = sorted_lifecycle_channels(document, scope) + for ch in channels: + if RUN.stop_lifecycle_delivery[scope]: + break + if INACTIVE(scope): + break + delivery = make_lifecycle_delivery(ch, lifecycle_node) + document = RUN_HANDLERS_FOR_DELIVERY(document, scope, ch, delivery.payload) + + return document +``` + +Lifecycle delivery may run handlers, which may patch, emit, consume gas, or terminate. + +### 7.12 Direct Writes (normative) + +A **Direct Write** is a processor mutation that does not produce a Document Update cascade and does not schedule cascade work. + +Direct Writes are used only for: + +- creating a **Channel Event Checkpoint** lazily before accepted external-channel newness evaluation; +- updating a checkpoint after successful external-channel processing; +- writing a **Processing Terminated Marker** at a scope on termination. + +Direct Writes mutate selected document state and return the updated selected document in functional pseudocode. They are visible to subsequent logic in the same run and persist in `new_doc`. + +A Direct Write to a processor-managed reserved path MUST create any missing object containers required for that reserved path, such as `contracts`, `checkpoint`, and `lastEvents`, when those containers are needed to perform a processor-required Direct Write. Such container creation is part of the Direct Write, produces no Document Update cascade, and has only the Direct Write gas specified in §12. If an intermediate path exists but is not an object where an object container is required, the Direct Write is a deterministic runtime fatal at the scope performing the processor operation. + +Handlers and channels cannot perform Direct Writes. + +--- + +## 8. Initialization and Lifecycle + +### 8.1 First-run initialization (normative) + +If a scope does not have `contracts/initialized` when Phase 2 begins, the processor initializes the scope. + +Initialization performs, in order: + +1. compute the scope Content BlueId before initialization; +2. publish **Document Processing Initiated** through Lifecycle Event Channels at the scope; +3. add **Processing Initialized Marker** under `contracts/initialized` using a processor-managed patch, which MUST trigger a Document Update cascade. + +The marker stores the pre-init scope Content BlueId in `documentId`. + +If the processor cannot compute the scope Content BlueId because required provider content is unavailable or invalid, the scope MUST terminate fatally. + +The pre-initialization scope Content BlueId is calculated from the selected scope subtree immediately after Phase 1 embedded processing for that scope and before the Processing Initialized Marker is written. + +The input to Content BlueId calculation is the scope subtree as a Blue Language Source-equivalent document after runtime selected-document normalization. It includes materialized non-runtime content and materialized contract content that exists at that scope at that moment. It excludes no fields merely because they are runtime fields, except that the not-yet-written initialized marker is absent. + +If a scope already has a valid terminated marker, initialization does not run. If Content BlueId cannot be computed deterministically because required provider content is unavailable, the scope terminates fatally. + +### 8.2 Initialization pseudocode (normative) + +```text +function INITIALIZE_SCOPE(document, scope): + CHARGE_INITIALIZATION(scope) + pre_init_id = compute_scope_content_blue_id(document, scope) + + initiated = make_document_processing_initiated(documentId=pre_init_id) + document = DELIVER_LIFECYCLE(document, scope, initiated) + + if INACTIVE(scope): + return document + + marker = make_processing_initialized_marker(documentId=pre_init_id) + patch = { op: "add", path: JOIN_SCOPE_PATH(scope, "/contracts/initialized"), val: marker } + document = APPLY_PROCESSOR_PATCH_WITH_CASCADE(document, origin_scope=scope, patch=patch) + + return document +``` + +Processor-managed initialization marker patches are not handler/channel patches and may target the reserved `initialized` key. They still produce Document Update cascades. + +### 8.3 No eager checkpoint creation (normative) + +Initialization MUST NOT create `contracts/checkpoint` merely because a scope is initialized. Checkpoints are created lazily only when an external channel candidate accepts at that scope and requires newness evaluation (§10). + +### 8.4 Lifecycle events and root outbox (normative) + +A lifecycle event recorded at root MUST be appended to the run's `triggered_events` outbox. + +Lifecycle events recorded at non-root scopes are not returned directly unless they are bridged by an ancestor and re-emitted at root by handlers. + +### 8.5 Persistent initialization (normative) + +Once a valid **Processing Initialized Marker** exists at a scope, subsequent invocations MUST NOT re-run initialization for that scope unless the marker has been removed by an ancestor replacing or removing the scope root outside the scope's own execution. + +Handlers and channels cannot remove or replace `contracts/initialized` directly because reserved keys are write-protected. + +--- + +## 9. Document Updates, Cascades, FIFOs, and Bridging + +### 9.1 One patch, one cascade (normative) + +Every successful patch causes exactly one Document Update cascade. + +If a patch generates type generalization writes, each generated write also causes exactly one Document Update cascade after the requested patch cascade, in deepest-to-root order. + +The cascade starts at the patch's origin scope and proceeds to each ancestor up to root, in order. + +For an origin scope `/a/b`, cascade scope order is: + +```text +/a/b -> /a -> / +``` + +Scopes that no longer exist are marked cut off and do not receive further work. + +### 9.2 Cascade matching (normative) + +At each receiving scope `S`, a Document Update Channel with path `P` matches iff `DESCENDANT_OR_EQUAL(patch.path, ABS(S, P))` is true. + +Matching uses absolute paths. Payload paths are scope-relative. + +Document Update channel discovery for a patch uses the post-patch Selected Document View. A Document Update Channel removed by the patch does not receive that patch's Document Update. A Document Update Channel added by the patch may receive that same patch's Document Update if it exists in a participating scope and matches the changed path in the post-patch view. + +If post-patch Document Update discovery encounters a materialized contract entry whose type is unsupported, malformed, or invalid under Contract Recognition Resolution, the receiving scope where discovery occurs MUST terminate fatally under the normal runtime-discovery rules. The original patch remains applied unless the failing scope is otherwise rolled back by a supported profile; Blue Contracts 1.0 core has no rollback. + +Example: + +```text +Patch path: /a/z/k +At scope /a, payload path: /z/k +At root /, payload path: /a/z/k +``` + +### 9.3 Uniform payload per scope (normative) + +For a given patch and receiving scope, the processor creates one immutable Document Update payload. All matching channels and handlers at that scope receive that same payload object. + +The payload object MUST NOT be mutated by handlers. + +### 9.4 No drain during cascades (normative) + +Triggered events emitted by handlers during a Document Update cascade are: + +- recorded under the emitting scope; +- enqueued into that scope's Triggered FIFO; +- not delivered through the Triggered Event Channel during the cascade. + +They may be delivered only during that scope's Phase 5 drain. + +### 9.5 FIFO persistence within a run (normative) + +Each scope has one FIFO for the entire processor invocation. + +Events are enqueued in emission order. Events emitted during FIFO drain append to the tail and are processed during the same drain if the scope remains active. + +If a scope terminates or is cut off, its FIFO is dropped. + +### 9.6 Bridge timing (normative) + +A parent bridges child emissions in Phase 4: + +- after embedded children have been processed; +- after the parent handles the incoming external event; +- before the parent drains its own Triggered FIFO. + +This ordering is normative. + +Informative rationale: bridge-before-drain lets parent Embedded Node Channel handlers react to child emissions and enqueue parent-scope Triggered events that can still be drained in the same parent invocation; reversing the order would defer those reactions to a later invocation. + +### 9.7 Bridge ordering (normative) + +Bridge processing order is: + +1. child scopes in the parent invocation's `processed_paths` insertion order; +2. child emissions in the order recorded under that child; +3. matching Embedded Node Channels sorted by `(order, key)`; +4. handlers within each Embedded Node Channel sorted by `(order, key)`. + +### 9.8 Bridge scope (normative) + +Embedded Node Channel handlers execute in the parent scope, not in the child scope. Patches they produce are parent-scope patches and are subject to the parent's boundary rules. + +Informative cascade/bridge diagram: + +```text +child patch + -> child Document Update cascade upward + -> child emissions recorded + +parent Phase 4 bridge + -> parent Embedded Node Channel delivery + -> parent FIFO enqueue by parent handlers + -> parent Phase 5 drain +``` + +--- + +## 10. External Channels and Channel Event Checkpoints + +### 10.1 External channels (normative) + +An **external channel** is any supported Channel type other than the processor-managed channel families defined in §5. + +External channels match the input `event` delivered to `PROCESS`. Concrete external channel types define their acceptance and channelization semantics. + +### 10.2 Checkpoint marker (normative) + +A **Channel Event Checkpoint** records the last processed checkpoint subject per external channel key: + +```yaml +contracts: + checkpoint: + type: Channel Event Checkpoint + lastEvents: + channelKey: +``` + +There MUST be at most one checkpoint per scope, and it MUST be under the reserved key `checkpoint`. + +`lastEvents` is keyed by the raw contract-map key of the external channel. The key is escaped only when constructing a runtime pointer used for Direct Write. The selected document stores the raw object key. + +### 10.3 Lazy creation (normative) + +A scope may lack `contracts/checkpoint` until an accepted external channel delivery first requires newness evaluation at that scope. + +Rejected external channel candidates do not create checkpoints. Lazy checkpoint creation occurs after an external channel candidate accepts the input event and before that accepted delivery's newness policy is evaluated. + +When an accepted external channel delivery at scope `S` requires newness evaluation and `contracts/checkpoint` is absent, the processor MUST Direct Write an empty checkpoint before newness evaluation: + +```yaml +lastEvents: {} +``` + +This Direct Write does not emit Document Update and does not consume checkpoint-update gas unless a gas profile explicitly says otherwise. Under §12, lazy creation itself costs zero gas. + +### 10.4 Newness policy (normative) + +For each external channel key, the processor uses a deterministic **newness policy** to decide whether the incoming event should be processed. + +Each external channel has an effective `checkpointIdentityMode`: + +- `contentBlueId` (default): compare Content BlueIds of checkpoint subjects; +- `nodeBlueId`: compare direct Node BlueIds of checkpoint subjects, requiring valid BlueId Input; +- `channelDefined`: the concrete channel type defines deterministic identity. + +The default for Blue Contracts 1.0 external channels is `contentBlueId`. + +A concrete external channel type MAY define its own newness policy. That policy MUST be deterministic and MUST depend only on: + +- the previous checkpoint subject stored in `lastEvents[channelKey]`, if any; +- the incoming event node; +- the accepted channelized payload, if the channel type declares that payload as part of its newness policy; +- the channel contract content; +- deterministic Blue Language identity operations. + +If a concrete channel type does not define a more specific policy, the default policy is **content-idempotent**: + +- if no previous incoming event is stored for the channel key, the event is new; +- otherwise, the event is new iff the incoming event's Content BlueId differs from the previous incoming event's Content BlueId. + +The default content-idempotent policy computes the incoming and stored event identities using the effective `checkpointIdentityMode`. Under the default `contentBlueId` mode, the processor uses the Blue Language Content BlueId pipeline over the normalized checkpoint subjects. Under `nodeBlueId`, the subject MUST already be valid BlueId Input after runtime insertion normalization. + +Provider failure required for this identity calculation is a runtime fatal at the evaluating scope, unless discovered during the initial capability check. + +The stored checkpoint subject remains the incoming event node by default, not the channelized payload. A concrete channel type that declares a non-default `checkpointSubject` MUST define how its newness policy uses that subject. + +The processor stores the checkpoint subject after event preprocessing and runtime checkpoint-subject normalization. It does not store an ambiguous source form unless the concrete channel type explicitly defines that behavior. + +The default policy detects duplicates but does not impose temporal ordering. Channels that require sequence numbers, ledgers, vector clocks, or monotonic timestamps MUST define those rules in their concrete channel type. + +### 10.5 Gating rule (normative) + +For each accepted external channel delivery: + +1. ensure the checkpoint exists, lazily creating it if needed; +2. read `lastEvents[channelKey]`; +3. evaluate the channel's newness policy; +4. if not new, skip handlers and leave the checkpoint unchanged; +5. if new, run handlers; +6. if channel handling completes without scope termination or fatal error, Direct Write `lastEvents[channelKey] = checkpoint_subject(channel, incomingEvent, delivery)`. + +The checkpoint stores the incoming event node, not the channelized payload, unless the concrete external channel type explicitly defines a different checkpoint subject. + +```text +function ENSURE_CHECKPOINT_FOR_ACCEPTED_DELIVERY(document, scope): + checkpoint_path = JOIN_SCOPE_PATH(scope, "/contracts/checkpoint") + if checkpoint absent at checkpoint_path: + emptyCheckpoint = ChannelEventCheckpoint(lastEvents={}) + document = DIRECT_WRITE(document, checkpoint_path, emptyCheckpoint) + return document + +function CHECKPOINT_ALLOWS(document, scope, channelKey, incomingEvent, delivery): + # Pure decision after lazy checkpoint existence has been ensured. + return evaluate_newness_policy(document, scope, channelKey, incomingEvent, delivery) + +function DIRECT_WRITE_CHECKPOINT_UPDATE(document, scope, channel, incomingEvent, delivery): + channelKey = channel.key + subject = NORMALIZE_RUNTIME_NODE_FOR_INSERTION(checkpoint_subject(channel, incomingEvent, delivery), checkpointSubject) + CHARGE_CHECKPOINT_UPDATE() + path = JOIN_SCOPE_PATH(scope, "/contracts/checkpoint/lastEvents/" + escape_pointer_segment(channelKey)) + return DIRECT_WRITE(document, path, subject) + +function checkpoint_subject(ch, incomingEvent, delivery): + if ch.checkpointSubject == "incoming-event": + return incomingEvent + if ch.checkpointSubject == "channelized-payload": + return delivery.payload + if ch.checkpointSubject == "channel-defined": + return deterministic_subject_defined_by_channel_type(ch, incomingEvent, delivery) + return incomingEvent +``` + +The value returned by `checkpoint_subject` MUST be a valid Blue node. If a channel-defined checkpoint subject is invalid or cannot be computed deterministically, the evaluating scope MUST terminate fatally and the checkpoint MUST NOT be updated. + +The object member created at the Direct Write path is the raw `channelKey`; pointer escaping is not part of the stored key. + +### 10.6 Successful channel processing (normative) + +An external channel is considered successfully processed when: + +- its accepted handlers have all run in deterministic order; +- all their patches, emissions, and termination requests have been applied; and +- the scope has not terminated fatally or gracefully during that channel. + +If the scope terminates during the channel, the checkpoint MUST NOT be updated for that channel unless the concrete termination policy explicitly says otherwise. Blue Contracts 1.0 default is no checkpoint update on termination. + +### 10.7 Multiple external channels (normative) + +External channel candidates at a scope are considered in `(order, key)` order. Candidates that accept the same input event each use their own checkpoint entry keyed by their contract-map key. + +One channel being stale does not prevent another channel from running. + +If an earlier channel's handlers patch ordinary document state, later Phase 3 candidate channel executions see the updated Selected Document View as context. However, the Phase 3 external candidate set and candidate recognition views are snapshotted at Phase 3 start under §3.9.1. + +### 10.8 Checkpoint tamper resistance (normative) + +Handlers and channels cannot patch `contracts/checkpoint` or its descendants. Attempts are deterministic runtime fatals. + +Only the processor may create or update checkpoints through Direct Write. + +--- + +## 11. Failure and Termination Semantics + +### 11.1 Capability failure (must-understand) (normative) + +If the initial must-understand capability check fails, the processor MUST NOT run. It returns a capability failure with: + +- unchanged document; +- no triggered events; +- total gas `0`; +- no lifecycle events; +- no termination markers. + +Capability failure is not a runtime fatal because runtime never begins. + +### 11.2 Runtime fatal (normative) + +A deterministic runtime error terminates the executing scope fatally. Runtime fatal causes include, but are not limited to: + +- boundary violation; +- root target patch; +- self-root mutation; +- invalid contract-map key discovered in an active scope; +- malformed patch entry; +- unsupported patch operation; +- invalid pointer; +- invalid patch value after runtime insertion normalization; +- array out-of-range; +- removing a non-existent member; +- non-object embedded scope root selected for traversal; +- malformed required marker; +- reserved-key write attempt; +- post-patch type soundness violation; +- generalization rejected by Type Generalization Policy; +- no valid permitted generalization target; +- duplicate required marker; +- unsupported contract type discovered after runtime mutation has begun; +- invalid contract result shape; +- handler or channel execution error; +- checkpoint creation or update failure; +- gas accounting failure; +- termination Direct Write failure after fallback; +- provider verification failure required for runtime contract recognition, scope Content BlueId calculation, or event identity calculation. + +### 11.3 Contract-requested termination (normative) + +A channel or handler may request graceful termination by invoking `terminate(cause="graceful", reason?)`. + +Graceful termination ends the scope without treating the run as erroneous. + +Contract-requested termination cause MUST be either `graceful` or `fatal`. A graceful request enters graceful termination. A fatal request enters fatal termination and is treated as a contract-declared fatal condition, not as a processor validation error. Any other cause value is a deterministic runtime fatal at the executing scope. + +Profiles MAY restrict handlers or channels to graceful-only termination, but such restriction is outside the Blue Contracts 1.0 core unless represented by a supported policy. + +### 11.4 Termination effects (normative) + +When a scope begins termination, gracefully or fatally, the processor MUST: + +1. If the scope is already terminating or terminated, apply the reentrancy rule below and return. +2. Mark `RUN.terminating_scopes[scope] = true`. +3. Direct Write `JOIN_SCOPE_PATH(scope, "/contracts/terminated")` with **Processing Terminated Marker**: + - `cause: graceful` or `cause: fatal`; + - optional `reason`. +4. Create the **Document Processing Terminated** lifecycle event. +5. Deliver the lifecycle event using `DELIVER_LIFECYCLE`; `DELIVER_LIFECYCLE` records it as bridgeable before routing it to Lifecycle Event Channels. +6. Mark `RUN.terminated_scopes[scope] = true`. +7. Drop the scope's Triggered FIFO. +8. Treat further patch/emit attempts from that scope as no-ops for the remainder of the run. +9. If the terminated scope is root, apply root graceful/fatal completion rules from §11.6-§11.7. + +The termination marker Direct Write does not emit a Document Update. + +If writing the Processing Terminated Marker by Direct Write fails because a required intermediate container is malformed, the processor MUST make one fallback attempt to replace the executing scope's `contracts` field with an object containing only a valid `terminated` marker and any reserved runtime subtrees that can be preserved without violating Blue Language validity. + +If that fallback also fails, the processor MUST abort the run with `TerminationError`. The returned document is the last valid selected document state before the failed termination write, and root fatal outbox behavior is implementation-exposed through the conformance result envelope rather than by a marker that could not be written. + +A processor MUST NOT loop indefinitely attempting termination writes. + +Termination is single-entry per scope per invocation. Once `ENTER_GRACEFUL_TERMINATION` or `ENTER_FATAL_TERMINATION` begins for a scope, that scope is in terminating state. The termination marker and exactly one **Document Processing Terminated** lifecycle event are produced for the first termination cause. Additional `terminate(...)` requests from handlers invoked during termination lifecycle delivery are ignored after their already-applied prior effects. + +Additional termination requests after `RUN.terminated_scopes[scope] = true` are ignored. + +If a deterministic runtime fatal occurs while a root scope is already terminating, the processor MUST append exactly one root outbox-only **Document Processing Fatal Error** if one has not already been appended. This does not change the already-written **Processing Terminated Marker** or the already-created **Document Processing Terminated** event. For non-root scopes, the fatal is suppressed as an additional termination cause; it MUST NOT write a second marker or emit a second lifecycle event. In all cases, the processor MUST abort remaining effects from the currently failing handler result and stop further lifecycle delivery at that terminating scope. No second termination marker charge, lifecycle delivery charge, or fatal termination overhead is charged for a suppressed additional termination cause. + +Terminating state is a reentrancy guard. It does not by itself make lifecycle handlers inactive before the first termination lifecycle delivery completes. + +### 11.5 Non-root fatal (normative) + +A fatal termination in a non-root scope is scope-terminal only by default. + +The parent continues processing unless it is itself terminated by a handler or by a separate fatal error. The child's already recorded emissions, including the termination lifecycle event, remain bridgeable to the parent. + +### 11.6 Root graceful termination (normative) + +If the root scope terminates gracefully, the processor records **Document Processing Terminated** in the root outbox and ends the run. It returns the current document, root outbox, and total gas. + +If §11.4 appends **Document Processing Fatal Error** because a deterministic runtime fatal occurs during root graceful termination lifecycle delivery, the already-written graceful termination marker and lifecycle event remain unchanged, and the root outbox also contains the fatal error signal. + +When both **Document Processing Terminated** and **Document Processing Fatal Error** appear in the root outbox for the same root termination sequence, **Document Processing Terminated** MUST appear before **Document Processing Fatal Error**. + +### 11.7 Root fatal termination (normative) + +If the root scope terminates fatally, the processor MUST: + +1. record **Document Processing Terminated** at root; +2. append **Document Processing Fatal Error** to the root outbox as an outbox-only event; +3. abort the run; +4. return the current document, root outbox, and total gas. + +**Document Processing Fatal Error** is not delivered to Lifecycle Event Channels and is not bridgeable. It is a root outbox signal only. + +When both **Document Processing Terminated** and **Document Processing Fatal Error** appear in the root outbox for the same root termination sequence, **Document Processing Terminated** MUST appear before **Document Processing Fatal Error**. + +### 11.8 Termination pseudocode (informative) + +```text +function ENTER_GRACEFUL_TERMINATION(document, scope, reason): + if RUN.terminated_scopes[scope]: + return document + if RUN.terminating_scopes[scope]: + return document + RUN.terminating_scopes[scope] = true + CHARGE_TERMINATION_MARKER_WRITE() + document = DIRECT_WRITE(document, + JOIN_SCOPE_PATH(scope, "/contracts/terminated"), + ProcessingTerminatedMarker(cause="graceful", reason=reason)) + event = DocumentProcessingTerminated(cause="graceful", reason=reason) + document = DELIVER_LIFECYCLE(document, scope, event) + RUN.terminated_scopes[scope] = true + clear_fifo(scope) + if scope == "/": + if RUN.root_fatal_error_appended: + raise ROOT_FATAL_TERMINATION + raise ROOT_GRACEFUL_TERMINATION + return document + +function ENTER_FATAL_TERMINATION(document, scope, reason): + if RUN.terminated_scopes[scope]: + return document + if RUN.terminating_scopes[scope]: + if scope == "/" and not RUN.root_fatal_error_appended: + RUN.root_events.append(DocumentProcessingFatalError(reason=reason)) + RUN.root_fatal_error_appended = true + RUN.stop_lifecycle_delivery[scope] = true + abort_current_handler_result() + return document + RUN.terminating_scopes[scope] = true + CHARGE_TERMINATION_MARKER_WRITE() + CHARGE_FATAL_OVERHEAD() + document = DIRECT_WRITE(document, + JOIN_SCOPE_PATH(scope, "/contracts/terminated"), + ProcessingTerminatedMarker(cause="fatal", reason=reason)) + event = DocumentProcessingTerminated(cause="fatal", reason=reason) + document = DELIVER_LIFECYCLE(document, scope, event) + RUN.terminated_scopes[scope] = true + clear_fifo(scope) + if scope == "/": + if not RUN.root_fatal_error_appended: + RUN.root_events.append(DocumentProcessingFatalError(reason=reason)) + RUN.root_fatal_error_appended = true + raise ROOT_FATAL_TERMINATION + return document +``` + +The pseudocode is informative. The observable state changes and ordering above are normative. + +If a termination helper raises `ROOT_GRACEFUL_TERMINATION` or `ROOT_FATAL_TERMINATION`, the raised control signal carries the current updated document. The top-level wrapper returns that updated document. This is pseudocode notation only; implementations may use exceptions, tagged returns, or another deterministic control-flow representation. + +`abort_current_handler_result()` means that the processor stops applying any remaining unapplied effects from the currently executing contract result. Effects already fully applied remain applied. No additional patches, Triggered emissions, or termination requests from that result are processed. + +--- + +## 12. Gas Accounting + +### 12.1 Philosophy and unit (normative) + +Gas is an abstract deterministic unit used to measure work. + +Processors MUST NOT base gas on wall-clock time, CPU model, memory pressure, I/O latency, scheduler behavior, or implementation-specific performance. + +Given the same input document, event, provider state, supported contract set, and deterministic contract implementations, all conforming processors MUST return the same `total_gas`. + +### 12.2 Accumulation (normative) + +`RUN.total_gas` MUST include: + +- all processor charges from this section; +- all explicit `consumeGas(units)` calls made by channels and handlers. + +`consumeGas(units)` MUST use a non-negative integer. Invalid gas amounts are deterministic runtime fatals. + +### 12.3 Scope management charges (normative) + +| Operation | Formula | Charge point | +|---|---:|---| +| Scope entry | `50 + 10 * depth` | On entry to `_PROCESS` for a scope. Root depth is 0. | +| Scope exit | `0` | On return from `_PROCESS`. | +| Initialization | `1000` | When first-run initialization starts for a scope. | + +`depth` is the number of embedded edges from root. + +Entering `_PROCESS` for an existing terminated scope still incurs the scope-entry charge. The terminated-marker check happens after scope entry and before any initialization, channel matching, lifecycle delivery, bridging, FIFO drain, or checkpoint work. + +### 12.4 Matching and contract-call charges (normative) + +| Operation | Formula | Charge point | +|---|---:|---| +| External channel match attempt | `5` per candidate tested | Each external channel candidate considered for an input event at a scope. | +| Handler call overhead | `50` | Immediately before executing each handler. | + +Explicit gas consumed by channel and handler code is added separately. + +### 12.5 Patch and cascade charges (normative) + +| Operation | Formula | Charge point | +|---|---:|---| +| Boundary check | `2` per patch | Before applying each handler/channel patch. | +| Patch `add` / `replace` | `20 + ceil(bytes / 100)` | After validation, before mutation. | +| Patch `remove` | `10` | After validation, before mutation. | +| Post-patch type soundness check | `5` per checked node | During `RESTORE_TYPE_SOUNDNESS`. | +| Cascade routing | `10` per participating scope | For each scope that receives the resulting Document Update. | + +For cascade-routing gas, a participating scope is an ancestor-or-origin scope that has at least one matching Document Update Channel for the changed path and therefore receives a Document Update delivery. + +For gas byte formulas, `bytes` is the UTF-8 byte length of the RFC 8785 canonical JSON representation of the node after `NORMALIZE_RUNTIME_NODE_FOR_INSERTION`, using selected-document form. It is not Content BlueId canonicalization and does not require resolving unrelated type chains. For `remove`, no `val` bytes are charged. + +Processor-managed initialization marker patches and generated type generalization writes are charged as patches and cascades. They are not charged for boundary checks because they are processor-internal and allowed to write their reserved or generated paths. + +### 12.6 Event, bridge, and FIFO charges (normative) + +| Operation | Formula | Charge point | +|---|---:|---| +| Emit event | `20 + ceil(bytes / 100)` | When `emitEvent(node)` succeeds. | +| Bridge child emission to parent | `10` per child emission delivered to at least one matching Embedded Node Channel | Before delivering the node to Embedded Node Channel handlers. | +| Drain FIFO event | `10` per dequeued event | Immediately before Triggered Channel handler routing. | + +`bytes` is the UTF-8 byte length of the emitted event node's RFC 8785 canonical JSON representation after `NORMALIZE_RUNTIME_NODE_FOR_INSERTION`, using selected-document form. + +The same gas-byte view applies to emitted event nodes after event validation and normalization. + +Emit-event gas is charged only after the emitted node has passed Blue Language validity checks. An invalid emitted event causes fatal termination but does not incur the successful emit-event charge. + +Bridge gas is not charged merely because a child emission was recorded. It is charged once per recorded child emission that is actually delivered to at least one matching Embedded Node Channel in the parent, regardless of how many matching channels receive that emission. + +### 12.7 Direct Write and checkpoint charges (normative) + +| Operation | Formula | Charge point | +|---|---:|---| +| Lazy checkpoint creation | `0` | When creating an empty checkpoint before accepted external-channel newness evaluation. | +| Checkpoint read | `0` | When consulting a checkpoint. | +| Checkpoint update | `20` | After successful external channel processing. | +| Termination marker Direct Write | `20` | When writing `contracts/terminated`. | + +Direct Writes never trigger Document Update cascades. + +### 12.8 Lifecycle and termination charges (normative) + +| Operation | Formula | Charge point | +|---|---:|---| +| Lifecycle delivery | `30` | Per `DELIVER_LIFECYCLE` call, before lifecycle handlers. | +| Graceful termination overhead | `0` | Marker write and lifecycle delivery are charged separately. | +| Fatal termination overhead | `100` | On fatal termination, in addition to marker write and lifecycle delivery. | +| Must-understand capability failure | `0` | Pre-execution failure. | + +A fatal termination step costs at least `150` gas: marker Direct Write `20`, lifecycle delivery `30`, and fatal overhead `100`, plus any handler, patch, cascade, or emitted-event costs already incurred. + +A graceful termination step costs at least `50` gas: marker Direct Write `20` plus lifecycle delivery `30`. + +### 12.9 Accounting-only default (normative) + +This specification defines gas accounting, not enforcement. + +Absent an active supported gas policy, a processor MUST NOT skip work, change behavior, or terminate solely because gas is high. It records and returns `total_gas`. + +A separate supported policy marker MAY define budgets and overrun behavior. Such policies MUST be deterministic. If an unsupported gas policy contract is present in an active scope, must-understand rules apply. + +### 12.10 Gas examples (informative) + +Already-initialized root with one accepted external channel, one small `replace`, one matching root Document Update handler, and a successful checkpoint update: + +```text +scope entry 50 +channel match 5 +handler overhead 50 +boundary check 2 +replace 21 # about 1-100 bytes +cascade routing 10 +update handler 50 +checkpoint update 20 +--------------------- +minimum total 208 # plus explicit consumeGas +``` + +Already-initialized root reached through one accepted external channel whose handler violates the boundary before any checkpoint update: + +```text +scope entry 50 +channel match 5 +handler overhead 50 +boundary check 2 +termination marker 20 +lifecycle delivery 30 +fatal overhead 100 +----------------------- +minimum total 257 +``` + +Exact totals depend on the number of channels tested, handlers invoked, patch sizes, cascades, emissions, bridges, lifecycle handlers, initialization work, and explicit contract gas. + +--- + +## 13. Processor vs Feeder + +### 13.1 Feeder responsibilities (informative) + +A feeder is an external component that may: + +- collect events from users, networks, ledgers, queues, or sensors; +- order or batch events; +- retry delivery; +- deduplicate at the transport level; +- attach signatures or proofs; +- decide which document receives which event. + +Feeder behavior is outside this specification. + +### 13.2 Processor responsibilities (normative) + +Given one `document` and one `event`, the processor executes exactly the rules in this specification. + +The processor MUST NOT assume that the feeder has removed stale or duplicate events. External channel checkpoints provide deterministic in-document gating. + +### 13.3 Event ordering (normative) + +The processor handles only the single event supplied to one invocation. Ordering across multiple invocations is outside this specification except where persisted state, such as checkpoints and document mutations, affects later invocations. + +--- + +## 14. Security, Determinism, and Sandboxing + +### 14.1 Deterministic execution (normative) + +Contract execution MUST be deterministic. A contract MUST NOT read or depend on: + +- wall-clock time; +- process uptime; +- random numbers; +- CPU speed, thread scheduling, or memory addresses; +- ambient environment variables; +- network calls; +- filesystem state not represented as deterministic provider content; +- hidden mutable global state. + +All data affecting contract behavior MUST be present in the selected document, the delivered event payload, supported contract content, deterministic provider content verified by BlueId, or explicit processor context defined by this specification. + +### 14.2 Side-effect isolation (normative) + +Handlers and channels MUST NOT perform external side effects. Their only observable effects are the processor operations defined here. + +A conforming processor SHOULD sandbox contract implementations to enforce this boundary. + +Informative examples of common enforcement strategies include a pure interpreter, deterministic WASM with disabled host imports, capability-safe host APIs, frozen/immutable input objects, deterministic gas/fuel counters, and denying filesystem, network, clock, or random access unless represented as verified provider content. + +### 14.3 Payload immutability (normative) + +Delivered payloads, snapshots, and context objects are read-only. Contracts MUST NOT mutate them. + +If a contract implementation attempts mutation and the processor can detect it, the processor SHOULD treat it as a deterministic runtime fatal. If the processor prevents mutation by construction, no fatal is needed. + +### 14.4 Resource exhaustion (normative) + +Processors SHOULD expose implementation limits for: + +- maximum recursion depth; +- maximum embedded scopes per run; +- maximum FIFO length; +- maximum emitted events per run; +- maximum patch size; +- maximum canonicalization size for gas measurement; +- maximum provider materialization depth. + +If a limit is exceeded, the processor MUST handle it deterministically, normally as a runtime fatal at the affected scope unless a supported policy specifies otherwise. + +### 14.5 Provider safety (normative) + +Provider content used for contract type resolution, Content BlueId calculation, or event identity MUST be verified against its BlueId according to the Blue Language specification. + +A processor MUST NOT execute unverified provider content as a contract. + +### 14.6 Authorization out of scope (informative) + +This specification does not decide who is allowed to submit events or install contracts. Authorization can be expressed by supported contract types or by feeder policy, but the processor semantics here remain deterministic. + +--- + +## 15. Conformance Checklist and Test Vectors + +### 15.1 Conformance checklist (normative) + +A compliant Blue Contracts and Processor 1.0 implementation MUST satisfy the requirements below. + +**Inputs and capabilities** + +- Operate on Processing Documents, or preprocess Source Documents outside the runtime run. +- Reject non-object document roots before runtime as invalid Processing Documents. +- Do not require a fully Resolved View before `PROCESS` begins. +- Treat input events as read-only Blue nodes. +- Enforce must-understand before mutation for the initial active processing closure. +- Treat unsupported contracts discovered after mutation as runtime fatal at the discovering scope. +- Use canonical runtime type registry BlueIds for processor-managed contracts. + +**Contract model** + +- Discover runtime contracts from materialized selected-document `contracts` entries only, unless a profile explicitly extends runtime discovery. +- Execute contracts only in active scopes. +- Keep contracts scope-local. +- Sort channels and handlers by `(order, key)`. +- Use dispatch snapshots so in-flight handler/channel lists and executable contract content are not changed by contract mutations. +- Normalize contract results before applying effects. +- Buffer handler/channel effects during execution and apply normalized results only through the specified gas, patches, events, termination order. +- Enforce same-scope handler binding. +- Enforce reserved processor key compatibility and write protection. +- Allow handler/channel mutation of `contracts/embedded.paths` only through the narrow exception in §3.7. +- Treat delivered payloads and snapshots as immutable. +- Enforce contract-map key grammar. + +**Embedded traversal and isolation** + +- Read **Process Embedded** paths dynamically and re-read after each child. +- Process each child path at most once per parent invocation. +- Enforce no resurrection. +- Enforce boundary rules, self-root mutation forbidden, and root target forbidden. +- Implement balloon cut-off when an active child root is removed or replaced. + +**Initialization and lifecycle** + +- Initialize a scope only when `contracts/initialized` is absent. +- Publish **Document Processing Initiated** before writing the initialized marker. +- Write **Processing Initialized Marker** by processor-managed patch that triggers Document Update cascade. +- Do not create checkpoints during initialization. +- Honor pre-existing **Processing Terminated Marker** by making the scope inactive. + +**Patch semantics** + +- Support only `add`, `replace`, and `remove`. +- Use absolute Blue Runtime Pointers. +- Auto-materialize missing intermediate objects for `add` and `replace`. +- Support array append and insert semantics. +- Reject array out-of-range, malformed pointers, missing `val`, invalid `val`, and unsupported operations. +- Normalize every inserted patch value using `NORMALIZE_RUNTIME_NODE_FOR_INSERTION`. +- Restore post-patch type soundness before exposing a Document Update cascade. +- Apply dynamic type generalization when required and permitted by policy; reject/fatal atomically when soundness cannot be restored. +- Capture `before` and `after` snapshots. + +**Cascades, queues, and bridges** + +- After every successful patch, deliver Document Update cascade origin to root. +- Match Document Update channels using absolute changed path and scope-relative channel path. +- Deliver uniform immutable payload per receiving scope per patch. +- Never drain Triggered FIFO during cascades. +- Drain each scope's FIFO at most once in Phase 5. +- Record every emitted event under its emitting scope. +- Bridge child emissions in Phase 4 before parent FIFO drain. +- Discover Triggered Event Channels separately for each dequeued FIFO event. +- Discover Embedded Node Channels separately for each recorded child emission. +- Include a `type` pure reference on every processor-emitted event instance. + +**External channels and checkpoints** + +- Create checkpoint lazily only for accepted external channel deliveries before newness evaluation. +- Do not create checkpoints for rejected external channel candidates. +- Evaluate external channel candidates before acceptance, charging each candidate match attempt. +- Gate external channels only; processor-managed channels are not gated. +- Store the normalized checkpoint subject by default in `lastEvents[channelKey]` under the raw contract-map key after successful processing, unless the channel type defines a different checkpoint subject. +- Apply the effective checkpoint identity mode: `contentBlueId` by default, `nodeBlueId` only for valid BlueId Input, or `channelDefined` for concrete supported channel types. +- Create missing reserved object containers required by processor-managed Direct Writes. +- Leave checkpoint unchanged for stale events and channels that terminate the scope. +- Enforce checkpoint tamper resistance. + +**Termination and failures** + +- Return capability failure with no mutation, no events, and zero gas. +- On scope termination, Direct Write **Processing Terminated Marker**, publish **Document Processing Terminated**, deactivate scope, and drop FIFO. +- Enforce single-entry termination per scope per invocation. +- Non-root fatal does not escalate by default. +- Root graceful ends the run with termination lifecycle in outbox. +- Root fatal appends **Document Processing Fatal Error** and aborts the run. +- Classify conformance-visible failures using Appendix C categories. +- Use the termination Direct Write fallback exactly once when malformed containers prevent writing `contracts/terminated`. + +**Gas** + +- Apply all formulas in §12 deterministically. +- Charge cascade routing only for participating scopes that receive Document Update delivery. +- Charge bridge gas once per child emission delivered to at least one matching Embedded Node Channel. +- Charge post-patch type soundness checks and generated generalization writes under §6.10.4 and §12. +- Use the runtime insertion normalization byte view for patch and emitted-event byte charges. +- Include explicit `consumeGas` units. +- Do not enforce budgets unless a supported deterministic policy says so. + +### 15.2 Behavior-defining test vectors (normative) + +The following vectors are normative. Machine-readable fixtures MAY add exact document inputs, event inputs, and expected gas totals. + +**T1 — Dynamic embedded list** +Root declares embedded paths `/a`, `/b`. While processing `/a`, a root-scope handler is invoked by a Document Update cascade or another root-scope delivery and patches only `/contracts/embedded/paths`, removing `/b` and adding `/c`. The handler does not replace `contracts/embedded` as a whole, change `contracts/embedded/type`, or write any other field under `contracts/embedded`. +**Then:** after `/a`, the processor re-reads paths and visits `/c`; `/b` is skipped if it no longer exists. + +**T2 — Boundary enforcement** +Root attempts `replace /a/x` while `/a` is an active embedded child. +**Then:** root terminates fatally; `contracts/terminated` is written with cause `fatal`; **Document Processing Fatal Error** is appended to root outbox; run aborts. + +**T3 — Initialization once** +First run at `/a` has no initialized marker. +**Then:** **Document Processing Initiated** is published, **Processing Initialized Marker** is patched into `/a/contracts/initialized`, and that patch triggers a Document Update cascade. Later runs do not reinitialize `/a`. + +**T4 — Update cascades: absolute match and relative payload** +A handler at `/a` applies `replace /a/z/k`. Root has a Document Update Channel watching `/a/z`. +**Then:** at `/a`, payload path is `/z/k`; at root, payload path is `/a/z/k`; matching uses absolute paths. + +**T5 — Cascade emissions are enqueued, not delivered** +A patch at `/a/b` causes a Document Update handler at `/a` to emit `E`. +**Then:** `E` is recorded under `/a` and enqueued; it is delivered only during `/a` Phase 5 if `/a` has a Triggered Event Channel. + +**T6 — Triggered FIFO order** +A handler at `/a` emits `E1`, then `E2`. `/a` has a Triggered Event Channel. +**Then:** `/a` drains `E1` then `E2`; events emitted during drain append to the tail. + +**T7 — Bridging child emissions** +Child `/x` emits events during its run. Parent has an Embedded Node Channel for `/x`. +**Then:** parent bridges `/x` emissions in recorded order during Phase 4, before parent FIFO drain. + +**T8 — Checkpoint gating** +Two external channels accept the same event; one event is stale under its channel policy, one is new. +**Then:** stale channel handlers are skipped; new channel handlers run; only the new channel's checkpoint entry is updated. + +**T9 — Capability failure** +The initial active processing closure contains an unsupported contract type. +**Then:** processor returns must-understand capability failure; document unchanged; no lifecycle events; total gas `0`. + +**T10 — No accepted external channel** +All external channel candidates reject the event in every active scope. +**Then:** document changes only if first-run initialization is required; otherwise no patches, emissions, or checkpoint creation occur except measured channel-match gas. + +**T11 — Object auto-materialization** +A handler applies `add /a/b/c { ... }` where `/a` exists and `/a/b` does not. +**Then:** processor creates `/a/b` as an object and writes `/a/b/c`; one Document Update cascade runs for `/a/b/c`. + +**T12 — Array append and insert** +Given `/a/items: ["x", "y"]`, `add /a/items/- "z"` yields `["x", "y", "z"]`; `add /a/items/1 "q"` yields `["x", "q", "y"]`. +**Then:** each patch triggers one cascade. + +**T13 — Deterministic runtime fatal for invalid patch** +`replace /a/items/7 "z"` when length is less than 8, or `remove /a/missingKey`. +**Then:** executing scope terminates fatally; root fatal only if executing scope is root. + +**T14 — Scope-relative payload** +Patch replaces `/a/b/x`. +**Then:** payload path is `/x` at `/a/b`, `/b/x` at `/a`, and `/a/b/x` at root. + +**T15 — Root lifecycle inclusion** +First processing at root publishes **Document Processing Initiated**. Later root fatal occurs. +**Then:** root outbox includes root lifecycle events and **Document Processing Fatal Error**. + +**T16 — Local delivery depends on Triggered Channel presence** +During a cascade at `/a`, a handler emits `E`. `/a` lacks Triggered Event Channel. +**Then:** `E` is recorded and bridgeable, but not locally delivered at `/a`. + +**T17 — Uniform event per scope** +Multiple Document Update Channels at `/a` match the same patch. +**Then:** all handlers at `/a` receive the same immutable Document Update payload object. + +**T18 — Lazy checkpoint creation** +A scope has an accepted external channel delivery and lacks `contracts/checkpoint`. +**Then:** before newness evaluation, the processor Direct Writes an empty checkpoint; no Document Update is emitted. + +**T19 — Duplicate checkpoint marker** +A scope contains a **Channel Event Checkpoint** under a non-reserved key in addition to `contracts/checkpoint`. +**Then:** runtime fatal. + +**T20 — Stale external event** +`lastEvents.testChannel` holds `E_old`; incoming event is not new under `testChannel` policy. +**Then:** channel handlers are skipped; checkpoint unchanged. + +**T21 — Checkpoint updated after success** +A new external event on a default-subject `testChannel` is processed successfully. +**Then:** `lastEvents.testChannel` is Direct Written to the entire incoming event node; no Document Update is emitted. + +**T22 — Multiple external channels** +Two external channels accept the event and are both new. +**Then:** they run in `(order, key)` order; each updates its own checkpoint key after successful processing. + +**T23 — Self-root mutation forbidden** +While executing at `/a`, a contract attempts `remove /a`, `replace /a`, or `add /a`. +**Then:** fatal termination at `/a`. + +**T24 — Root-document mutation forbidden** +Any contract targets `/` with any patch operation. +**Then:** fatal termination at the executing scope; if root, run aborts with fatal outbox. + +**T25 — Balloon cut-off** +While `/b` is being processed, a parent watcher removes `/b`. +**Then:** current effect completes; no further work, handlers, or drain occur for `/b`; already recorded emissions remain bridgeable; re-adding `/b` in the same run does not schedule it again. + +**T26 — Termination is final in a run** +A scope terminates gracefully; later a handler at that scope would emit or patch. +**Then:** further patch/emit from that scope are no-ops. + +**T27 — Child fatal does not escalate by default** +`/a` terminates fatally. +**Then:** `/a` is marked terminated; parent continues; child termination lifecycle is bridgeable. + +**T28 — Child graceful termination bridges lifecycle** +`/a` terminates gracefully. +**Then:** parent may observe **Document Processing Terminated** via Embedded Node Channel if configured. + +**T29 — Root graceful termination ends run** +Root terminates gracefully. +**Then:** run ends; root outbox includes **Document Processing Terminated**. + +**T30 — Root fatal termination ends run with fatal outbox** +Root terminates fatally. +**Then:** run ends; root outbox includes **Document Processing Terminated** followed by **Document Processing Fatal Error**. + +**T31 — Pre-existing terminated marker** +An embedded scope has a valid `contracts/terminated` marker before processing. +**Then:** processor charges scope entry for entering and recognizing that scope, but does not initialize, match, run, bridge, drain, or create checkpoint state for that scope. + +**T32 — Default content-idempotent checkpoint policy** +An external channel defines no custom newness policy. Incoming event has same Content BlueId as stored previous event. +**Then:** event is stale and handlers are skipped. + +**T33 — Reserved-key tamper** +A handler attempts `replace /contracts/checkpoint/lastEvents/x ...`. +**Then:** executing scope terminates fatally. + +**T34 — Direct Write does not cascade** +Checkpoint creation, checkpoint update, or termination marker write occurs. +**Then:** no Document Update event is emitted solely for the Direct Write. + +**T35 — Unsupported contract introduced mid-run** +A handler patches a supported scope to add an unsupported contract type, and a later step attempts to execute that scope. +**Then:** the discovering scope terminates fatally, not capability-fails retroactively. + +**T36 — Processing Document is not eagerly resolved** +A document has a contract whose type reference can be resolved, but unrelated type references elsewhere are unavailable. +**Then:** processing may proceed unless the unavailable reference is needed for contract discovery, contract execution, Content BlueId calculation, or event identity. + +**T37 — Termination reentrancy** +A termination lifecycle handler calls `terminate(...)` again. +**Then:** exactly one terminated marker and one **Document Processing Terminated** event are produced for that scope. + +**T38 — External channel payload type declaration** +An external channel declares `payloadType`. +**Then:** handler matching is against the channelized payload conforming to that type, not the original input event. + +**T39 — Candidate channel gas** +A scope has three external candidate channels; two reject and one accepts. +**Then:** channel match gas is charged for all three candidate evaluations. + +**T40 — Root path joining** +Root initialization or root termination writes a processor marker. +**Then:** the processor writes `/contracts/initialized` or `/contracts/terminated`, never `//contracts/initialized` or `//contracts/terminated`. + +**T41 — Rejected external candidates do not create checkpoint** +A scope has an external channel candidate that rejects the event and no accepted external channel. +**Then:** no checkpoint marker is lazily created. + +**T42 — Termination lifecycle fatal is deterministic** +A root graceful termination lifecycle handler causes a deterministic runtime fatal. +**Then:** the original terminated marker and **Document Processing Terminated** event are not duplicated; the root outbox contains **Document Processing Terminated** followed by exactly one **Document Processing Fatal Error**. + +**T43 — Type-derived contracts are not executed by core** +A scope's type contains a `contracts.audit` entry, but the selected document scope has no materialized `contracts.audit`. +**Then:** Blue Contracts 1.0 core does not execute `audit`. If a profile wants inherited runtime contracts, it must define that as an extension. + +**T44 — Contracts-map reserved-key bypass is forbidden** +A root handler attempts `replace /contracts` with a map omitting `checkpoint` or `initialized`. +**Then:** the executing scope terminates fatally, even though the patch did not directly target `/contracts/checkpoint` or `/contracts/initialized`. + +**T45 — Handler list snapshot** +Two handlers `H1` and `H2` are eligible for one delivery. `H1` removes `H2`'s contract entry. +**Then:** `H2` still runs for that delivery unless the scope is terminated or cut off. The removal affects later deliveries only. + +**T46 — External candidate snapshot** +External candidate `C1` runs before `C2`. `C1` removes `C2`'s contract entry. +**Then:** `C2` remains in the current Phase 3 candidate snapshot. Later invocations observe the removal. + +**T47 — Post-patch Document Update discovery** +A patch adds a Document Update Channel watching the changed path. +**Then:** that channel is eligible to receive the Document Update for the same patch, because discovery uses the post-patch Selected Document View. + +**T48 — Snapshotted executable handler content** +Two handlers `H1` and `H2` are eligible for one delivery. `H1` replaces `H2`'s contract body before `H2`'s turn. +**Then:** `H2` still executes using the snapshotted resolved contract content captured for that delivery. Later deliveries observe the replacement. + +**T49 — Direct Write creates missing reserved containers** +A root document has no `contracts` map. An accepted external channel requires checkpoint creation, or root termination requires writing `contracts/terminated`. +**Then:** the processor creates the required reserved object containers by Direct Write, emits no Document Update solely for those writes, and never writes `//contracts/...`. + +**T50 — Reserved-key preservation uses Blue-node equality** +A handler replaces `/contracts` with a serialization-different but canonically identical reserved marker subtree. +**Then:** the replacement is allowed only if all reserved processor keys and descendants are preserved as the same selected-document Blue nodes under Blue Language normalization; raw source byte equality is not used. + +**T51 — Embedded paths mutation exception** +A root-scope handler invoked by a Document Update cascade or other root-scope delivery during `/a` processing patches only `/contracts/embedded/paths` to remove `/b` and add `/c`. +**Then:** the patch is allowed if the Process Embedded marker remains valid; after `/a`, the processor re-reads paths and visits `/c`. + +**T52 — Embedded marker type remains protected** +A handler attempts to patch `/contracts/embedded/type`. +**Then:** the executing scope terminates fatally with `ReservedKeyWrite`. + +**T53 — Embedded marker whole replace remains protected** +A handler attempts to replace `/contracts/embedded` as a whole. +**Then:** the executing scope terminates fatally with `ReservedKeyWrite`. + +**T54 — Triggered channel discovery is per FIFO event** +A Triggered FIFO drain handles `E1`, and a handler during `E1` adds a Triggered Event Channel that matches `E2`. +**Then:** the new channel does not affect `E1`, but it is discoverable for later dequeued `E2`. + +**T55 — Embedded channel discovery is per child emission** +A parent bridges a child emission and a handler adds or removes an Embedded Node Channel during that bridge delivery. +**Then:** the mutation does not change the already-snapshotted emission delivery, but later emissions use fresh discovery. + +**T56 — Contract-map key grammar** +A scope contains contract-map keys `""`, `type`, or `value`. +**Then:** the keys are invalid when discovered in an active scope; a key containing `/` remains stored raw and is escaped only when constructing runtime pointers. + +**T57 — Checkpoint raw key storage** +An accepted external channel has contract key `orders/incoming`. +**Then:** the checkpoint object member key is `orders/incoming`; the pointer used for Direct Write escapes it as `orders~1incoming`. + +**T58 — Default checkpoint identity mode** +An external channel declares no checkpoint identity mode. +**Then:** newness compares Content BlueIds of normalized checkpoint subjects, and an authored `eventId` field has no special meaning unless the concrete channel defines it. + +**T59 — Node BlueId checkpoint identity mode** +An external channel selects `nodeBlueId` checkpoint identity mode and supplies a checkpoint subject that is not valid BlueId Input. +**Then:** processing fails deterministically with `CheckpointError`. + +**T60 — Effect buffering order** +A handler calls host APIs in the order `emitEvent(E)`, `applyPatch(P)`, `terminate(graceful)`. +**Then:** the normalized result applies explicit gas first, then patch `P` and its cascades, then records/enqueues `E`, then terminates. + +**T61 — Buffered effects are discarded on handler throw** +A handler buffers a patch and then throws before returning a valid result. +**Then:** the buffered patch is not committed; the executing scope terminates fatally with `HandlerExecutionError`. + +**T62 — Runtime insertion normalization** +A patch value is a bare scalar, a wrapped scalar, or a list containing a recursively empty object. +**Then:** the value is normalized under `NORMALIZE_RUNTIME_NODE_FOR_INSERTION` before insertion, gas-byte calculation, event identity, and downstream contract discovery. + +**T63 — Processor-emitted event instances carry type** +The processor emits Document Update, Document Processing Initiated, Document Processing Terminated, or Document Processing Fatal Error. +**Then:** the delivered event instance includes a `type` pure reference to the corresponding runtime event type BlueId. + +**T64 — Document Update null sentinels** +A patch adds a previously absent node or removes an existing node. +**Then:** delivered Document Update payloads use `before: null` or `after: null` as runtime absence sentinels; handlers observe them in the delivered payload even though Blue Language identity cleaning removes null object fields. + +**T65 — Initialization Content BlueId timing** +A scope initializes for the first time. +**Then:** `documentId` is the scope Content BlueId immediately after Phase 1 embedded processing and before the initialized marker is written. + +**T66 — Termination Direct Write fallback** +The processor must write a terminated marker but the scope's existing `contracts` container is malformed. +**Then:** it makes one fallback attempt as defined in §11.4; if fallback also fails, the run aborts with `TerminationError`. + +**T67 — Extension role contract is unsupported, not inert** +A contract's effective type is a subtype of Contract but not Channel, Handler, or Marker, and the processor does not support that exact extension role type BlueId. +**Then:** the contract is unsupported and subject to must-understand or runtime-fatal rules. + +**G1 — Fixed value violation generalizes nearest node type** +Given `/price` typed `Price in EUR`, a patch replaces `/price/currency` with `USD`, and `/price` conforms to ancestor type `Price`. +**Then:** the processor generalizes `/price/type` to `Price` when policy permits. + +**G2 — Generalization propagates to parent** +The root type requires `/price: Price in EUR`; `/price` generalizes to `Price`; and the root conforms only to ancestor type `Global Product`. +**Then:** the processor also generalizes root type to the nearest valid permitted parent type. + +**G3 — Policy floor prevents over-generalization** +A Type Generalization Policy rule requires root to remain equal to or a subtype of `Bank Transfer PayNote`. +**Then:** a patch that would require generalizing above that type is runtime fatal with `GeneralizationRejected` or `GeneralizationNoValidType`. + +**G4 — Reject mode prevents generalization** +The effective generalization policy for a changed path is `reject`. +**Then:** a patch that would require generalization is runtime fatal and no tentative patch or generated write is committed. + +**G5 — Generalization writes produce Document Updates** +A patch requires generated writes to child and parent `/type` fields. +**Then:** the requested patch cascade is delivered first, followed by generated type-write cascades in deepest-to-root order. + +**G6 — Embedded child cannot generalize ancestor** +A patch executing inside an embedded child would require generalizing the parent scope to restore global type soundness. +**Then:** the child patch is runtime fatal unless the ancestor itself issued the patch or a future extension explicitly permits cross-scope generalization. + +### 15.3 Machine-readable fixtures (normative) + +The Blue Contracts 1.0 conformance suite MUST publish machine-readable fixtures for the vectors above. + +The canonical Blue Contracts 1.0 fixture package is part of the Blue Contracts 1.0 release artifact and is versioned with this specification. The release authority MUST publish the fixture package identity, either as a BlueId or as a content-addressed release artifact digest. This prose specification intentionally does not include placeholder fixture BlueIds. + +The fixture package identity for this Blue Contracts and Processor 1.0 publication is: + +```text +sha256:2f197ca3bbdc41b75e772777cc48e51019754347e1bee26b5f3209b71d9bd9ca +``` + +A fixture with `operation: processDocument` is executable unless it explicitly sets `informativeOnly: true`. + +An executable process fixture MUST include enough machine-readable input and expected output to be run by an independent implementation. At minimum it MUST include: + +- `initialDocument`; +- `event`; +- either `mockRuntime`, concrete supported runtime contract types, or a declared deterministic `processorCapabilities` entry sufficient to execute the fixture; +- at least one machine-checkable expected result such as `expectedStatus`, `expectedDocument`, `expectedDocumentPaths`, `expectedAbsentDocumentPaths`, `expectedRootEvents`, `expectedRootEventTypes`, `expectedTotalGas`, `expectedErrorCategory`, `expectedErrorCategories`, or `expectedNoDocumentMutation`. + +The free-text `assertions` field is informative only. It MUST NOT be the only evidence for a conformance-required executable fixture. + +A fixture that contains only prose assertions MUST set `informativeOnly: true` and MUST NOT be counted as passing executable conformance coverage. + +The release fixture manifest is the canonical list of required executable fixtures for this Blue Contracts 1.0 release. A conforming implementation MUST report the fixture package identity it passes. Release tooling MUST verify that every manifest entry exists, every fixture ID is unique, and no unlisted fixture YAML files are present. + +Registry fixtures, pointer utility fixtures, and other non-`processDocument` fixtures MAY use operation-specific inputs instead of `initialDocument` and `event`. The executable input requirements above apply only to `operation: processDocument`. + +The fixture package identity algorithm is part of the release artifact. To calculate `fixturePackageIdentity`: + +1. Normalize all line endings to LF. +2. Start the digest with the UTF-8 bytes of `manifest.yaml\n`. +3. Read `manifest.yaml`, replace the line beginning `fixturePackageIdentity:` with `fixturePackageIdentity: ""`, normalize line endings, and append those bytes. +4. Iterate manifest `fixtures` in manifest order. Do not sort paths separately. +5. For each fixture, append the UTF-8 bytes of `\n--- \n`, then append the fixture file bytes after LF line-ending normalization. +6. Encode the SHA-256 digest as lowercase hexadecimal prefixed by `sha256:`. + +Fixtures SHOULD include: + +```yaml +id: T21 +category: Checkpoint +initialDocument: ... +event: ... +expectedDocument: ... +expectedRootEvents: ... +expectedTotalGas: ... +``` + +Exact gas fixtures MUST specify contract implementations or mock contract result functions so that handler gas and emitted effects are deterministic. + +Fixture packages MUST include registry conformance fixtures proving that: + +- the Contract registry node hashes to its published BlueId; +- the Channel registry node hashes to its published BlueId; +- the Handler registry node hashes to its published BlueId; +- the Marker registry node hashes to its published BlueId; +- the Json Patch Entry registry node hashes to its published BlueId; +- the Contract Execution Result registry node hashes to its published BlueId; +- the Process Embedded registry node hashes to its published BlueId; +- the Processing Initialized Marker registry node hashes to its published BlueId; +- the Processing Terminated Marker registry node hashes to its published BlueId; +- the Channel Event Checkpoint registry node hashes to its published BlueId; +- the Type Generalization Policy registry node hashes to its published BlueId; +- the Type Generalization Rule registry node hashes to its published BlueId; +- the Document Update Channel registry node hashes to its published BlueId; +- the Triggered Event Channel registry node hashes to its published BlueId; +- the Lifecycle Event Channel registry node hashes to its published BlueId; +- the Embedded Node Channel registry node hashes to its published BlueId; +- the Document Update event registry node hashes to its published BlueId; +- the Document Processing Initiated event registry node hashes to its published BlueId; +- the Document Processing Terminated event registry node hashes to its published BlueId; +- the Document Processing Fatal Error event registry node hashes to its published BlueId; +- documentId fields use Text with BlueId-string semantics unless a formal canonical BlueId type is intentionally published; +- changing a processor-managed runtime type `description` changes the node BlueId. + +The Blue Contracts runtime registry manifest MUST make identity-bearing descriptions explicit. Each entry in the registry manifest MUST identify the registry kind, specification version, entry key, exact registry source node path, the exact preprocessed/canonical node used for BlueId calculation or deterministic preprocessing rule, published BlueId, conformance fixture package identity, and `semanticDescriptionIdentityBearing: true`. + +Release checks MUST verify that: + +- registry nodes are loaded from files, not reconstructed from implementation constants; +- registry file content hashes to the published BlueIds; +- runtime constants equal the calculated registry BlueIds; +- no canonical registry node is edited without updating its BlueId and fixture package identity; +- generated documentation is derived from registry nodes, or explicitly marked non-canonical. + +Fixture packages MAY use the following portable mock runtime format: + +```yaml +id: Txx +category: ... +initialDocument: ... +event: ... +mockRuntime: + channels: + - contract: /contracts/incoming + calls: + - when: + event: any + accepted: true + payload: + $event: true + gasConsumed: 0 + - contract: /a/contracts/orders + calls: + - when: + eventContentBlueId: "" + accepted: true + payload: + type: Example Payload + gasConsumed: 0 + termination: null + handlers: + - contract: /contracts/saveName + calls: + - when: + channelKey: incoming + payload: any + result: + gasConsumed: 0 + patches: + - op: replace + path: /name + val: Alice + triggeredEvents: [] + termination: null +expectedDocument: ... +expectedRootEvents: ... +expectedTotalGas: ... +``` + +Normative fixture rules: + +- `contract` is an absolute pointer to a contract entry. +- Calls are consumed in order. +- For channel mock calls, `accepted` is required. +- If `accepted: true`, `payload` is required unless the channel terminates before delivery. +- If `accepted: false`, `payload` MUST be absent. +- `gasConsumed` defaults to `0`. +- `patches` and `triggeredEvents` default to `[]`. +- `termination` defaults to `null` and may be `null` or `{ cause: graceful|fatal, reason?: Text }`. +- Handler mock `result` uses the abstract **Contract Execution Result** fields. +- `payload: { $event: true }` means the original input event node. +- Matchers such as `any`, `eventContentBlueId`, and `payload` are fixture matcher syntax, not Blue content. +- A mock result MUST NOT grant a contract effects outside its role unless the fixture explicitly declares a profile extension. +- A fixture is invalid if a channel or handler call occurs with no matching mock call. +- `processorCapabilities` names deterministic fixture-harness capabilities required to execute the fixture. A conforming implementation may satisfy a capability natively or through a test harness, but it MUST report unsupported capabilities as fixture execution failures. +- `typeGraph` is fixture provider syntax for dynamic type generalization and type-soundness fixtures. It maps fixture type names to exact BlueId strings and parent relationships used by the conformance runner's provider. +- `expectedNoDocumentMutation: true` means the selected document after the operation is semantically equal to `initialDocument` under the selected-document normalization rules. +- `expectedDocumentUpdateOrder` is a machine-checkable list of Document Update changed paths in delivery order when a fixture needs to prove cascade ordering. +- Error fixtures MAY use `expectedErrorCategory: ` when exactly one primary diagnostic is asserted. +- Error fixtures MAY use `expectedErrorCategories: [, ...]` when more than one category is acceptable. +- Fixtures that intentionally contain multiple independent errors MUST assert only failure, or MUST list all acceptable categories. +- This mock schema is for conformance fixtures only; it is not a contract language. + +### 15.4 Fixture harness capabilities (normative for fixtures) + +Fixture harness capabilities are deterministic conformance-fixture tools. They are not Blue Contracts core contract languages and MUST NOT be treated as canonical runtime registry types. + +`blue-contracts-fixture-scripted-runtime-v1` defines a scripted fixture runtime for channel and handler behavior. It recognizes fixture-only channel and handler contracts whose type BlueIds are declared by the fixture package or whose behavior is supplied by `mockRuntime`. These fixture-only types are not part of the canonical runtime registry. + +A scripted external channel accepts an event when its configured `mockRuntime.channels[].calls[].when` matcher matches and `accepted: true`. If a fixture marks a contract as a generic fixture external channel and supplies no more specific matcher, the channel accepts all events. A rejected channel call has `accepted: false` and MUST NOT supply `payload`. An accepted channel call supplies a channelized `payload`, optional non-negative `gasConsumed`, and optional `termination`. + +A scripted handler produces a **Contract Execution Result** from either `mockRuntime.handlers[].calls[].result` or fixture-only fields on the contract entry. The following fixture-only handler fields map to buffered result effects: + +| Field | Fixture meaning | +|---|---| +| `patches` | List of Json Patch Entry objects buffered as handler patches. | +| `triggeredEvents` | List of Blue event nodes buffered as emitted Triggered events. | +| `gasConsumed` | Non-negative explicit gas consumed by the contract. | +| `consumeGas` | Fixture shorthand for explicit gas or a host `consumeGas` API call. | +| `termination` | `null`, `graceful`, `fatal`, or `{ cause: graceful|fatal, reason?: Text }`. | +| `emitInvalidEvent` | Emits a deliberately invalid event for error fixtures. | +| `hostApiCalls` | Ordered fixture host API calls used to prove buffering semantics. | + +`hostApiCalls` supports these operations: + +```yaml +hostApiCalls: + - emitEvent: + - applyPatch: { op: replace, path: /x, val: y } + - consumeGas: 5 + - terminate: { cause: graceful, reason: done } + - throw: { category: HandlerExecutionError } +``` + +These calls are fixture-harness host API calls. They are buffered according to §3.11 and are not immediate side effects. A `throw` aborts the current contract call before a valid result is returned; effects buffered by that throwing call are discarded unless a fixture explicitly says otherwise. + +The scripted runtime also defines these helper fields used by the release fixture package: + +| Field | Fixture meaning | +|---|---| +| `addDocumentUpdateChannelAt` | Fixture shorthand for a handler patch that installs a Document Update Channel at the given runtime pointer. | +| `documentUpdatePath` | Path value used with `addDocumentUpdateChannelAt` for the installed channel's watched `path`. | +| `childEmissions` | Fixture-provided recorded child emissions for Embedded Node bridge-order fixtures. | +| `bridgeMutations` | Fixture-provided mutations that occur during bridge delivery to test per-emission snapshots. | +| `forcedFatal` | Fixture instruction that forces a deterministic runtime fatal at the given scope. | +| `orderLog` | Ordinary fixture document field used to record observable ordering when a fixture expects it. | + +Matchers under `when` are fixture matcher syntax. `event: any` matches any Processing Event. `payload: any` matches any channelized payload. `eventContentBlueId` matches the Content BlueId of the normalized Processing Event. Exact map/list/scalar matcher values match by Blue node equality after runtime insertion normalization. + +`blue-contracts-fixture-type-graph-v1` defines a fixture provider for dynamic type generalization and type-soundness tests. It is fixture provider syntax only, not canonical Blue type syntax. + +```yaml +typeGraph: + Price: + blueId: + PriceInEUR: + blueId: + parent: Price + fixedValues: + /currency: EUR + Product: + blueId: + fields: + /price: + type: Price +``` + +`blueId` is the type identity used in fixture documents. `parent` defines the type-chain parent used for nearest-valid generalization. `fixedValues` defines path/value invariants. `fields` defines child-type constraints used for parent revalidation. Fixture paths in `typeGraph` are Blue Runtime Pointers unless stated otherwise. + +### 15.5 Fixture assertion fields (normative for fixtures) + +Any fixture field beginning with `expected` that is not defined here or in an operation-specific fixture manifest schema is invalid. + +| Field | Meaning | +|---|---| +| `expectedStatus` | Abstract fixture status: `success`, `runtime-fatal`, `capability-failure`, `invalid-processing-document`, or `invalid-input`. `invalid-processing-document` is the preferred fixture status when the selected document is not valid enough to enter runtime; `invalid-input` remains available for invalid event, fixture, or processor API input cases. | +| `expectedCapabilityFailure` | Boolean legacy assertion. If true, implies capability failure with no mutation, no gas, and no root events unless explicitly overridden. | +| `expectedNoDocumentMutation` | Final selected document is semantically equal to `initialDocument` after selected-document normalization. | +| `expectedDocument` | Exact selected document expected after the operation. | +| `expectedDocumentPaths` | Map from Blue Runtime Pointer to expected Blue node/value shape in the final selected document. | +| `expectedDocumentPathExists` | List of Blue Runtime Pointers that must exist in the final selected document. | +| `expectedDocumentPathValues` | List form of path/value assertions in the final selected document. | +| `expectedAbsentDocumentPaths` | Blue Runtime Pointers that must not exist in the final selected document. | +| `expectedAbsentDocumentPathValues` | Path/value pairs that must not match in the final selected document. | +| `expectedDocumentUpdates` | Expected Document Update payload assertions. | +| `expectedDocumentUpdateOrder` | Ordered list of changed paths for Document Update deliveries. | +| `expectedRootEvents` | Exact root outbox events. | +| `expectedRootEventTypes` | Ordered root event type BlueIds or symbolic fixture aliases. | +| `expectedRootEventPathValues` | Path/value assertions inside root outbox events. | +| `expectedRootEventCount` | Exact root outbox event count. | +| `expectedProcessorEventTypes` | Expected processor-emitted event type BlueIds by symbolic event name. | +| `expectedTotalGas` | Exact total gas. | +| `expectedExactGas` | Alias for exact total gas. A fixture MUST NOT use both `expectedTotalGas` and `expectedExactGas` unless they are equal. | +| `expectedTotalGasMin` | Lower bound on total gas. It is allowed only for non-exact smoke or performance-tolerant fixtures. | +| `expectedErrorCategory` | Exact diagnostic category. Fixture should isolate one primary error. | +| `expectedErrorCategories` | List of acceptable diagnostic categories for intentionally ambiguous multi-error cases. | +| `expectedFailureReasonContains` | Legacy substring check for diagnostic text. It is weaker than `expectedErrorCategory` and SHOULD NOT be used by new fixtures unless no category is stable. | +| `expectedCheckpointLastEvents` | Expected checkpoint subjects under raw channel keys in `lastEvents`. | +| `expectedRuntimeInsertionNormalizedValues` | Assertions about selected-document form after `NORMALIZE_RUNTIME_NODE_FOR_INSERTION`. | +| `expectedGasByteView` | Assertion describing which normalized form is used for patch or emitted-event gas byte calculation. | +| `expectedEffectApplicationOrder` | Ordered observable effect labels proving buffered effect order. | +| `expectedStoredObjectKeys` | Raw object keys stored in selected document at the given path. | +| `expectedEmbeddedDeliveryOrder` | Exact embedded scope processing or bridge delivery order. | +| `expectedTriggeredDeliveryOrder` | Exact Triggered FIFO event/channel delivery order. | +| `expectedTriggeredFifoAfterDocumentUpdates` | Boolean assertion that Triggered FIFO drain occurs only after all requested and generated Document Update cascades in the fixture. | +| `expectedPointerReads` | Expected runtime-pointer read paths used by pointer utility fixtures. | +| `expectedPointerWrites` | Expected runtime-pointer write paths used by pointer utility or Direct Write fixtures. | +| `expectedInitializationContentBlueIdInput` | Expected input/timing used to calculate initialization Content BlueId. | +| `expectedTerminationFallback` | Expected termination Direct Write fallback behavior. | +| `expectedBlueId` | Expected BlueId for registry or BlueId-calculation fixtures. | +| `expectedOriginalBlueId` | Expected original BlueId before mutation in identity-change fixtures. | +| `expectedRuntimeBlueIds` | Map of runtime type constants to expected registry BlueIds. | +| `expectedValid` | Boolean validity result for pointer utility or validation fixtures. | +| `expectedDescendantOrEqual` | Boolean result for runtime-pointer descendant-or-equal utility fixtures. | + +--- + +## 16. Worked Examples + +### 16.1 Minimal external event handler (informative) + +```yaml +contracts: + incoming: + type: Example External Channel + order: 0 + saveName: + type: Example Patch Handler + channel: incoming + patch: + op: replace + path: /name + val: Alice +``` + +When the `incoming` channel accepts an event, `saveName` patches `/name`. The patch triggers a Document Update cascade from root to root. + +### 16.2 Embedded child event bridge (informative) + +```yaml +contracts: + embedded: + type: Process Embedded + paths: [/payment] + paymentEvents: + type: Embedded Node Channel + childPath: /payment + forwardPayment: + type: Example Forward Handler + channel: paymentEvents + +payment: + contracts: + incoming: + type: Example External Channel + emitReceipt: + type: Example Emit Handler + channel: incoming +``` + +The root processes `/payment` first. If `/payment` emits a receipt event, root's `paymentEvents` channel bridges it in Phase 4. `forwardPayment` may re-emit a root-scope event, which is returned in root `triggered_events` and can be locally drained if root has a Triggered Event Channel. + +### 16.3 Document Update watcher (informative) + +```yaml +contracts: + watchAmount: + type: Document Update Channel + path: /amount + onAmount: + type: Example Audit Handler + channel: watchAmount +``` + +Any successful patch at `/amount` or below it triggers `watchAmount`. A patch at `/amount/currency` matches; a patch at `/status` does not. + +### 16.4 Checkpoint behavior (informative) + +```yaml +contracts: + orders: + type: Example Ordered Event Channel + handleOrder: + type: Example Order Handler + channel: orders +``` + +On first accepted external delivery requiring newness evaluation, the processor Direct Writes: + +```yaml +contracts: + checkpoint: + type: Channel Event Checkpoint + lastEvents: {} +``` + +If the event is new and `handleOrder` completes successfully, the processor Direct Writes: + +```yaml +contracts: + checkpoint: + type: Channel Event Checkpoint + lastEvents: + orders: checkpoint_subject(orders, event, delivery) +``` + +For the default checkpoint subject, this is the entire incoming event node. + +No Document Update is emitted for either Direct Write. + +### 16.5 End-to-end root update, audit, and checkpoint (informative) + +```yaml +contracts: + incoming: + type: Example External Channel + payloadType: Example Status Command + setStatus: + type: Example Patch Handler + channel: incoming + patch: + op: replace + path: /status + val: accepted + statusUpdates: + type: Document Update Channel + path: /status + emitAudit: + type: Example Audit Emit Handler + channel: statusUpdates + auditEvents: + type: Triggered Event Channel + storeAudit: + type: Example Audit Sink Handler + channel: auditEvents + +status: pending +``` + +Expected high-level order: + +1. During Phase 3, `incoming` is evaluated as an external channel candidate and accepts the input event. +2. If `contracts/checkpoint` is absent, the processor Direct Writes an empty checkpoint before newness evaluation. +3. `setStatus` receives the channelized payload and patches `/status` to `accepted`. +4. The patch produces a root Document Update cascade; `statusUpdates` receives the update payload. +5. `emitAudit` emits an audit event, which is recorded under root and appended to root's Triggered FIFO. +6. After successful external channel handling, the processor Direct Writes `lastEvents.incoming` to `checkpoint_subject(incoming, event, delivery)`, which is the incoming event node under the default checkpoint subject. +7. During Phase 5, `auditEvents` drains the audit event and `storeAudit` handles it. + +No exact BlueIds are shown here; concrete fixture packages provide exact canonical identities when needed. + +--- + +## Appendix A — Runtime Type Catalog + +Appendix A defines the canonical runtime types referenced throughout Blue Contracts and Processor 1.0. + +The canonical Blue runtime type registry supplies the exact Blue nodes and BlueIds for these types. The registry is the authority for exact string content, canonicalized node content, and published BlueIds. + +The canonical runtime type nodes below are intentionally self-describing. Their `description` fields are normative, identity-bearing content. Changing a canonical runtime description changes the node's BlueId and therefore defines a different runtime type. + +The YAML blocks in this appendix are intended registry source nodes. If a block uses symbolic core type aliases such as `Text`, `Integer`, `List`, or `Dictionary`, those aliases are resolved by the standard Blue Language baseline preprocessing environment before the canonical runtime registry BlueId is published. The registry release MUST publish the exact nodes and BlueIds it uses. + +Non-normative examples, rationale, translations, and implementation notes are not part of canonical runtime type nodes unless explicitly included in the registry node. + +### A.1 Base runtime type nodes + +#### Contract + +```yaml +name: Contract +description: > + Base Blue Contracts and Processor 1.0 runtime type for executable or + processor-interpreted declarations under an active scope's contracts map. + A Contract is scope-local, identity-bearing Blue content. The processor + discovers materialized contract entries in the selected document, resolves + each entry far enough to identify its effective runtime type BlueId, and + either executes supported behavior or applies must-understand and fatal + rules. Contract entries are sorted by effective order and contract-map key + when ordering is required. A Contract by itself has no executable behavior; + concrete subtypes define Channel, Handler, Marker, or extension semantics. +order: + type: Integer + description: > + Optional deterministic sort key within a scope. Missing order is treated + as 0. Ordering compares order first, ascending, then contract-map key in + lexicographic Unicode code-point order. +``` + +#### Json Patch Entry + +```yaml +name: Json Patch Entry +description: > + Blue Contracts and Processor 1.0 runtime patch request produced by handlers. + A Json Patch Entry describes one deterministic mutation request against the + selected document. Only add, replace, and remove are supported. The path is + a Blue Runtime Pointer and must not target the document root. Despite its + historical name, Json Patch Entry is not full RFC 6902; it uses Blue-specific + upsert, auto-materialization, runtime insertion normalization, and post-patch + type-soundness rules. The val field is required for add and replace and must + be absent for remove. Patches are applied in result order; each successful + patch triggers its full Document Update cascade before the next patch is + applied. Field is named val, not value, because value is Blue's scalar + payload wrapper. +op: + type: Text + description: > + Required patch operation. Allowed values are add, replace, and remove. + schema: + required: true + enum: [add, replace, remove] +path: + type: Text + description: > + Required absolute Blue Runtime Pointer identifying the mutation target. + The empty string is invalid. The root pointer / is not a valid runtime + patch target for handlers or channels. + schema: + required: true +val: + description: > + Patch payload for add and replace. It may be any valid Blue node. It must + be absent for remove. +``` + +#### Contract Execution Result + +```yaml +name: Contract Execution Result +description: > + Abstract processor result shape used to normalize effects returned by a + supported handler or by a supported channel type that explicitly permits + channel results. In Blue Contracts 1.0 core, patches and Triggered emissions + are handler effects. External channels must not return patches or Triggered + events unless a supported extension explicitly grants that capability. When + a result is applied, the processor applies explicit gas first, then patches + in order with immediate cascades, then emitted events in order, then a + requested termination. Invalid present result fields cause runtime fatal + termination before any effects from that result are applied, except for + overhead already charged. +patches: + type: List + itemType: + type: Json Patch Entry + description: > + Optional list of patch entries. Missing is equivalent to an empty list. + Patches are applied in list order. Each successful patch triggers its + Document Update cascade before the next patch. +triggeredEvents: + type: List + description: > + Optional list of Blue event nodes to record and enqueue as Triggered + events after all patches from the same result are applied. Missing is + equivalent to an empty list. +gasConsumed: + type: Integer + description: > + Optional non-negative explicit gas consumed by the contract. Missing is + equivalent to 0. Negative gas is invalid and causes runtime fatal + termination. +termination: + description: > + Optional termination request. If present, it requests graceful or fatal + termination after gas, patches, and emitted events from the same result + have been processed in the required order. +``` + +Canonical Blue field names use camelCase. Pseudocode may use snake_case aliases for readability; they refer to the same abstract result fields. + +### A.2 Contract role runtime type nodes + +#### Channel + +```yaml +name: Channel +type: Contract +description: > + Runtime contract role for event entry points within a scope. A Channel + evaluates an incoming event or processor-managed delivery and either rejects + it or accepts it by producing one channelized payload for same-scope + handlers bound to that channel key. A Channel may consume gas and may request + termination only through processor-defined interfaces. A Channel must not + directly mutate the selected document. Processor-managed channel subtypes are + fed only by the processor and are never directly entered by external events. +event: + description: > + Optional channel-specific matcher or matcher configuration. The meaning is + defined by the concrete channel type. +``` + +#### Handler + +```yaml +name: Handler +type: Contract +description: > + Runtime contract role for deterministic logic bound to exactly one channel + in the same scope. A Handler is eligible only for deliveries produced by the + same-scope channel named by its channel field. A Handler may request patches, + emit Blue event nodes, consume non-negative gas, or request termination. It + has no other permitted observable side effects. For a given document + snapshot, channelized payload, handler contract content, and allowed context, + a Handler must produce deterministic results. +channel: + type: Text + description: > + Required same-scope contract-map key of the channel this handler binds to. + Handlers do not bind to channels in parent, child, embedded, or referenced + nodes. + schema: + required: true +event: + description: > + Optional handler-specific matcher for the channelized payload. The meaning + is defined by the concrete handler type or extension runtime. +``` + +#### Marker + +```yaml +name: Marker +type: Contract +description: > + Runtime contract role for processor-observed state or policy. Markers do not + run contract logic. The processor obeys supported marker semantics when a + supported marker appears at the correct reserved key. Unsupported marker + types in an active scope are subject to must-understand rules. Required + processor-managed markers have reserved keys under contracts and must not + appear under other keys. +``` + +### A.3 Processor-managed marker runtime type nodes + +#### Process Embedded + +```yaml +name: Process Embedded +type: Marker +description: > + Required processor-managed marker at contracts/embedded. It declares + embedded child scopes beneath the current scope. The processor reads paths + dynamically during embedded traversal, re-reads after each processed child, + processes each normalized child path at most once per parent invocation, and + rejects malformed, duplicate, self-root, or non-object embedded scope paths + according to the processor rules. Missing child paths are skipped and marked + processed for the current invocation. +paths: + type: List + itemType: + type: Text + description: > + Required list of scope-relative Blue Runtime Pointers identifying embedded + child roots. Each path must begin with /, must not be /, and must resolve + inside the current scope's pointer domain. Duplicate resolved child paths + are invalid. + schema: + required: true + uniqueItems: true +``` + +#### Processing Initialized Marker + +```yaml +name: Processing Initialized Marker +type: Marker +description: > + Required processor-managed marker at contracts/initialized. It records that + a scope has completed first-run initialization. The processor publishes the + Document Processing Initiated lifecycle event before writing this marker. + The marker is written by a processor-managed patch that triggers the normal + Document Update cascade. The marker stores the pre-initialization Content + BlueId of the scope subtree as documentId. +documentId: + type: Text + description: > + Required BlueId string for the pre-initialization Content BlueId of the + scope subtree. The value must be a valid Blue Language BlueId string. + schema: + required: true +``` + +#### Processing Terminated Marker + +```yaml +name: Processing Terminated Marker +type: Marker +description: > + Required processor-managed marker at contracts/terminated. It records final + runtime state for a scope. A scope with a valid pre-existing terminated + marker is inactive for processing: it incurs scope-entry gas when entered, + but it is not initialized, matched, bridged, drained, checkpointed, or run. + Termination markers are written by processor Direct Write and do not emit + Document Update cascades. An ancestor may replace or remove an embedded child + root containing this marker as a whole. +cause: + type: Text + description: > + Required termination cause. fatal means deterministic runtime fatal + termination. graceful means contract-requested non-error termination. + schema: + required: true + enum: [fatal, graceful] +reason: + type: Text + description: > + Optional human-readable deterministic reason supplied by the processor or + contract. It is content in the selected document and in emitted lifecycle + events when present. +``` + +#### Channel Event Checkpoint + +```yaml +name: Channel Event Checkpoint +type: Marker +description: > + Required processor-managed marker at contracts/checkpoint. It stores + idempotency state for external channel deliveries. Checkpoints are never used + for processor-managed Document Update, Triggered Event, Lifecycle Event, or + Embedded Node channels. The processor creates this marker lazily when an + external channel accepts an event and no checkpoint exists. It updates + lastEvents by Direct Write after successful external channel processing. + Checkpoint Direct Writes do not emit Document Update cascades. By default, + lastEvents stores the normalized checkpoint subject for each external + channel's raw contract-map key, and newness is determined by the channel's + effective checkpointIdentityMode. Pointer escaping is used only when writing + the member by Direct Write; it is not part of the stored key. +lastEvents: + type: Dictionary + keyType: + type: Text + description: > + Required dictionary keyed by raw external-channel contract-map key. Each + value is the previous normalized checkpoint subject for that external + channel. The default subject is the preprocessed incoming event node. + schema: + required: true +``` + +#### Type Generalization Policy + +```yaml +name: Type Generalization Policy +type: Marker +description: > + Optional processor-managed marker at contracts/generalization. It controls + whether post-patch type soundness may be restored by dynamic type + generalization in the current scope. If absent, the processor uses + defaultMode nearest-valid with no rules. Handlers and channels must not + patch this marker or its descendants in Blue Contracts and Processor 1.0. +defaultMode: + type: Text + description: > + Optional default generalization mode for paths not governed by a more + specific rule. Missing means nearest-valid. nearest-valid permits the + processor to choose the nearest valid permitted ancestor type. reject makes + a patch fatal when restoring soundness would require generalization. + schema: + enum: [nearest-valid, reject] +rules: + type: List + itemType: + type: Type Generalization Rule + description: > + Optional ordered list of path-specific generalization rules. The most + specific matching path wins; if two rules normalize to the same path, the + later rule in list order wins. +``` + +#### Type Generalization Rule + +```yaml +name: Type Generalization Rule +description: > + Rule entry used by Type Generalization Policy. It governs a scope-relative + subtree path and can reject dynamic generalization or require the generated + type to remain equal to or a subtype of a declared floor type. +path: + type: Text + description: > + Required scope-relative Blue Runtime Pointer identifying the governed + subtree. The pointer is normalized against the scope containing the policy + marker before rule selection. + schema: + required: true +mode: + type: Text + description: > + Optional mode for this path. Missing means the policy defaultMode. reject + forbids generalization at the governed path. nearest-valid permits the + nearest valid permitted ancestor type. + schema: + enum: [nearest-valid, reject] +mustRemainSubtypeOf: + description: > + Optional type reference floor. If present, any generated type selected for + the governed path must be equal to or a subtype of this type. +``` + +### A.4 Processor-managed channel runtime type nodes + +#### Document Update Channel + +```yaml +name: Document Update Channel +type: Channel +description: > + Processor-managed channel fed after each successful runtime patch. For every + successful patch, the processor discovers matching Document Update Channels + from the post-patch selected document and delivers one Document Update + payload per participating scope, from the patch origin scope toward root. A + Document Update Channel matches when the absolute changed path is + descendant-or-equal to the channel path resolved against the receiving scope. + The channel is never checkpoint-gated and is never entered directly by + external events. Triggered FIFO is not drained during Document Update + cascades. +path: + type: Text + description: > + Required scope-relative Blue Runtime Pointer watched by this channel. + The channel matches patches whose absolute changed path is + descendant-or-equal to ABS(scope, path). + schema: + required: true +``` + +#### Triggered Event Channel + +```yaml +name: Triggered Event Channel +type: Channel +description: > + Processor-managed channel that drains events emitted into a scope's Triggered + FIFO. A scope drains its Triggered FIFO at most once per PROCESS invocation, + during the scope's FIFO phase. Triggered FIFO delivery does not occur during + Document Update cascades. If a scope has no Triggered Event Channel, emitted + events are still recorded and may be bridged to a parent, but they are not + locally delivered. +``` + +#### Lifecycle Event Channel + +```yaml +name: Lifecycle Event Channel +type: Channel +description: > + Processor-managed channel for lifecycle events emitted by the processor at a + scope. Lifecycle events include Document Processing Initiated and Document + Processing Terminated. Lifecycle events are delivered through Lifecycle Event + Channels, recorded as bridgeable emissions for parent Embedded Node Channels, + and, at root, appended to the root outbox. Lifecycle events are not enqueued + into the Triggered FIFO unless a lifecycle handler explicitly emits a + Triggered event. +``` + +#### Embedded Node Channel + +```yaml +name: Embedded Node Channel +type: Channel +description: > + Processor-managed channel in a parent scope that bridges recorded emissions + from a processed embedded child scope. Bridging occurs after the parent has + handled the external event and before the parent drains its Triggered FIFO. + Child emissions are delivered in the order recorded by the child, and child + scopes are bridged in the parent invocation's processed-path insertion order. + Bridge gas is charged only when an emission is actually delivered to at + least one matching Embedded Node Channel. +childPath: + type: Text + description: > + Required scope-relative Blue Runtime Pointer identifying the embedded child + root whose emissions this channel receives. The resolved child path is + compared with the processed child scope path. + schema: + required: true +``` + +### A.5 Processor-emitted event runtime type nodes + +#### Document Update + +```yaml +name: Document Update +description: > + Processor-emitted event delivered through Document Update Channels after each + successful runtime patch. One Document Update payload is created per + participating receiving scope for that patch. The path is relative to the + receiving scope. before and after are immutable snapshots of the changed + path before and after the patch, using null when the changed path was absent + or removed. All handlers at the same receiving scope for the same patch see + the same immutable payload object. +op: + type: Text + description: > + Required operation that caused the update: add, replace, or remove. + schema: + required: true + enum: [add, replace, remove] +path: + type: Text + description: > + Required path of the changed node, relative to the receiving scope. / means + the receiving scope root itself. + schema: + required: true +before: + description: > + Snapshot at the changed path before the patch, or null when absent. +after: + description: > + Snapshot at the changed path after the patch, or null when removed. +``` + +#### Document Processing Initiated + +```yaml +name: Document Processing Initiated +description: > + Processor-emitted lifecycle event published at a scope before the Processing + Initialized Marker is written. It represents first-run initialization of + that scope for the current selected document state. At root, this event is + also recorded in the root outbox. At non-root scopes, it is bridgeable to a + parent Embedded Node Channel. The documentId field is the pre-initialization + Content BlueId of the scope subtree. +documentId: + type: Text + description: > + Required BlueId string for the pre-initialization Content BlueId of the + scope subtree. + schema: + required: true +``` + +#### Document Processing Terminated + +```yaml +name: Document Processing Terminated +description: > + Processor-emitted lifecycle event published at a scope when that scope + terminates gracefully or fatally. It is delivered through Lifecycle Event + Channels, recorded as bridgeable for parent Embedded Node Channels, and, at + root, included in the root outbox. For a root fatal termination, this event + appears before Document Processing Fatal Error. +cause: + type: Text + description: > + Required termination cause: fatal or graceful. + schema: + required: true + enum: [fatal, graceful] +reason: + type: Text + description: > + Optional deterministic reason for termination. +``` + +#### Document Processing Fatal Error + +```yaml +name: Document Processing Fatal Error +description: > + Processor-emitted root outbox event appended when root processing terminates + fatally. It is appended after Document Processing Terminated for the same + root termination sequence. It is outbox-only: it is not delivered to + Lifecycle Event Channels, is not recorded as bridgeable, and is not placed in + the Triggered FIFO. +reason: + type: Text + description: > + Optional deterministic fatal error reason. +``` + +### A.6 Optional external-channel example + +The following is an informative example of how a profile or application may define an external channel type. It is not a Blue Contracts and Processor 1.0 core runtime type and MUST NOT be included in the canonical runtime registry unless intentionally published as a separate extension type with its own BlueId. + +```yaml +name: Example Ordered Event Channel +type: Channel +description: > + Illustrative external channel that accepts events with a monotonically + increasing sequence number. This is not a required Blue Contracts and + Processor 1.0 core runtime type. +sequencePath: + type: Text + description: Optional event pointer to a sequence value. +payloadType: + type: Text + description: > + Optional BlueId string for the expected Blue type or schema of the + channelized payload delivered to handlers. +checkpointSubject: + type: Text + description: > + Optional checkpoint subject policy for this illustrative channel. + schema: + enum: [incoming-event, channelized-payload, channel-defined] +newnessPolicy: + type: Text + description: Optional illustrative newness policy. + schema: + enum: [content-idempotent, increasing-sequence] +``` + +This example is informative and MUST NOT be included in the core runtime registry unless intentionally published as an extension type. + +--- + +## Appendix B — Common Implementer Mistakes + +This appendix is informative. + +### B.1 Do not execute `contracts` during Blue Language processing + +The Blue Language treats `contracts` as identity-bearing content. Runtime execution happens only under this processor specification. + +### B.2 Do not drain Triggered events during Document Update cascades + +Cascades enqueue Triggered events. FIFO drain happens once in Phase 5. + +### B.3 Do not let children patch outside their subtree + +A child scope can patch strict descendants of itself only. It cannot replace its own root and cannot patch siblings. + +### B.4 Do not let parents patch inside embedded children + +A parent can replace or remove a child root as a whole, but cannot patch inside it. + +### B.5 Do not emit Document Updates for Direct Writes + +Checkpoint creation, checkpoint update, and termination marker writes are Direct Writes. They are visible state changes but do not cascade. + +### B.6 Do not create checkpoints during initialization + +Checkpoint creation is lazy and external-channel-specific. + +### B.7 Do not skip must-understand + +Unsupported active contracts must be detected before mutation whenever they are in the initial active processing closure. + +### B.8 Do not use wall-clock or random behavior in contracts + +Determinism is part of conformance. + +### B.9 Do not treat lifecycle events as Triggered events + +Lifecycle events are delivered through Lifecycle Event Channels and recorded for bridging. They are not enqueued into the Triggered FIFO unless a lifecycle handler emits them explicitly. + +### B.10 Do not update checkpoints for stale or terminated channels + +Checkpoint entries update only after successful external channel processing. + +### B.11 Do not concatenate runtime pointer strings + +Use `ABS` or `JOIN_SCOPE_PATH`. Root scope `/` plus `/contracts/x` must produce `/contracts/x`, not `//contracts/x`. + +### B.12 Do not pre-filter external channels for free + +Candidate external channels are charged before acceptance or rejection. Optimizations must preserve the same candidate set and gas. + +### B.13 Do not compare source bytes for reserved marker preservation + +Reserved processor marker preservation is Blue-node semantic equality after normalization, not YAML or JSON byte equality. + +### B.14 Do not let dispatch mutation rewrite the current call list + +Dispatch snapshots freeze which handlers/channels and executable contract content are called for the current delivery or Phase 3 candidate loop. Contract mutations affect later discovery points only. + +### B.15 Do not store escaped checkpoint keys + +`lastEvents` object members use raw contract-map keys. Escape `/` and `~` only when constructing a Blue Runtime Pointer for Direct Write. + +### B.16 Do not apply handler effects immediately + +Host APIs that look like `emitEvent`, `applyPatch`, or `terminate` buffer effect requests. Observable mutation, enqueueing, and termination happen only when the normalized result is applied. + +### B.17 Do not commit type-unsound patches + +After every patch, restore type soundness before delivering any Document Update cascade. If required generalization is rejected or has no valid target, the tentative patch is not committed. + +### B.18 Do not reuse Language view-path parsing for runtime pointers + +Blue Runtime Pointer `/` denotes the runtime root and is not a patch target. Blue Language view paths use RFC 6901 root `""`. + +--- + +## Appendix C — Processor Result Status and Diagnostic Categories + +This appendix is normative for conformance reporting. It does not require a particular host-language exception class, wire format, or exact error message. + +A conforming processor API MAY expose any host-language result type. Blue Contracts 1.0 conformance fixtures use this abstract result shape: + +```yaml +status: success | capability-failure | runtime-fatal | invalid-input +newDocument: +rootEvents: +totalGas: +errorCategory: +fatalScope: +``` + +The status values mean: + +| Status | Meaning | +|---|---| +| `success` | Processing completed without capability failure, invalid input, or runtime fatal. | +| `capability-failure` | Initial must-understand or capability checking failed before runtime mutation. | +| `runtime-fatal` | Runtime began and a deterministic fatal condition terminated the executing scope. | +| `invalid-input` | The processing document, event, fixture, or processor API input is not valid enough to enter runtime. | + +Conformance-visible deterministic failures MUST be classifiable into one of these categories: + +| Category | Meaning | +|---|---| +| `InvalidProcessingDocument` | The input document is not a valid Processing Document. | +| `InvalidEvent` | The input event is malformed, unresolved Source syntax under the runtime API, or otherwise invalid. | +| `UnsupportedContract` | A required active contract or extension role is unsupported. | +| `InvalidReservedMarker` | A processor-reserved marker has an invalid type, key, or shape. | +| `InvalidRuntimeType` | A runtime type reference is malformed, unavailable, or incompatible with the expected role. | +| `ProviderUnavailable` | Required provider content is unavailable. | +| `ProviderBlueIdMismatch` | Provider-returned content does not verify against the requested BlueId. | +| `InvalidPatch` | A patch operation, pointer, path target, or operation/value combination is invalid. | +| `BoundaryViolation` | A patch violates scope, embedded boundary, root, or self-root rules. | +| `ReservedKeyWrite` | A handler or channel attempted to write a protected processor-reserved path. | +| `InvalidRuntimePointer` | A Blue Runtime Pointer is malformed or cannot be interpreted in its context. | +| `InvalidPatchValue` | A patch `val` fails runtime insertion normalization or Blue Language validity. | +| `TypeSoundnessViolation` | A tentative selected document cannot satisfy required type/schema soundness. | +| `GeneralizationRejected` | Effective Type Generalization Policy rejects required generalization. | +| `GeneralizationNoValidType` | No nearest valid permitted ancestor type exists for required generalization. | +| `ContractResultShapeError` | A handler or channel returned an invalid result shape or disallowed effect. | +| `HandlerExecutionError` | A handler fails during execution before returning a valid result. | +| `ChannelExecutionError` | A channel fails during matching, payload creation, or supported execution. | +| `CheckpointError` | Checkpoint creation, identity, newness, or update fails. | +| `GasError` | Deterministic gas accounting or budget policy fails. | +| `EmbeddedScopeError` | Embedded traversal, path normalization, no-resurrection, or child-scope setup fails. | +| `TerminationError` | Termination marker Direct Write and fallback cannot complete deterministically. | + +An invalid document or run may contain multiple independent errors. Blue Contracts 1.0 does not define a universal precedence order for simultaneous failures. Conformance fixtures that assert an exact error category MUST isolate one primary error so that a conforming implementation can deterministically report that category without ambiguity. If a fixture intentionally contains multiple independent errors, it MUST assert only that the operation fails, or it MUST explicitly declare acceptable error categories. + +--- + +*End of Blue Contracts and Processor Specification 1.0.* diff --git a/src/test/resources/language/1.0/spec.md b/src/test/resources/language/1.0/spec.md new file mode 100644 index 0000000..ae2ba25 --- /dev/null +++ b/src/test/resources/language/1.0/spec.md @@ -0,0 +1,2932 @@ +# Blue Language Specification 1.0 + +> **Scope.** This document defines Blue's content language: the node model, Blue Graph, Blue Documents, typing, overlays, schema constraints, preprocessing, resolution, expansion, collapse, canonicalization, minimization, and BlueId. It does **not** define runtime execution, handlers, events, channels, gas, or contract processing. Those belong to the separate **Blue Contracts and Processor Specification**. + +Where this document references core types such as **Text**, **Integer**, **Double**, **Boolean**, **Dictionary**, and **List**, their canonical type definitions and canonical BlueIds are supplied by the canonical Blue type registry. Appendix A defines their normative semantics and shows the intended canonical registry nodes. The registry is the authority for the exact node content and BlueIds. + +Canonical core type nodes are identity-bearing Blue content. Their `description` fields define type semantics and affect BlueId. Editing a canonical description changes the type identity and therefore MUST be treated as a registry/versioning change, not as ordinary documentation editing. + +The Blue Language 1.0 release is defined by this prose specification, the canonical Blue type registry, and the Blue Language 1.0 conformance fixture package together. If these artifacts conflict, the release process MUST be corrected; implementations MUST NOT guess. + +## Conventions + +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, **MAY**, and **OPTIONAL** are to be interpreted as normative requirement levels. + +Sections marked **normative** define required behavior for conforming Blue Language 1.0 implementations. Sections marked **informative** explain intent, examples, or implementation guidance. + +--- + +## 0. Overview + +Blue is a deterministic content language for describing a **content-addressed graph of typed nodes**. + +A **Blue Graph** is the conceptual network of Blue nodes. Nodes are connected by ordinary object fields, list elements, type links, and `blueId` references. A **Blue Document** is a serialized rooted slice of that graph. It is **not required to contain the whole graph**: any pure `{ blueId: ... }` reference may point to content outside the selected document. + +The **BlueId** of a document is the BlueId of its root node. BlueId is a content address. Equivalent source, expanded, collapsed, resolved, and canonical forms of the same content produce the same semantic identity when processed through the appropriate identity pipeline. + +Blue supports several **views** of the same content. Implementations and authors MUST distinguish them. + +| View / state | Purpose | Identity status | +|---|---|---| +| **Source Document** | Authored input. May use authoring sugar and the root `blue` directive. | Not necessarily direct BlueId Input. | +| **Preprocessed Document** | Source after preprocessing has applied authoring transforms and removed `blue`. | Eligible for resolution and, if otherwise valid, direct hashing. | +| **Expanded View** | Pure `{ blueId: X }` references materialized from a provider. | Preserves Node BlueId when provider content verifies. | +| **Collapsed View** | Materialized subtrees replaced by pure `{ blueId: X }` references. | Preserves Node BlueId. | +| **Resolved View** | Fully type-merged and schema-validated semantic view. | Carries semantic identity; not necessarily direct BlueId Input. | +| **Canonical Identity Input** | Deterministic identity form derived from a Resolved View. It may contain final canonical payloads that are not ordinary Source overlays. | Direct input to Node BlueId; produces Content BlueId. | +| **Minimized Overlay** | Author-facing reduced overlay that re-resolves to the same Resolved View. | Same Content BlueId when processed through the identity pipeline. | + +The term **Canonical Overlay** is retained as a historical shorthand in some examples, but its normative role is **Canonical Identity Input**: the deterministic BlueId Input used to compute Content BlueId. It is not necessarily valid Source Document authoring form and is not required to re-resolve through ordinary Source overlay semantics. + +A **Minimized Overlay** is the author-facing reduced form that re-resolves to the same Resolved View. + +The identity pipeline for a Source Document is: + +```text +Source Document + -- preprocess --> Preprocessed Document + -- resolve --> Resolved View + -- canonicalize --> Canonical Identity Input + -- BlueId algorithm --> Node BlueId + = Content BlueId of the Source Document +``` + +A Blue Document is a rooted slice of a larger graph: + +```text +Selected document slice ++-----------------------------+ +| root | +| +- local field | +| +- local list | +| +- type: { blueId: T } -----+----> external type node T ++-----------------------------+ + \--> more graph reachable by BlueId +``` + +This specification defines content-language semantics only. + +--- + +## 1. Scope, Goals, Versioning, and Conformance + +### 1.1 Goal + +Blue is a universal, deterministic **content language** with: + +- a strict, mergeable type system with overlay and subtyping rules; +- a content address called **BlueId** that is stable across equivalent content forms; +- a precise pipeline that maps an authored document to deterministic content identity; +- graph-slice semantics, so documents can contain local content and external `blueId` references. + +### 1.2 Out of scope + +The following are not defined by this specification: + +- runtime execution; +- event processing; +- channels; +- handlers; +- gas accounting; +- document update listeners; +- processor lifecycle markers; +- contract execution. + +The field `contracts` is reserved by the language because it is a possible field in Blue content and therefore can affect BlueId. Its runtime meaning is defined only by the separate Blue Contracts and Processor Specification. + +### 1.3 Versioning + +This document defines **Blue Language 1.0**. + +A Blue node does not carry a required language-version field. A node's meaning is determined by this specification, its content, and the BlueIds of any referenced types. + +Implementations MUST declare which Blue Language version they implement. + +Blue Language 1.x revisions MUST preserve the meaning and BlueId of valid Blue Language 1.0 documents. Any incompatible change to the BlueId algorithm, node model, or resolution semantics requires a new major language version and an out-of-band version-selection mechanism. Such a mechanism MUST NOT require interpreting a node under the wrong BlueId algorithm before the version is known. + +### 1.4 Conformance + +A conforming Blue Language 1.0 implementation MUST implement all normative requirements in this specification. + +A conforming implementation MUST support: + +- parsing Blue Source Documents and BlueId Input; +- preprocessing, including the standard baseline preprocessing environment; +- type resolution and overlay merging; +- schema validation; +- list merge semantics and list control forms; +- provider-backed resolution when referenced content is required; +- expansion semantics, including provider-backed materialization when referenced content is required; +- collapse semantics if the implementation exposes a collapse API; +- canonicalization for Content BlueId calculation; +- author-facing minimization if the implementation exposes a minimization API; +- Node BlueId and Content BlueId calculation; +- circular reference set BlueIds; +- rejection of invalid Blue Language 1.0 documents and invalid BlueId Input; +- the Blue Language 1.0 conformance suite. + +Implementations MAY expose smaller internal APIs, such as direct Node BlueId calculation, but such APIs do not define separate conformance levels. + +A library or tool that implements only a subset of this specification may be useful, but it MUST NOT describe itself as a conforming Blue Language 1.0 implementation. + +### 1.5 Core registry dependency + +The canonical Blue type registry is part of the Blue Language 1.0 release surface. Its entries for `Text`, `Integer`, `Double`, `Boolean`, `Dictionary`, and `List` are content-addressed and versioned with this specification. + +A conforming implementation MUST use the registry BlueIds for core type aliases. A different registry binding does not produce portable Blue Language 1.0 Content BlueIds. + +Canonical registry nodes are self-describing Blue content. + +A registry node's `name` and `description` fields are identity-bearing content under the Blue Language. A canonical registry entry SHOULD include a concise normative `description` that defines the semantics of the type. Changing that semantic description changes the node's BlueId and therefore defines a different type. + +Non-normative examples, rationale, translations, tutorial material, implementation notes, and editorial commentary MUST NOT be included in canonical registry nodes unless intentionally made identity-bearing. Such material belongs in the prose specification, registry documentation, or examples outside the canonical node. + +The registry file is the authority for the exact byte/string content of canonical nodes. Code blocks in this specification that claim to show canonical nodes SHOULD be generated from, or kept byte-equivalent to, the registry entries used to calculate the published BlueIds. + +Practical editorial rule: if changing the text should change what the type means, put it in the canonical node. If changing the text only improves explanation, examples, formatting, translation, or teaching, keep it outside the canonical node. + +The canonical registry entry for each core type MUST include: + +- the exact canonical Blue node; +- the node's calculated BlueId; +- the Blue Language version that publishes it; +- the conformance fixture package identity that verifies it. + +A conforming implementation MUST verify, at release or test time, that every bundled core type node hashes to the published registry BlueId. + +The Blue Language 1.0 release is defined by three artifacts together: + +1. this prose specification; +2. the canonical Blue type registry for Blue Language 1.0; +3. the Blue Language 1.0 conformance fixture package. + +If these artifacts conflict, the release is inconsistent and MUST be corrected. Implementations MUST NOT guess which artifact wins. + +The prose explains the rules, the registry supplies the exact identity-bearing type nodes and BlueIds, and the fixtures provide behavior-defining examples. These artifacts MUST be versioned and published together. + +The fixture package is behavior-defining. It MUST publish exact expected BlueIds, canonical registry BlueIds, and fixture package identity. + +--- + +## 2. Serialization and Data Model + +### 2.1 JSON data model (normative) + +Blue documents use the JSON data model: + +- objects; +- arrays; +- strings; +- numbers; +- booleans; +- null. + +YAML is an authoring syntax for this JSON data model. A YAML parser used for Blue MUST NOT introduce YAML-specific data types into the Blue data model. + +### 2.2 YAML restrictions (normative) + +When YAML is used for Blue serialization: + +- duplicate object keys MUST be rejected; +- custom YAML tags MUST be rejected; +- Portable Blue YAML MUST reject YAML anchors, aliases, and merge keys. An implementation MAY expose a non-portable preprocessing mode that expands them deterministically before Blue parsing, but documents relying on that mode are not portable Blue Source Documents. +- non-JSON implicit types, including timestamps, binary blobs, sets, and ordered maps, MUST be disabled; +- timestamp-like values SHOULD be quoted by authors. Blue Language 1.0 defines no timestamp scalar. + +Blue YAML 1.0 uses the YAML 1.2 JSON schema data model. Portable Blue YAML MUST reject custom tags, non-string object keys, binary tags, sets, ordered maps, and non-JSON implicit scalar types. + +The parsed value of a YAML block scalar is the exact Text value. Blue performs no block-scalar normalization. Different YAML scalar styles, indentation, folding, chomping indicators, trailing newlines, or line endings that produce different parsed strings produce different BlueIds. + +Examples: + +```yaml +# Text, not a Date/Time type in Blue Language 1.0 +ts: "2025-09-01T12:00:00Z" +``` + +Blue Language 1.0 does not define a core Date or Timestamp scalar type. + +### 2.3 Duplicate keys (normative) + +Serialized Blue documents MUST NOT contain duplicate object keys. Parsers MUST reject duplicate keys. Later-key-wins behavior is not conforming. + +### 2.4 Number tokens and large integers (normative) + +Blue distinguishes the mathematical value of an integer from the JSON/YAML encoding used to carry it. + +The interoperable **safe JSON numeric integer range** for Blue Language 1.0 is: + +```text +[-9007199254740991, 9007199254740991] +``` + +JSON itself does not define a numeric range. Blue uses this safe range because it is exactly representable by JSON implementations that store numbers as IEEE 754 binary64 values. + +Rules: + +1. An unquoted integer token within this range MAY be used as an `Integer` value. +2. An integer value outside this range MUST be authored as a quoted canonical decimal string and MUST have explicit type `Integer` or a type that resolves to `Integer`. +3. In Canonical Identity Input and BlueId Input, an `Integer` value outside this range MUST be represented as its quoted canonical decimal string while retaining the explicit `Integer` type. +4. The canonical decimal string form is an optional leading `-` followed by decimal digits, with no leading zeros except the single digit `0`. +5. Quoted decimal text without an explicit `Integer` type is Text, not Integer. + +A quoted canonical decimal string value is interpreted as an `Integer` when the node has an explicit effective type that resolves to `Integer`. The effective type may be authored locally or inherited from the resolved type chain. + +If no effective type resolves to `Integer`, quoted decimal text is Text. + +If an effective type resolves to `Integer` and the quoted value is not a valid canonical decimal integer string, resolution MUST fail. + +Primitive scalar inference for quoted strings is provisional for Source Documents. Resolution MAY refine a quoted scalar's effective scalar type when an inherited or explicit type requires `Integer` and the quoted value is a valid canonical decimal integer string. + +Examples: + +```yaml +small: + type: Integer + value: 42 + +large: + type: Integer + value: "9007199254740992" +``` + +The same rule applies below the negative bound: + +```yaml +veryNegative: + type: Integer + value: "-9007199254740992" +``` + +Example with inherited Integer type: + +```yaml +# Type +name: Account +accountId: + type: Integer + +# Source instance +type: Account +accountId: "9007199254740992" +``` + +After preprocessing and resolution, `accountId` is an Integer value because the effective inherited type resolves to `Integer`. + +Without the inherited or explicit Integer type, the same quoted value is Text. + +Floating-point `Double` values MUST be finite. `NaN`, `Infinity`, and `-Infinity` are not valid Blue scalar values. + +Double parsing MUST produce a finite IEEE 754 binary64 value using round-to-nearest, ties-to-even semantics. A numeric token that overflows to positive or negative Infinity, underflows to a non-finite value, or parses as NaN is invalid. + +A parsed `-0.0` Double value compares equal to `0.0` and canonicalizes as JSON number `0` under RFC 8785. The node remains Double because its effective type is Double. + +A Double whose RFC 8785 canonical JSON representation is integer-looking, such as `1`, remains Double because its effective type is represented in BlueId Input. + +If a parser cannot deterministically parse a numeric token as binary64 with these semantics, the implementation MUST reject the token or require explicit authoring in a supported form. + +### 2.5 Numeric token inference (normative) + +When a numeric Source Document value has no explicit type: + +- an unquoted integer token with no decimal point and no exponent infers `Integer`; +- an unquoted numeric token with a decimal point or exponent infers `Double`, even if its mathematical value is integral. + +Examples: + +```yaml +a: 1 # Integer +b: 1.0 # Double, canonical numeric payload may render as 1 +c: -0.0 # Double, canonical numeric payload renders as 0 +d: 1e999 # invalid Double +``` + +If a parser cannot preserve the lexical distinction between integer tokens and decimal/exponent tokens, it MUST require explicit type annotations for ambiguous numeric values or document that such inputs are not portable Source Documents. + +### 2.6 String and multiline scalar identity (normative) + +After parsing, a Blue string value is identity-bearing exactly as parsed. Blue Language performs no automatic whitespace normalization, line-ending normalization, trailing newline stripping, indentation rewriting, Unicode normalization, case folding, or YAML block-scalar canonicalization. + +Different YAML scalar styles may produce different string values and therefore different BlueIds. In particular, YAML block scalar choices such as `|`, `|-`, `|+`, `>`, and `>-` may differ in line folding and trailing newline behavior. + +Canonical registry nodes SHOULD be generated, fixture-checked, or otherwise protected against accidental string drift. Authors of identity-sensitive documents SHOULD treat edits to multiline `description` fields as content edits, not formatting edits. + +Blue Language uses the parsed Unicode code-point sequence. Implementations MUST NOT normalize Text by default. Applications that need a normalization convention, such as NFC, SHOULD apply it explicitly at the application/preprocessing layer. + +--- + +## 3. Blue Graph, Blue Documents, and References + +### 3.1 The Blue Graph (normative) + +The **Blue Graph** is the conceptual content-addressed network of Blue nodes. Edges in the graph arise from: + +- ordinary object fields, for example `address -> child node`; +- list elements; +- type links, for example `type: ...`; +- `blueId` references. + +Nodes are identified by BlueId. The graph is global and content-addressed; it is not owned by any single document. + +### 3.2 Blue Documents as graph slices (normative) + +A **Blue Document** is a serialized rooted slice of the Blue Graph. It may contain: + +- fully materialized child nodes; +- pure references to external nodes using `{ blueId: ... }`; +- a mixture of local content and external references. + +A Blue Document is not required to be closed. A `{ blueId: X }` reference may point to content outside the selected document. Implementations may require a provider to expand references, resolve types, or canonicalize a view. + +### 3.3 Pure references (normative) + +A **pure reference** is exactly: + +```yaml +blueId: +``` + +or, as a field value: + +```yaml +field: + blueId: +``` + +A pure reference object MUST NOT carry sibling fields. The following is not a pure reference: + +```yaml +blueId: +name: Something +foo: bar +``` + +Mixed `blueId` forms MUST be rejected in Source Documents, Preprocessed Documents, Canonical Identity Input, and BlueId Input. Provider metadata MUST be represented out-of-band or in a non-Blue envelope. + +A non-Blue envelope is packaging metadata outside the Blue Document root. It is not part of the Blue node and is not included in BlueId calculation. + +A pure reference cannot carry sibling fields. To refine or extend referenced content, the reference MUST appear in a type position or be resolved as an ancestor/type, and the overlay MUST be written as ordinary instance content outside the pure reference object. + +Invalid: + +```yaml +blueId: X +extra: value +``` + +Valid as a typed overlay: + +```yaml +type: + blueId: X +extra: value +``` + +### 3.4 Document identity (normative) + +The BlueId of a Blue Document is the BlueId of its root node. There is no separate document-level identity above the root node. + +A Blue Document root MAY be a scalar, list, object, or pure reference. Scalar and list roots follow the same wrapper-equivalence rules as field values. A Blue Document root MUST NOT be `null`. + +--- + +## 4. Node Model and Reserved Fields + +### 4.1 Node anatomy (normative) + +A **Blue node** consists of reserved language fields and, optionally, one primary payload kind. + +```text +Node = reserved language fields + zero or one payload kind +``` + +The permitted payload kinds are: + +- **scalar payload**: a `value` field carrying a string, number, or boolean; +- **list payload**: an `items` field carrying an ordered sequence; +- **object payload**: one or more ordinary child fields, where ordinary child fields are fields whose keys are not reserved language keys. + +A node MUST NOT combine payload kinds. For example, a node MUST NOT contain both `value` and `items`, or both `value` and ordinary child fields. + +A node MAY have no payload. Such a node is a metadata-only, type-only, schema-only, or overlay-only node. Examples include: + +```yaml +age: + type: Integer +``` + +and: + +```yaml +name: Person +``` + +A pure reference is a special metadata-only reference node. It is valid only when the object contains exactly `blueId`. + +If a node has no payload and no retained reserved content after object-field cleaning, it may normalize to an empty map and be omitted when it appears as an object field. It MUST NOT be silently deleted when it appears as a list element; list element normalization is context-sensitive (§11.5, §14.2). + +### 4.2 Reserved language keys (normative) + +The following keys are reserved by the language: + +```text +name, description, +type, itemType, keyType, valueType, +value, items, +blueId, blue, +schema, mergePolicy, +contracts +``` + +The following keys are reserved-invalid and MUST be rejected wherever they would appear as object fields: + +```text +properties, constraints +``` + +Reserved fields are grouped as follows: + +| Category | Fields | +|---|---| +| Identity labels | `name`, `description` | +| Type and constraint metadata | `type`, `itemType`, `keyType`, `valueType`, `schema`, `mergePolicy` | +| Payload wrappers | `value`, `items` | +| Reference and preprocessing controls | `blueId`, `blue` | +| Reserved extension field | `contracts` | + +`contracts` is reserved by the language but semantically defined only by the Blue Contracts and Processor Specification. + +The key `blue` is valid only as a preprocessing directive on the root of a Source Document. A conforming implementation MUST reject `blue` anywhere else. Direct Node BlueId calculation MUST reject any node containing `blue` as direct BlueId Input. + +There is no `properties` field in the Blue Language. The key `properties` is reserved-invalid in Blue Language 1.0 and MUST NOT appear as an ordinary child field or language wrapper. Applications that need a data key literally named `properties` MUST use an escaped representation defined by the application's type. + +Reserved language keys cannot be used as ordinary child-field names in direct object encoding. Direct object encoding can therefore represent only data keys that do not collide with reserved language keys. +Applications that need arbitrary user keys, including keys that equal reserved language keys, MUST use an escaped representation defined by the application's type. + +### 4.3 Reserved field value types (normative) + +Implementations MUST validate reserved field value types. + +| Field | Required value shape | +|---|---| +| `name` | string, or absent | +| `description` | string, or absent | +| `type` | node, string alias in Source Documents before preprocessing, or pure reference | +| `itemType` | node, string alias in Source Documents before preprocessing, or pure reference | +| `keyType` | node, string alias in Source Documents before preprocessing, or pure reference | +| `valueType` | node, string alias in Source Documents before preprocessing, or pure reference | +| `value` | string, number, boolean, or absent | +| `items` | list, or absent | +| `blueId` | string BlueId, only in pure references | +| `blue` | string or object directive; root Source Document only | +| `schema` | object using only schema keywords from §9 | +| `mergePolicy` | `append-only`, `positional`, or absent | +| `contracts` | object; runtime semantics out of scope | + +Wrong reserved-field types MUST be rejected. Implementations MUST NOT silently coerce reserved field values such as `blueId: 123` or `name: true` into strings. + +### 4.4 `contracts` boundary (normative) + +In Blue Language 1.0, `contracts` is a reserved identity-bearing content field. A language implementation MUST parse, preserve, resolve, canonicalize, and hash `contracts` as content. It MUST NOT execute `contracts`. + +Unless a separate processor specification is explicitly being applied, `contracts` participates in language-level merge and canonicalization according to ordinary object-field rules. Runtime interpretation, reserved processor keys under `contracts`, processor lifecycle behavior, and contract capability handling are outside this specification. + +Language-level merge of `contracts` is field-wise: + +- If only the ancestor contributes a contract entry at key `k`, the entry is materialized in the Resolved View as type-derived content. +- If only the instance contributes a contract entry at key `k`, the entry is preserved as instance-supplied content. +- If both ancestor and instance contribute `contracts[k]`, the two contract nodes are merged recursively under the same fixed-value, type-compatibility, schema, and object-field rules used for ordinary child fields. +- A descendant MUST NOT remove an inherited contract entry during language resolution. Runtime removal or mutation of contracts, if allowed, belongs to the Blue Contracts and Processor Specification. +- The language resolver MUST NOT interpret, execute, sort, dispatch, or validate processor-specific contract behavior. + +Processor-reserved keys inside `contracts` have no runtime effect in this specification. They are still parsed, resolved, canonicalized, and hashed as content. + +### 4.5 `name` and `description`: identity vs field semantics (normative) + +`name` and `description` are content on the node. They affect BlueId. + +They are also matcher-neutral. Matchers MUST ignore `name` and `description` for: + +- type conformance checks; +- subtype compatibility checks; +- structural or shape matching; +- resolution matching. + +Identity equality includes `name` and `description`. Structural and type equality ignore them. + +### 4.6 Document identity vs field semantics for labels (normative) + +A node whose `type` is `T` is not `T`; it is a new entity. The resolved node's top-level `name` and `description` come only from the instance and MUST NOT be inherited from the type. The embedded type object may carry its own `name` and `description` inside `node.type`. + +When a type materializes declaration-only fields or list elements into an instance, those child nodes carry the type's `name` and `description` as inherited labels until the instance explicitly overrides them. + +However, when the inherited child node contains a fixed payload value, fixed list payload, fixed object subtree, or pure reference, the labels on that node are part of the inherited fixed value's identity. A descendant MUST NOT change `name` or `description` on such a fixed-value node unless the inherited type leaves that label absent or the change is otherwise allowed by an explicit resolution rule. + +Dereferencing `{ blueId: X }` to materialize a node may copy the referenced node's `name` and `description` onto that materialized node, because the node itself is being materialized. This is expansion, not type inheritance. + +--- + +## 5. Authoring Forms and Wrapper Equivalence + +### 5.1 Wrapper equivalence (normative) + +To improve ergonomics, Blue admits equivalent authoring forms for scalars and lists, provided the wrapper has no other keys. + +Scalar sugar: + +```yaml +x: 1 +``` + +is equivalent to the wrapped form: + +```yaml +x: + value: 1 +``` + +List sugar: + +```yaml +x: [a, b] +``` + +is equivalent to: + +```yaml +x: + items: [a, b] +``` + +### 5.2 Sugar vs explicit metadata (normative) + +The sugar rule applies only when the wrapper has no other keys. Therefore: + +```yaml +x: 1 +``` + +is sugar for: + +```yaml +x: + value: 1 +``` + +but: + +```yaml +x: + type: Integer + value: 1 +``` + +is not sugar. It is the explicit scalar node form with metadata. + +A node may carry metadata such as `type`, `description`, `schema`, or `mergePolicy` alongside a payload kind. Metadata is not a payload kind. + +### 5.3 Object nodes (normative) + +Object payloads are written directly as ordinary child fields: + +```yaml +x: + a: 1 + b: 2 +``` + +There is no `properties` wrapper. The key `properties` is reserved-invalid (§4.2). + +### 5.4 Identity over forms (normative) + +Equivalent authoring forms of the same semantic content MUST produce the same Content BlueId. + +The BlueId algorithm operates on the abstract node model after canonical input normalization, not on authoring syntax. In particular, a bare scalar and its `{ value: ... }` wrapped form normalize identically. A bare list and its `{ items: ... }` wrapped form normalize identically. + +--- + +## 6. Preprocessing and the `blue` Directive + +### 6.1 Purpose (normative) + +The root of a Source Document MAY contain a `blue` field. The `blue` directive declares preprocessing transforms that normalize authoring conveniences before the document is treated as identity-bearing content. + +A string-valued `blue` directive identifies a preprocessing environment or import document according to the implementation's declared preprocessing configuration. +An object-valued `blue` directive declares imports and preprocessing transforms directly. The exact object fields supported by a preprocessing environment MUST be deterministic and documented by that environment. + +Preprocessing is part of Content BlueId calculation. It is not part of direct Node BlueId calculation, because direct Node BlueId accepts only BlueId Input. + +A conforming implementation MUST support this portable `blue.imports` shape: + +```yaml +blue: + imports: + AliasName: + blueId: +``` + +Each key under `imports` is an authoring alias. Each value MUST be a pure reference object. During preprocessing, occurrences of that alias in `type`, `itemType`, `keyType`, or `valueType` positions are replaced by the corresponding pure reference. + +Aliases declared in `blue.imports` are scoped to the Source Document being preprocessed. They are removed with the `blue` directive and are not identity content after preprocessing. + +An alias name MUST NOT be declared more than once in the same `imports` object. An alias declared in `blue.imports` MUST NOT redefine a built-in core type name unless it maps to the same canonical BlueId. + +### 6.2 Standard baseline preprocessing (normative) + +A conforming implementation MUST support the standard baseline preprocessing environment: + +1. **Core type aliases to BlueIds.** Core aliases such as `Text`, `Integer`, `Double`, `Boolean`, `Dictionary`, and `List` are replaced by canonical type references supplied by the canonical Blue type registry. +2. **Document-declared aliases to BlueIds.** Aliases other than the built-in core type names MUST be declared by the Source Document, for example through the root `blue` directive, or by content-addressed import documents referenced from it. +3. **Primitive scalar inference.** Bare scalar payloads with no explicit type are assigned the corresponding core primitive type: `Text`, `Integer`, `Double`, or `Boolean`. +4. **Wrapper normalization.** Scalar and list sugar are normalized into the abstract node model. +5. **List placeholder normalization.** In Source Documents, list elements that are `null`, `{}`, or that recursively normalize to an empty object after object-field cleaning are normalized to `$empty: true` (§11.5). + +If the root `blue` directive is omitted, conforming implementations MUST still apply the standard baseline preprocessing environment. If a `blue` directive is present, it MAY configure imports and additional declared supported transforms, but it MUST NOT disable the mandatory baseline transforms required for interoperability. + +Implementation-local alias configuration MAY be used for authoring convenience, but documents depending on undeclared implementation-local aliases do not have portable Content BlueIds. + +### 6.3 Additional preprocessing transforms (normative) + +Additional preprocessing transforms MAY be used only when they are explicitly declared by the root `blue` directive and supported by the implementation. +Such transforms MUST be deterministic. If a Source Document requires a transform that the implementation does not support, preprocessing MUST fail. +Any imported preprocessing document that affects Content BlueId MUST itself be identified by BlueId or by a deterministic registry binding declared by the Source Document. +A document that depends on implementation-local transforms not declared by the Source Document does not have a portable Content BlueId. + +### 6.4 Preprocessing rules (normative) + +- The `blue` directive is valid only on the root of a Source Document. +- The `blue` directive is not semantic content. +- A document containing `blue` is not valid BlueId Input. +- Preprocessing MUST remove the `blue` directive after applying it. +- Direct Node BlueId calculation MUST reject a node containing `blue`. +- Content BlueId calculation MUST preprocess the document and remove `blue` before hashing. + +Simply ignoring `blue` is not correct. The directive may define aliases and transforms that change the canonical content. A direct hasher that sees `blue` MUST reject the input rather than hash a partially processed structure. + +### 6.5 Security (normative) + +Remote fetch of preprocessing imports or transforms is DISABLED by default. Implementations MAY support remote preprocessing documents only through explicit opt-in configuration and deterministic caching rules. + +Any preprocessing import document or transform document fetched by BlueId MUST be verified against that BlueId before use. If verification fails, preprocessing MUST fail deterministically. + +A preprocessing import that is not identified by BlueId MUST be supplied by a deterministic registry binding declared by the Source Document or by the implementation's declared preprocessing configuration. Such bindings are outside the portable Source Document unless their identity is included in the conformance fixture or release artifact. + +--- + +## 7. BlueId and Content Identity + +### 7.1 BlueId summary (normative) + +Every Blue node has a content identity called its **BlueId**. The BlueId of a Blue Document is the BlueId of its root node. + +BlueId is a content address: equivalent representations of the same content produce the same identity after the relevant view transformations have been applied. + +This section defines BlueId conceptually. The algorithmic details are in §14. + +### 7.2 Node BlueId and Content BlueId (normative) + +Blue defines two related identities. + +**Node BlueId** is the result of applying the BlueId algorithm directly to valid **BlueId Input**. + +**Content BlueId** is the semantic identity of a Source Document. It is calculated as: + +1. preprocess the Source Document (§6); +2. resolve type chains and validate constraints (§10), producing a Resolved View; +3. canonicalize the Resolved View into a Canonical Identity Input (§13); +4. compute the Node BlueId of the Canonical Identity Input (§14). + +All conforming implementations MUST produce the same Content BlueId for equivalent Source Documents, given the same provider state required for resolution. + +### 7.3 Identity preservation across views (normative) + +Expansion preserves Node BlueId when the provider returns verified content. Pure references hash to their target BlueId; materializing a reference into content does not change the surrounding node's Node BlueId if the materialized content has that BlueId. + +Collapse preserves Node BlueId. Replacing materialized content with a pure reference to its known BlueId yields the same Node BlueId. + +Resolution preserves semantic identity. A Source Document and its Resolved View have the same Content BlueId when the Resolved View is canonicalized. + +A Resolved View is not generally direct BlueId Input. It may contain inherited or materialized fields that are derivable from the type chain. Directly hashing a Resolved View is not guaranteed to produce the Content BlueId. + +### 7.4 BlueId Input (normative) + +**BlueId Input** is any node valid for direct application of the BlueId algorithm after BlueId input normalization. + +BlueId Input MUST NOT contain: + +- the `blue` directive; +- unresolved aliases introduced only for authoring convenience; +- illegal payload combinations; +- invalid list-control forms; +- mixed `blueId` reference shapes; +- unresolved cyclic placeholders such as `this#0`, except inside the explicit cyclic-set calculation API defined in §15; +- `$pos` overlays; +- `null` list elements; +- empty-object list elements that have not been normalized to `$empty: true`. + +A node containing `blue` MUST NOT be accepted as direct BlueId Input. The `blue` directive is never identity content. + +### 7.5 Allowed BlueId forms (normative) + +A **plain BlueId** is the Base58 encoding of a SHA-256 digest using the following alphabet: + +```text +123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz +``` + +Blue Language 1.0 does not define alternative BlueId alphabets. A registry MAY define aliases or packaging metadata, but MUST NOT redefine the BlueId hash alphabet. + +A plain BlueId MUST be the canonical Base58 encoding of exactly 32 bytes, the output length of SHA-256. Implementations MUST reject non-canonical Base58 encodings, strings containing characters outside the BlueId alphabet, and strings that decode to any length other than 32 bytes. + +A plain BlueId MUST NOT contain `#`. The `#` suffix syntax is reserved for cyclic-set member BlueIds. + +The ZERO_BLUEID sentinel defined in §15.2 is not a plain BlueId because the character `0` is not in the BlueId alphabet. + +A **cyclic-set member BlueId** has the form: + +```text +# +``` + +where `MASTER` is the plain BlueId of the ordered cyclic set list and `index` is a non-negative decimal integer. + +`this#` is an algorithm-internal placeholder accepted only by the explicit cyclic-set calculation API defined in §15. It MUST NOT appear in ordinary BlueId Input or provider-stored content. + +--- + +## 8. Types, Overlays, and Subtyping + +### 8.1 Any node can be a type (normative) + +There is no schema-versus-instance bifurcation in Blue. Any node can appear under `type`. + +If `T` is used in `type: T`, then `T` contributes: + +- structure; +- nested type chains; +- schema constraints; +- fixed values. + +A type is an **overlay source**, not a class declaration. + +### 8.2 Fixed-value invariant (normative) + +A concrete value embedded in a type is immutable in descendants at that path. A descendant MUST NOT replace, remove, or contradict that value. Any attempted override MUST fail resolution. + +For example, if a type fixes: + +```yaml +country: + value: PL +``` + +then a descendant cannot resolve with: + +```yaml +country: + value: US +``` + +### 8.3 Fixed-value equality (normative) + +Fixed-value equality is evaluated after preprocessing and wrapper normalization. + +- Scalar equality compares the parsed scalar value and effective scalar type. +- Object and list equality compares the Node BlueId of the normalized subtree. +- `name` and `description` are content for fixed-value equality. Matcher neutrality applies to type/shape matching, not to identity equality of fixed values. + +Scalar payload equality compares parsed scalar value and effective scalar type. Full fixed-node equality compares the normalized Blue node identity, including `name`, `description`, metadata, and payload. Thus a descendant may not change labels on an inherited fixed-value node, because doing so changes the fixed node's identity. + +Therefore these are equal after wrapper normalization: + +```yaml +city: Warsaw +``` + +```yaml +city: + value: Warsaw +``` + +but these are different fixed values because labels are identity content: + +```yaml +city: + name: City + value: Warsaw +``` + +```yaml +city: + name: Location + value: Warsaw +``` + +Valid label override on declaration-only field: + +```yaml +# Parent type +city: + name: City + type: Text + +# Descendant +city: + name: Location + value: Warsaw +``` + +Invalid label override on fixed-value field: + +```yaml +# Parent type +city: + name: City + value: Warsaw + +# Descendant +city: + name: Location + value: Warsaw +``` + +The second case fails because the inherited fixed node includes the label `name: City` as identity content. + +### 8.4 Subtyping and Liskov substitutability (normative) + +When resolving, descendants MUST satisfy: + +1. **No fixed-value override.** Immutable values inherited from types cannot be changed. +2. **Type compatibility.** A descendant type at a path must be equal to or a subtype of the inherited type at that path (§8.4.1). +3. **Additive structure.** Guaranteed fields cannot be deleted. +4. **Collection compatibility.** `itemType`, `keyType`, and `valueType` compatibility must be preserved. + +Every instance of a subtype MUST be substitutable for its parent. + +If `itemType`, `keyType`, or `valueType` is inherited at a path, a descendant that omits the field inherits it. A descendant MAY narrow the inherited type by supplying an equal type or subtype. A descendant MUST NOT widen, remove, or replace the inherited type with an incompatible type. + +Omitting `itemType`, `keyType`, or `valueType` means unconstrained only when there is no inherited effective type constraint at that path. + +### 8.4.1 Formal subtype relation (normative) + +For Blue Language 1.0, `T <: P` ("T is a subtype of P") iff resolving `T` as a descendant overlay of `P` succeeds under the resolution rules in §10, and every valid instance of `T` is substitutable where an instance of `P` is required. + +A subtype check MUST ignore `name` and `description` for matcher/type-shape purposes, but fixed-value equality still includes `name` and `description` because they are identity content (§8.3). + +For each path contributed by parent type `P`, subtype `T` MUST satisfy all of the following: + +1. **Fixed values preserved.** If `P` fixes a scalar, object, list, or subtree value at a path, `T` MUST preserve the same fixed value under §8.3. +2. **Guaranteed structure preserved.** If `P` guarantees a field or list prefix element, `T` MUST keep it present in all valid instances unless a specific list merge rule explicitly refines it without removal. +3. **Schema constraints compatible.** Every schema constraint contributed by `P` MUST remain satisfied by `T`. Additional constraints in `T` are allowed only when their intersection with inherited constraints is non-empty and not weaker. +4. **Type constraints narrowed only.** If `P` declares `type`, `itemType`, `keyType`, or `valueType` at a path, `T` may repeat the same type or provide a subtype. It MUST NOT omit, widen, or replace the inherited effective type constraint with an incompatible type. +5. **Payload kind compatible.** Scalar, list, and object payload kinds MUST remain compatible with inherited guarantees. A subtype MUST NOT turn an inherited scalar requirement into a list/object requirement, or vice versa, unless resolution can prove the inherited requirement is not applicable. +6. **List policies preserved.** An inherited `mergePolicy: append-only` MUST remain append-only. A descendant MUST NOT weaken append-only to positional. If no merge policy is inherited and none is authored, the effective default is positional. + +Equivalently, `T <: P` when the Resolved View produced by resolving `T` over `P` is valid and does not violate any invariant or guarantee of `P`. + +If checking `T <: P` requires resolving a type chain that revisits a type already on the active resolution stack, resolution MUST fail with a type-cycle error (§10.2.1). + +### 8.4.2 Nominal core type identity (normative) + +The canonical core primitive and collection types `Text`, `Integer`, `Double`, `Boolean`, `Dictionary`, and `List` are **nominal** Blue Language types identified by their canonical registry BlueIds. + +A type resolving to one of these canonical core types is compatible with another such type only when the canonical registry BlueId is equal, unless the canonical registry explicitly declares a subtype relationship. Blue Language 1.0 declares no implicit subtype relationship between distinct core types. + +Matcher-neutral treatment of `name` and `description` applies to structural field matching and subtype shape checks. It does **not** make two different canonical registry type identities interchangeable. If a core type description changes and therefore the type BlueId changes, it is a different nominal type. + +Examples: + +- The canonical `Integer` type is compatible with itself by registry BlueId. +- A node named `Integer` with a different description and different BlueId is not the canonical `Integer` type. +- `Integer` and `Double` are not subtypes of each other in Blue Language 1.0. + +### 8.5 Instance-as-type (normative) + +Nodes representing individuals can be used as types. + +For example: + +- `Alice` may have `type: Person`. +- `Alice Smith` may have `type: Alice`. + +All fixed values in `Alice` become invariants in `Alice Smith`. Alice's top-level `name` and `description` do not flow to Alice Smith (§4.6). + +### 8.6 Requirement overlays (normative) + +An ancestor may partially constrain a subtree without binding a concrete type at that path. + +Example: + +```yaml +# Parent +name: A +prop1: + x: 1 + schema: + minFields: 1 +``` + +A descendant may later set: + +```yaml +name: B +type: A +prop1: + type: Some +``` + +This is valid only if the merged result still satisfies all overlay obligations, including fixed values and schema constraints. If the overlay had a type, the descendant's type must be equal to or a subtype of that type. + +If the overlay forces `x = 1` but `Some` forces `x = 2`, resolution MUST fail. + +--- + +## 9. Schema Constraints + +### 9.1 Attaching schema (normative) + +A `schema` object MAY be attached to any node. + +All schema constraints accumulate along the type chain. Compatible constraints are intersected according to §9.9. Irreconcilable constraints MUST fail resolution. + +### 9.2 Schema vocabulary (normative) + +Only the keywords listed in §9.3-§9.8 are valid inside a `schema` object. Implementations MUST reject any other key inside `schema`. + +The valid schema keywords are: + +```text +required, +minItems, maxItems, uniqueItems, +minFields, maxFields, +minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, +minLength, maxLength, +enum +``` + +A schema object MUST NOT contain any key outside this list. + +### 9.2.1 Schema keyword value types (normative) + +| Keyword | Required value shape | +|---|---| +| `required` | boolean | +| `minItems`, `maxItems`, `minFields`, `maxFields`, `minLength`, `maxLength` | non-negative integer in the safe JSON numeric integer range | +| `uniqueItems` | boolean | +| `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` | numeric scalar or explicit numeric scalar node | +| `enum` | list of scalar values or explicit scalar nodes | + +A schema keyword value with the wrong shape MUST be rejected. Implementations MUST NOT coerce schema keyword values across scalar types. + +### 9.2.2 Schema applicability (normative) + +Each schema keyword applies only to the effective node kind for which it is defined. + +- String constraints apply only to effective Text values. +- Numeric constraints apply only to effective Integer or Double values. +- List constraints apply only to effective list payloads. +- Object field-count constraints apply only to effective object payloads. +- `enum` applies to scalar values unless an explicit scalar-node enum entry is used. +- `required` applies to the child field declaration at the path where it appears. + +If a schema keyword is evaluated against an incompatible effective node kind, validation MUST fail with a schema violation. Implementations MUST NOT silently ignore incompatible schema keywords. + +### 9.2.3 Required fields (normative) + +`required: true` on a child field declaration requires that the field be semantically present in resolved descendants. + +A required field is satisfied only if the resolved child node contains at least one of: + +- a scalar payload `value`; +- a list payload `items`, including an empty list; +- an object payload with at least one ordinary child field; +- a pure reference; +- a fixed payload or fixed subtree inherited from an ancestor type. + +A metadata-only child declaration, such as a node containing only `type`, `schema`, `name`, or `description`, does not by itself satisfy `required: true`. + +If a field is required but has no semantic payload or fixed inherited content after resolution and cleaning, validation MUST fail. + +### 9.2.4 Field counting (normative) + +`minFields` and `maxFields` count ordinary child fields of the effective object payload after resolution and object-field cleaning. + +Reserved language fields such as `name`, `description`, `type`, `schema`, `contracts`, `value`, and `items` do not count as ordinary fields. + +Fields removed by object-field cleaning do not count. Inherited ordinary child fields that are materialized in the Resolved View do count. + +### 9.3 Presence + +```yaml +required: true +``` + +When a schema with `required: true` is attached to a child field in a type or object overlay, that field MUST be semantically present in resolved descendants according to §9.2.3. If used at a document root, `required` is trivially satisfied by the existence of the root node. + +### 9.4 Lists + +```yaml +minItems: +maxItems: +uniqueItems: true | false +``` + +`maxItems` MUST be greater than or equal to `minItems` when both are present. + +`uniqueItems: true` compares items by item BlueId, not by textual rendering. + +### 9.5 Objects + +```yaml +minFields: +maxFields: +``` + +`maxFields` MUST be greater than or equal to `minFields` when both are present. + +The term **fields** is used because Blue objects have direct ordinary fields and no `properties` wrapper. + +### 9.5.1 Dictionary direct encoding validation (normative) + +For direct Dictionary object encoding, each direct key MUST be valid under the effective `keyType`. + +For direct object encoding, `keyType` MUST resolve to one of the scalar key types with a canonical textual representation: Text, Integer, Double, or Boolean. If `keyType` is omitted and no effective `keyType` is inherited, it defaults to Text. + +A key's serialized object-member name MUST be exactly the canonical textual form of the parsed key value. If two key values canonicalize to the same object-member string, the document has a duplicate key conflict and MUST be rejected. + +Every value in a Dictionary with an effective `valueType` MUST resolve as an instance of, or subtype-compatible with, the effective `valueType`. + +Applications needing arbitrary non-scalar keys or reserved-key collisions MUST use an application-defined escaped representation rather than direct object encoding. + +### 9.6 Numerics + +```yaml +minimum: number +maximum: number +exclusiveMinimum: number +exclusiveMaximum: number +multipleOf: number +``` + +Numeric schema keyword values MAY be authored in either scalar form or explicit scalar-node form. + +Scalar form: + +```yaml +schema: + minimum: 5 +``` + +Explicit scalar-node form: + +```yaml +schema: + minimum: + type: Integer + value: "9007199254740992" +``` + +A quoted decimal string without explicit `type: Integer` is Text and MUST NOT be accepted as a numeric constraint. + +Rules: + +- `minimum: m` means the numeric value must be greater than or equal to `m`. +- `maximum: m` means the numeric value must be less than or equal to `m`. +- `exclusiveMinimum: m` means the numeric value must be strictly greater than `m`. +- `exclusiveMaximum: m` means the numeric value must be strictly less than `m`. +- `multipleOf` must be greater than zero. + +If multiple numeric constraints appear in the type chain, the value must satisfy all of them. For integer `multipleOf` constraints, implementations MUST combine compatible constraints using least common multiple (LCM). The effective merged schema MUST contain one `multipleOf` value equal to that LCM, and the Resolved View and Canonical Identity Input MUST NOT preserve an implementation-specific list of equivalent integer `multipleOf` constraints. + +For `Double` `multipleOf`, both the tested value and the `multipleOf` constraint are interpreted as their exact IEEE 754 binary64 rational values after parsing. A Double value `v` satisfies `multipleOf: m` iff `m > 0` and the exact rational quotient `v / m` is an integer. Implementations MUST NOT use epsilon comparisons, decimal string rounding, host-language modulo on binary floating point, or implementation-specific approximation. + +For cross-type numeric comparisons, an `Integer` value is interpreted as an exact rational integer. A `Double` bound or value is interpreted as its exact IEEE 754 binary64 rational value. Comparison between Integer and Double uses exact rational comparison. + +A numeric token that cannot be parsed to a finite IEEE 754 binary64 value under §2.4 is invalid before schema evaluation. + +Implementations MAY use arbitrary-precision rational arithmetic internally to implement these predicates. They MUST NOT expose host floating-point rounding differences in conformance behavior. + +Numeric schema keyword values follow the same numeric representation rules as scalar values (§2.4). Integer constraints outside the safe JSON numeric integer range MUST be represented as typed Integer scalar nodes that preserve exact integer identity. Quoted decimal text without explicit Integer typing is Text and MUST NOT be treated as a numeric schema constraint. + +### 9.7 Strings + +```yaml +minLength: +maxLength: +``` + +Length is measured in Unicode code points. `maxLength` MUST be greater than or equal to `minLength` when both are present. + +### 9.8 Enumerations + +```yaml +enum: [v1, v2, ...] +``` + +Enumeration values are scalar Blue values. They MAY be authored as bare scalars when unambiguous, or as explicit scalar nodes with `type` and `value` when type disambiguation is required, for example for large integers represented as quoted canonical decimal text. Equality is by parsed scalar value, effective scalar type, and canonical JSON value semantics, not by textual rendering. + +`enum` comparison is performed after preprocessing and scalar type inference. Therefore the untyped enum entry `1` is an `Integer`, while `1.0` and `1e0` are `Double`. A quoted decimal string is Text unless authored as an explicit `Integer` scalar node. + +Example with a large integer enum value: + +```yaml +schema: + enum: + - 1 + - 1.0 + - type: Integer + value: "9007199254740992" +``` + +The first two enum entries above are distinct because their effective scalar types are different. + +There is no separate `const` keyword. A fixed value in a type enforces a constant. + +### 9.8.1 Enumeration normalization (normative) + +`enum` is a set of allowed scalar identities. Authoring order is not semantic. + +During schema validation, schema merge, and canonicalization, each enum entry MUST be normalized to its typed scalar identity: effective scalar type plus canonical scalar value. Duplicate entries with the same typed scalar identity are redundant and MUST be removed in the effective schema. + +The canonical enum representation MUST sort entries by the RFC 8785 canonical JSON byte sequence of their typed scalar identity form. If two entries have identical canonical bytes, they are duplicates and only one is retained. + +Therefore these schemas are semantically equivalent and MUST canonicalize identically: + +```yaml +schema: + enum: [A, B] +``` + +```yaml +schema: + enum: [B, A, A] +``` + +The effective canonical enum contains `A` and `B` once each, in the canonical ordering defined above. + +### 9.9 Schema merge rules (normative) + +When schemas accumulate along the type chain, implementations MUST merge keyword constraints as follows: + +| Keyword | Merge rule | Failure case | +|---|---|---| +| `required` | logical OR | never, for the keyword itself | +| `minItems` | maximum | merged `minItems > maxItems` | +| `maxItems` | minimum | merged `maxItems < minItems` | +| `uniqueItems` | logical OR | never, for the keyword itself | +| `minFields` | maximum | merged `minFields > maxFields` | +| `maxFields` | minimum | merged `maxFields < minFields` | +| `minimum` | strongest lower bound | incompatible with upper bounds | +| `maximum` | strongest upper bound | incompatible with lower bounds | +| `exclusiveMinimum` | strongest exclusive lower bound | incompatible with upper bounds | +| `exclusiveMaximum` | strongest exclusive upper bound | incompatible with lower bounds | +| `multipleOf` | all constraints must hold; integer constraints MUST be merged to their LCM; Double constraints MUST be evaluated by exact rational arithmetic over IEEE 754 binary64 values under §9.6 | no possible numeric value satisfies all constraints | +| `minLength` | maximum | merged `minLength > maxLength` | +| `maxLength` | minimum | merged `maxLength < minLength` | +| `enum` | normalize both sides under §9.8.1, then intersect by typed scalar identity; canonical effective enum is duplicate-free and sorted under §9.8.1 | empty intersection | + +For lower/upper-bound interactions, an exclusive bound at the same numeric value is stricter than an inclusive bound. For example, `minimum: 5` merged with `exclusiveMinimum: 5` yields `exclusiveMinimum: 5`. + +--- + +## 10. Resolution and Resolved Views + +### 10.1 Goal (normative) + +Resolution produces a **Resolved View**: a fully materialized, type-merged, schema-validated semantic view of a Source Node. + +A Resolved View is the correct input for type checks and semantic validation. It is not necessarily direct BlueId Input because it may contain inherited or materialized fields that are derivable from the type chain. + +To compute Content BlueId, the Resolved View MUST be canonicalized into a Canonical Identity Input (§13) and then hashed (§14). + +### 10.2 Resolution algorithm (normative) + +Given a Source Node `S`, a conforming implementation performs: + +1. **Preprocess** `S` (§6), producing a Preprocessed Document. +2. **Resolve type chain.** If `S.type` exists, recursively resolve it. If the type is a pure reference, follow it through a provider and verify the fetched content (§12.4). The result is the ancestor Resolved View `A`. +3. **Merge ancestor and source.** Merge `A` into target `T`, then merge `S` into `T`: + - **Root labels:** when merging a type into an instance root, do not copy the type root's `name` or `description` onto the instance root (§4.6). + - **Values:** copy if absent; if both are present, they must be equal under fixed-value equality (§8.3). + - **Types:** assign and propagate under §8. + - **Schema:** accumulate under §9. + - **Object fields:** merge recursively; children must remain compatible. + - **Lists:** merge under §11. + - **Contracts:** preserve and merge as identity-bearing content under §4.4; do not execute. +4. **Validate schema** after merging. +5. **Produce the Resolved View.** Implementations MAY freeze it into a **Resolved Snapshot** when immutability matters. + +Schema validation is performed after inherited and instance values are merged at a node. Therefore an inherited schema applies to inherited fixed values, type-derived fields, and instance-supplied values in the final Resolved View. + +Type-chain resolution is depth-first: the effective ancestor type is resolved before it is merged into the descendant target. A resolver MUST track the active type-resolution stack for cycle detection. + +### 10.2.1 Type-chain cycle detection (normative) + +Type-chain cycles are invalid for Blue Language 1.0 resolution. + +If resolving a node requires resolving a type that is already present on the active type-resolution stack, resolution MUST fail deterministically with a type-cycle error. + +Example invalid cycle: + +```yaml +# A +name: A +type: + blueId: + +# B +name: B +type: + blueId: +``` + +Circular-set BlueIds (§15) identify cyclic document sets. They do not make cyclic inheritance or cyclic type chains resolvable. Blue Language 1.0 does not define fixed-point type semantics. + +### 10.2.2 Reference resolution pseudocode (informative) + +The following pseudocode is informative, but illustrates the required order of operations. + +```text +resolve(source, provider): + S = preprocess(source) + if S.type exists: + T_ref = normalize_type_reference(S.type) + T_node = materialize_if_reference(T_ref, provider) + A = resolve(T_node, provider) + else: + A = empty node + R = merge_as_instance(ancestor=A, instance=S, path="/") + validate_schema_recursively(R) + return ResolvedView(R, provenance) + +merge_as_instance(ancestor, instance, path): + T = copy_type_derived_content(ancestor, path) + if path == "/" and ancestor is the effective type of instance: + do not copy ancestor.name or ancestor.description to T + merge reserved metadata using field-specific rules + merge ordinary child fields recursively + merge lists using §11 + merge contracts using §4.4 + reject fixed-value, type, schema, or payload-kind conflicts + record provenance for each retained contribution + return T +``` + +Precise implementation structure is not normative. The observable Resolved View, provenance sufficient for canonicalization, validation behavior, and resulting Content BlueId are normative. + +### 10.3 Resolution provenance (normative) + +A conforming implementation MUST track enough provenance to canonicalize deterministically. For each resolved path, the implementation MUST be able to determine whether the content was: + +- **instance-supplied** by the Source Document after preprocessing; +- **type-derived** from an ancestor type; +- **provider-materialized** from a `blueId` reference; +- **preprocessing-derived** from mandatory or declared preprocessing; +- **merge-derived** from compatible instance and type contributions. + +The exact internal representation is implementation-defined, but the canonicalization result MUST be deterministic and conform to §13. + +### 10.4 Identity guarantee (normative) + +Resolution preserves semantic identity. A Source Document and its Resolved View have the same Content BlueId when the Resolved View is canonicalized. + +Implementations MUST NOT assume that directly hashing a Resolved View produces the Content BlueId. + +### 10.5 Provider failures (normative) + +A conforming implementation MUST materialize referenced content when that content is required for resolution, canonicalization, expansion, collapse, or validation. If required content is unavailable, the operation MUST fail deterministically. Implementations MUST NOT silently substitute empty content for missing references. + +### 10.6 Limits (normative) + +Implementations SHOULD support path and depth limits to bound materialization of large graphs. Limits affect materialization, not semantic meaning. If a limit prevents content required for resolution, resolution MUST fail or return an explicitly incomplete view, depending on the declared API. An incomplete view MUST NOT be used for Content BlueId. + +--- + +## 11. Lists, Merge Policies, and List Control Forms + +### 11.1 Authoring model (normative) + +A list field SHOULD be authored in typed form when list semantics matter: + +```yaml +: + type: List + itemType: + mergePolicy: append-only | positional + items: + - ...elements... +``` + +A surface list is permitted for simple cases: + +```yaml +tags: [a, b, c] +``` + +Typed form is REQUIRED when `mergePolicy`, anchors, or overlays are used. + +Every element of a resolved list with an effective `itemType` MUST resolve as an instance of, or subtype-compatible with, the effective `itemType`. If an item cannot be resolved or is incompatible with `itemType`, validation MUST fail. + +If `itemType` is omitted and no effective inherited `itemType` exists, list elements are unconstrained by item type. + +### 11.2 Allowed item forms inside `items` (normative) + +Each item inside `items` MUST be exactly one of the following forms after Source Document preprocessing. + +#### Normal element + +```yaml +- +``` + +A normal element is content. + +#### Append anchor + +```yaml +- $previous: + blueId: +``` + +Rules: + +- `$previous` is allowed only as the first item. +- The shape MUST be exactly one top-level `$previous` key whose value is an object with exactly one `blueId` key. +- `$previous` is never content. + +#### Positional overlay + +Map overlay: + +```yaml +- $pos: 1 + ...overlay fields... +``` + +Replacement overlay for an object: + +```yaml +- $pos: 1 + $replace: + type: Address + city: Warsaw +``` + +Replacement overlay for a list: + +```yaml +- $pos: 1 + $replace: + items: + - A + - B +``` + +Replacement overlay for a pure reference: + +```yaml +- $pos: 1 + $replace: + blueId: X +``` + +Rules: + +- `$pos` MUST be a non-negative integer using zero-based indexing. +- `$pos` is valid only when `mergePolicy: positional`. +- A `$pos` item without `$replace` is a map overlay. It is valid only when the inherited element at that index is an object-compatible node. If the inherited element is scalar, list, or pure reference, the overlay MUST use `$replace` and remain type-compatible. +- `$pos` overlays are consumed by resolution and do not appear as content in the final list. +- `$replace` is valid only inside a `$pos` item. Its value is a full Blue node used to replace the inherited element, subject to type and schema compatibility. +- For scalar replacement, the concise form below is equivalent to `$replace: { value: B }`: + +```yaml +- $pos: 1 + value: B +``` + +The `value` form MUST NOT be used to carry list or object replacements. Use `$replace` for non-scalar replacements. + +#### Placeholder element + +```yaml +- $empty: true +``` + +`$empty: true` is content. It is a real element that occupies a position and affects BlueId. It is distinct from `null`, `{}`, and `[]`. + +The shape MUST be exactly one top-level `$empty` key whose value is the boolean `true`. `$empty: false`, `$empty: null`, and `$empty` with sibling fields are invalid as list placeholder elements. + +### 11.3 Scope of list control keys (normative) + +The special keys `$previous`, `$pos`, `$replace`, and `$empty` are recognized only as top-level keys of elements inside a list payload. + +`$empty` is valid in any list payload. + +`$previous`, `$pos`, and `$replace` are list overlay controls. They are valid only when the list is being resolved as a typed or overlay-capable list. Authors SHOULD use the typed list form when using these controls. + +Outside list-control position, `$previous`, `$pos`, `$replace`, and `$empty` are ordinary field names unless another specification gives them meaning. They do not act as list controls outside list elements. + +### 11.4 Default merge policy (normative) + +If no effective `mergePolicy` is inherited and no `mergePolicy` is authored on the list, resolvers MUST assume: + +```yaml +mergePolicy: positional +``` + +If an inherited list has an effective `mergePolicy`, a descendant list overlay that omits `mergePolicy` inherits that effective policy. A descendant MAY repeat the same `mergePolicy`. + +A descendant MUST NOT change an inherited `mergePolicy`. If an effective `mergePolicy` is inherited, omission by the descendant means inheritance, not defaulting. If no policy is inherited and no policy is authored, the effective default is `positional`. + +In particular, `append-only` MUST NOT be weakened to `positional`. + +For histories, ledgers, timelines, and append-only logs, authors MUST specify: + +```yaml +mergePolicy: append-only +``` + +### 11.5 Semantics of `null`, `{}`, `[]`, and `$empty` (normative) + +Blue distinguishes object-field absence from list position. + +#### Object fields + +In object fields, `null` means no information. Before hashing: + +- fields whose value is `null` MUST be omitted; +- fields whose value normalizes to an empty object `{}` MUST be omitted; +- empty lists `[]` MUST be preserved. + +This removal is recursive and may cascade. + +#### List elements + +List elements are positional. Implementations MUST NOT delete list elements during cleaning, because doing so changes list length and shifts later indices. + +In Source Documents, a list element that is `null`, an empty object `{}`, or an object that recursively normalizes to an empty object after object-field cleaning MUST be normalized to: + +```yaml +$empty: true +``` + +It MUST NOT be deleted from the list, because list position is content. + +In Canonical Identity Input and BlueId Input, `null` list elements and empty-object list elements MUST NOT appear. They MUST already have been normalized to `$empty: true` or rejected. + +The marker `$empty: true` is content. It occupies a list position and affects BlueId. + +Empty lists `[]` are preserved as list elements and are distinct from `$empty: true`. + +Consequences: + +```text +id([A, null, B] after preprocessing) == id([A, {$empty: true}, B]) +id([A, null, B] after preprocessing) != id([A, B]) +id([A, {}, B] after preprocessing) == id([A, {$empty: true}, B]) +id([A, [], B]) != id([A, {$empty: true}, B]) +``` + +### 11.6 Merge semantics (normative) + +Let `P` be the resolved parent list and `C` be the child overlay list. + +#### `append-only` + +For `mergePolicy: append-only`: + +- inherited indices `< length(P)` MUST NOT be modified or deleted; +- `$pos` overlays are forbidden; +- normal items after the inherited prefix are appended; +- an optional `$previous` anchor may appear as the first child item. + +Errors: + +- any `$pos` overlay; +- malformed `$previous`; +- `$previous` not first; +- repeated `$previous`; +- attempted modification, removal, or reordering of the inherited prefix. + +#### `positional` + +For `mergePolicy: positional`: + +- `$pos: i` refines inherited index `i`, where `0 <= i < length(P)`; +- map overlays merge field-wise, subject to type and schema compatibility; +- `$replace` overlays replace the inherited element, subject to compatibility; +- scalar `value` overlays replace the inherited element with a scalar node, subject to compatibility; +- normal items without `$pos` are appended after the inherited prefix in author order; +- reordering, removal, and gaps within the inherited prefix are forbidden. + +Errors: + +- `$pos` missing or non-integer; +- `$pos` out of range; +- duplicate overlays for the same index; +- type or schema incompatibility at the index; +- attempted reordering or removal of parent elements; +- `value` used as a non-scalar positional replacement. + +### 11.7 `$previous` validation (normative) + +`$previous` is a resolution-time anchor. + +During resolution, the resolver MUST verify that the inherited prefix hashes to `$previous.blueId`. If it does not match, resolution MUST fail. + +During direct Node BlueId calculation of valid BlueId Input that already contains a leading `$previous`, the anchor MAY be used as a list-fold seed (§14.8). Validity of the anchor is a precondition of the input. An implementation performing direct Node BlueId calculation without resolution context MAY reject `$previous` inputs. + +A direct hasher MUST NOT silently ignore `$previous` and recompute when it cannot verify the prefix. A direct hasher has no provider or inheritance context and therefore cannot determine whether an anchor is stale. + +### 11.8 List conformance checklist (normative) + +Implementations supporting lists MUST satisfy: + +- `id([])` is defined and distinct from absent values and cleaned object fields; +- `[A]` hashes differently from `A`; +- `[[A, B], C]` hashes differently from `[A, B, C]`; +- Source list `[A, null, B]` normalizes to `[A, {$empty: true}, B]`, not `[A, B]`; +- Source list `[A, {}, B]` normalizes to `[A, {$empty: true}, B]`, not `[A, B]`; +- Source list `[A, {x: null}, B]` normalizes to `[A, {$empty: true}, B]`, not `[A, B]`; +- `$previous` is recognized only as the first item; +- `$previous` mismatch fails resolution; +- `append-only` rejects `$pos`; +- inherited `append-only` remains effective when a child overlay omits `mergePolicy`; +- `positional` accepts valid `$pos` overlays and rejects duplicate or out-of-range overlays; +- `$empty: true` remains content and affects BlueId; +- malformed `$empty` placeholder items are rejected; +- object-field cleaning removes `null` and object fields that normalize to `{}`, but does not delete list positions. + +### 11.9 Worked examples (informative) + +Present-empty vs absent: + +```yaml +# Absent +doc: {} + +# Present-empty +doc: + list: + type: List + items: [] +``` + +Append-only timeline: + +```yaml +# Parent +entries: + type: List + itemType: Timeline Entry + mergePolicy: append-only + items: + - { type: Timeline Entry, ts: "2025-09-01T12:00:00Z", message: A } + - { type: Timeline Entry, ts: "2025-09-01T12:05:00Z", message: B } + +# Child +entries: + type: List + itemType: Timeline Entry + mergePolicy: append-only + items: + - $previous: { blueId: PrevId } + - { type: Timeline Entry, ts: "2025-09-01T12:10:00Z", message: C } +``` + +Positional hole and refinement: + +```yaml +# Parent +entries: + type: List + mergePolicy: positional + items: + - A + - $empty: true + - C + +# Child +entries: + type: List + mergePolicy: positional + items: + - $pos: 1 + value: B +# Resolved: [A, B, C] +``` + +--- + +## 12. References, Providers, Expansion, and Collapse + +### 12.1 Providers (informative) + +A **provider** is any mechanism that resolves a BlueId to node content. Examples include an in-memory map, a local registry, a database, or a content-addressed network store. + +This specification defines only the semantic role of providers. It does not define transport, trust, availability, or persistence protocols. + +### 12.2 Provider trust model (normative/informative) + +A provider MAY be untrusted. A conforming implementation MUST verify provider-returned content against the requested BlueId before using it for expansion, resolution, or canonicalization. + +BlueId verification provides content integrity: the returned content matches the requested content address. It does not provide authenticity, authorization, availability, freshness, confidentiality, or provenance of the provider itself. + +If a provider returns missing content, malformed content, content that does not verify under the declared provider mode, or content that requires unsupported resolution, the operation MUST fail deterministically. + +### 12.3 Provider content form (normative) + +A provider used to dereference a plain `blueId: X` in expansion, resolution, or canonicalization MUST return content whose direct Node BlueId is `X`, unless the provider is explicitly declared as a Source Document provider. + +The portable provider model for Blue Language 1.0 is a verified BlueId provider: provider content is already valid BlueId Input or canonical content. Implementations MUST verify the returned content by direct Node BlueId before using it. + +A Source Document provider MAY be supported as an implementation extension or registry mode. Such a provider verifies returned content by Content BlueId, not direct Node BlueId. This requires declaring the Blue Language version, preprocessing environment, provider state, and registry bindings used for Content BlueId calculation. A Source Document provider is not the default portable provider model. + +A conforming implementation MUST NOT silently accept Source Document provider content under the ordinary BlueId provider model. + +### 12.4 Plain BlueId provider verification (normative) + +When a provider returns materialized content for `blueId: X`, the implementation MUST verify that the returned content has Node BlueId `X`. If verification fails, expansion or resolution MUST fail deterministically. + +Implementations MUST NOT silently use provider content whose computed BlueId differs from the requested BlueId. + +### 12.5 Cyclic-set member provider verification (normative) + +A cyclic-set member BlueId of the form `#` cannot be verified by ordinary single-node Node BlueId calculation. + +A provider that returns content for a cyclic-set member BlueId MUST either: + +1. return a verified cyclic-set envelope containing the full ordered set needed to recompute `MASTER` and select member `index`; +2. be a trusted registry binding whose cyclic-set membership and `MASTER` were verified as part of the release artifact; or +3. fail deterministically. + +An implementation MUST NOT verify `#` by hashing the returned member alone. + +### 12.6 Expansion (normative) + +**Expansion** materializes content referenced by `blueId` from a provider without changing identity. + +Given: + +```yaml +field: + blueId: X +``` + +expansion fetches the content for `X`, verifies it (§12.4), and materializes it in place or side-by-side, enabling nested references to expand recursively. + +Expansion is a view operation. It changes representation, not meaning. + +Expansion MUST NOT change Node BlueId. A pure reference hashes to its target BlueId. Materialized content contributes the same identity when the materialized content verifies to that BlueId. + +Implementations SHOULD support path and depth limits to avoid runaway traversal of large graphs. Limits affect only materialization, not identity. + +### 12.7 Collapse (normative) + +**Collapse** is the inverse of expansion. It replaces a materialized subtree with a pure reference `{ blueId: X }` when the subtree's Node BlueId is known to be `X`. + +Collapse is optional as an exposed view operation. If an implementation exposes collapse, the operation MUST satisfy this section and MUST preserve Node BlueId. A collapsed result MUST be a pure reference and MUST NOT produce mixed `blueId` forms. + +Minimized Overlays MAY use collapse when the minimization rules permit it (§13). Canonical Identity Input MUST follow the deterministic canonicalization rules. + +### 12.8 Graph boundary (normative) + +A Blue Document need not be a closed tree. A `{ blueId: ... }` reference may point outside the selected document. Implementations materialize referenced content only as needed and within configured limits. + +### 12.9 Blue Language view paths (normative when exposed) + +Blue Language view paths are implementation-facing selectors used for expansion limits, collapse limits, diagnostics, and provenance. They are not Blue content and do not affect BlueId. + +A conforming implementation that exposes path-limited expansion, collapse, or diagnostics MUST support RFC 6901 JSON Pointer paths over the abstract Blue node model: + +- the empty string `""` selects the root node; +- `/field` selects an object field named `field`; +- `/items/0` selects list payload item index `0` in the abstract node model; +- `~0` represents `~`, and `~1` represents `/`, following RFC 6901. + +The path `/` selects an object field whose key is the empty string. Since empty object-field names are valid JSON member names but are not recommended in portable Blue documents, implementations MUST still treat `/` according to RFC 6901 if exposed. + +The wildcard `*`, such as `/spent/*`, is not part of the required Blue Language 1.0 path grammar. Implementations MAY support wildcards as an extension, but portable conformance fixtures MUST use RFC 6901 paths unless a future path-selector specification defines more. + +--- + +## 13. Canonicalization and Minimization + +### 13.1 Distinction (normative) + +Blue defines two related but different operations on a Resolved View. + +**Minimization** is any semantics-preserving reduction of a Resolved View into a smaller overlay. Different minimizers MAY produce different serialized forms. + +**Canonicalization** is the deterministic identity-input derivation used to compute Content BlueId. For a given Resolved View and the same provider state required by resolution, there is exactly one Canonical Identity Input. + +### 13.2 Canonical Identity Input (normative) + +A **Canonical Identity Input** is the deterministic identity form derived from a Resolved View. It contains the deterministic identity-bearing content needed for BlueId calculation. It may contain final canonical payloads, including final list payloads, that are not ordinary Source overlays. A Canonical Identity Input MUST be valid BlueId Input. It is not required to be accepted as a Source Document or to re-resolve under ordinary Source overlay semantics. + +The Content BlueId of a Source Document is the Node BlueId of its Canonical Identity Input. + +A Canonical Identity Input MUST NOT contain `blue`, unresolved aliases, `$previous`, `$pos`, `null` list elements, or empty-object list elements. + +The re-resolution guarantee belongs to Minimized Overlay (§13.3). A Canonical Identity Input and a Minimized Overlay MAY have different serialized forms and different direct Node BlueIds when hashed outside the full Source identity pipeline. + +### 13.3 Minimized Overlay (normative) + +A **Minimized Overlay** is an author-facing reduced overlay that re-resolves to the same Resolved View. + +A conforming implementation MUST implement canonicalization. A conforming implementation MAY expose author-facing minimization. If it does, every Minimized Overlay it produces MUST re-resolve to the same Resolved View and MUST produce the same Content BlueId through the full identity pipeline. + +Optional author-facing minimizers MAY produce different Minimized Overlays. Such overlays MAY have different direct Node BlueIds, but when processed through the full identity pipeline they MUST produce the same Content BlueId. + +Unlike Canonical Identity Input, a Minimized Overlay MAY contain authoring conveniences such as `$previous`, `$pos`, and `$replace` when those controls are valid Source overlay controls. + +### 13.4 Canonicalization requirements (normative) + +Given a Resolved View `R`, canonicalization MUST: + +- preserve all instance contributions that are not derivable from the type chain; +- remove fields fully derivable from the type chain; +- preserve instance-level `name` and `description` when present on the instance; +- not inherit top-level `name` or `description` from the type; +- preserve instance-fixed values that are not derivable from the type chain; +- replace materialized type objects with canonical `type: { blueId: ... }` references when their BlueId is known; +- ensure the Canonical Identity Input contains no type aliases; if an instance supplied a type alias, preprocessing MUST replace it with the canonical `type: { blueId: ... }` reference before resolution; +- for provider-materialized content, preserve the original pure reference when that reference is an instance contribution and the materialized subtree contributes no additional instance-supplied content; +- remove the `blue` directive if present, because it is invalid after preprocessing; +- normalize list placeholders so that list `null` and empty-object elements become `$empty: true`; +- consume all `$pos` overlays and produce final canonical list content; +- produce valid BlueId Input. + +Schema objects included in Canonical Identity Input MUST use normalized effective schema form. In particular, `enum` values are duplicate-free and sorted under §9.8.1, and integer `multipleOf` constraints are represented by the merged LCM value rather than by raw inherited/descendant contributions. + +### 13.5 Canonicalization as deterministic diff (normative) + +Canonicalization can be understood as a deterministic diff between the Resolved View and the resolved ancestor view contributed by the effective type chain. + +For each node: + +1. If the node has an effective type, include the canonical type reference unless the type reference itself is fully derivable at that path and not required by the canonical identity form. +2. For each reserved metadata field other than `type`, include it only when it is an instance contribution that is not derivable from the ancestor view, except where this specification requires preservation. +3. For each ordinary child field, omit it when the child is fully derivable from the ancestor view. Otherwise include the canonical identity input of the child. +4. For scalar values, omit an inherited fixed value and include an instance value not derivable from the ancestor. +5. For lists, use the canonical list rules in §13.6. +6. After the identity input is constructed, apply BlueId input normalization and object-field cleaning. Empty object fields are omitted. Empty lists are preserved. + +Implementations MUST make all tie-breakers deterministic and covered by conformance vectors. + +### 13.5.1 Canonicalization tie-breakers (normative) + +When multiple candidate identity inputs would represent the same Resolved View, the Canonical Identity Input MUST be selected by the following tie-breakers, in order: + +1. **Omit derivable non-list content.** A field, metadata entry, or non-list subtree that is fully derivable from the effective type chain MUST be omitted from the Canonical Identity Input, unless another rule in this section explicitly requires it. **List payloads are special:** for list nodes, §13.6 overrides this general omission rule. Canonicalization of a list produces the final canonical list payload for identity calculation, including inherited prefix elements, positional refinements, append-only appends, and `$empty` placeholders after normalization. +2. **Preserve non-derivable instance content.** Content supplied by the instance or Source Document and not derivable from the type chain MUST be preserved. +3. **Use pure references for referenced ancestors/types.** A materialized type or referenced ancestor whose BlueId is known MUST be represented as `{ blueId: X }` in type positions and other reference-preserving positions. +4. **Preserve source pure references materialized only for resolution.** If a Source Document provided a pure reference and the provider materialized it only to resolve or validate content, the Canonical Identity Input MUST prefer the original pure reference form unless the instance supplied an overlay that must be represented. +5. **Consume overlay controls.** `$pos`, `$replace`, `$previous`, source list `null`, and empty-object list elements MUST NOT appear in Canonical Identity Input. Their effects must be represented as ordinary canonical content. +6. **No authoring aliases.** Type aliases and `blue` preprocessing directives MUST NOT appear in Canonical Identity Input. +7. **Deterministic map ordering.** When serializing helper maps or canonical JSON, property order is the order defined by RFC 8785 canonical JSON. No locale-sensitive ordering, implementation insertion order, or host map order is permitted. +8. **Smallest semantic identity input wins.** If two candidate identity inputs both satisfy the rules above, the one with fewer non-derivable fields and fewer materialized subtrees wins. If still tied, the RFC 8785 canonical JSON byte sequence of the candidate identity input is compared lexicographically and the smaller byte sequence wins. + +These rules are part of the Blue Language 1.0 identity definition and MUST be implemented consistently. The conformance fixture suite provides examples but does not replace these rules. + +### 13.6 Canonical list rules (normative) + +Canonical list rules produce final list payload content for identity calculation. + +For list payloads, final canonical list content is the canonical identity form. This rule overrides the general "omit derivable content" tie-breaker in §13.5.1. Blue Language 1.0 does not define a canonical list-diff representation. + +For a list with no inherited prefix, the Canonical Identity Input contains the canonicalized full list. + +For an inherited list under `mergePolicy: append-only`, a Minimized Overlay MAY use a valid `$previous` anchor followed by appended elements. A Canonical Identity Input MUST NOT contain `$previous`. Canonicalization MUST produce the final canonical list payload before hashing. Implementations MAY internally optimize list hashing by using a verified inherited-prefix BlueId, but that optimization is not part of the serialized Canonical Identity Input. + +For an inherited list under `mergePolicy: positional`, a Minimized Overlay MAY represent inherited-index refinements using `$pos` overlays. A Canonical Identity Input MUST NOT contain `$pos`. Canonicalization MUST apply all positional overlays and produce the final canonical list payload before hashing. + +A final canonical list payload in Canonical Identity Input is identity input, not an instruction to append to or refine an inherited list under ordinary Source overlay semantics. + +### 13.7 Deterministic collapse during minimization (normative) + +A Minimized Overlay MAY collapse a subtree to `{ blueId: X }` only when: + +1. the subtree's Node BlueId is known to be `X`; +2. provider verification has established that `X` identifies that content if the subtree came from a provider; +3. collapse at that path is deterministic under the implementation's declared minimization rules; +4. the collapsed overlay re-resolves to the same Resolved View. + +A Canonical Identity Input MUST follow the deterministic canonicalization rules. Unless this specification explicitly requires collapse at a path, Canonical Identity Input MUST prefer the materialized canonical identity form. Optional collapse is an author-facing minimization feature, not a source of variation in Content BlueId. + +A Canonical Identity Input MUST NOT depend on implementation-local collapse preferences. + +--- + +## 14. BlueId Algorithm + +### 14.1 Hash function (normative) + +Let: + +```text +H(x) = Base58(SHA-256(RFC 8785 canonical JSON of x)) +``` + +BlueId is computed bottom-up over canonical BlueId Input using `H`. + +### 14.2 Context-sensitive cleaning and placeholder normalization (normative) + +Before hashing, implementations MUST normalize BlueId Input context-sensitively. + +#### Object-field cleaning + +For object fields: + +- remove fields whose value is `null`; +- remove fields whose value normalizes to an empty object `{}`; +- preserve fields whose value is an empty list `[]`; + +This removal is recursive and may cascade. + +#### List-element rules + +For list elements: + +- list elements MUST NOT be deleted merely because they are `null` or `{}`; +- in Source Documents, `null`, `{}`, and elements that recursively clean to empty objects MUST have been normalized to `$empty: true` before BlueId calculation; +- in BlueId Input, `null` and `{}` list elements are invalid; +- `[]` is preserved as an empty list element; +- `$empty: true` is preserved as placeholder content. + +This rule preserves list length, order, and positional meaning. + +In object-field context, an object that becomes empty after cleaning is omitted. In list-element context, a Source element that becomes empty after recursive cleaning is normalized to `$empty: true` before BlueId Input is produced. Direct BlueId Input MUST NOT contain raw empty-object list elements. + +#### Root normalization + +The root of BlueId Input is never omitted by cleaning. + +If the root is an empty object `{}`, its Node BlueId is `H({})`. + +If object-field cleaning causes the root object to become empty, the root remains `{}` and hashes as `H({})`. + +A root `null` value is not valid BlueId Input. Source Documents whose root is `null` MUST be rejected. Authors who intend an empty object document MUST write `{}`; authors who intend an empty list document MUST write `[]`. + +### 14.3 Canonical BlueId input normalization (normative) + +The BlueId algorithm hashes the abstract node model, not authoring syntax. + +Direct Node BlueId calculation does not run the full Source Document preprocessing pipeline. However, BlueId input normalization includes the mandatory primitive scalar inference needed to make bare scalar nodes identity-stable across conforming implementations. This inference is limited to the core primitive types listed below and does not apply aliases, imports, `blue` directives, or declared preprocessing transforms. + +Before hashing a Node value: + +- scalar sugar is normalized to scalar payload; +- list sugar is normalized to list payload; +- bare scalar payloads with no explicit type are assigned the corresponding core primitive type reference; +- integer values outside the safe JSON numeric integer range are represented as quoted canonical decimal text while retaining explicit `Integer` type (§2.4); +- finite `Double` values are converted to their canonical scalar representation; +- pure references are represented exactly as `{ blueId: X }`; +- `blue` is rejected; +- `$pos` is rejected; +- list `null` and empty-object elements are rejected unless already normalized to `$empty: true`. + +Primitive scalar inference for BlueId input normalization uses: + +| Parsed value kind | Inferred type | +|---|---| +| string | `Text` | +| integer numeric token with no decimal point or exponent, or explicitly typed canonical integer text | `Integer` | +| numeric token with a decimal point or exponent, or other non-integer finite number | `Double` | +| boolean | `Boolean` | + +A scalar payload with explicit type uses the explicit type, subject to resolution and validation. + +### 14.4 Scalars (normative) + +For BlueId calculation, every scalar payload node is normalized to a **typed scalar identity form** before hashing. If no explicit effective type is present, the inferred primitive type from §14.3 is inserted. Therefore an untyped Source scalar token `1` hashes as a scalar node with effective type `Integer`, while source tokens `1.0` and `1e0` hash as scalar nodes with effective type `Double`. The effective scalar type is part of identity. + +A bare scalar payload is represented as the canonical scalar value and, when converted to canonical BlueId input as a node, includes its inferred primitive type unless an explicit type is already present. + +Scalar values are encoded using RFC 8785 canonical JSON value rules after Blue scalar normalization. + +For `Integer`, implementations MUST preserve mathematical integer identity. Integer values outside the safe JSON numeric integer range MUST be encoded as canonical decimal text while retaining `type: Integer` in the canonical BlueId input (§2.4). + +For `Double`, only finite numbers are valid. `NaN`, `Infinity`, and `-Infinity` are invalid Blue scalar values. + +A `Double` value whose canonical JSON number renders as an integer-looking number, such as `1`, remains distinct from `Integer` because the canonical BlueId input retains `type: Double`. Numeric rendering alone does not determine scalar type after preprocessing. + +### 14.4.1 Payload normalization before hashing (normative) + +The BlueId algorithm hashes the abstract Blue node model, not raw JSON/YAML syntax. + +Before map hashing is applied, each node is classified as one of: + +1. pure reference; +2. scalar payload node; +3. list payload node; +4. object payload node; +5. metadata-bearing node. + +A node with a scalar payload and no retained metadata other than its effective scalar type and value hashes as the typed scalar identity form. "Payload-only scalar" does not mean hashing the raw JSON scalar alone; it means hashing the canonical Blue scalar node consisting of the effective primitive type reference and the canonical scalar value. If no explicit effective type is present, the inferred primitive type is inserted before hashing. + +A node with a list payload and no retained metadata other than the payload itself hashes as the list payload. + +Therefore these forms hash identically: + +```yaml +x: 1 +``` + +```yaml +x: + value: 1 +``` + +and these forms hash identically: + +```yaml +x: [a, b] +``` + +```yaml +x: + items: [a, b] +``` + +Thus these Source scalar tokens do not all have the same typed scalar identity unless an explicit type or schema says otherwise: + +```yaml +1 # effective type Integer, value 1 +1.0 # effective type Double, canonical numeric payload may render as 1 +1e0 # effective type Double, canonical numeric payload may render as 1 +``` + +`1.0` and `1e0` are equivalent Double values, but they are not equivalent to Integer `1` because the effective type differs. + +When a node has retained metadata such as `type`, `schema`, `name`, `description`, `itemType`, `mergePolicy`, or `contracts`, it hashes as a metadata-bearing map. In that case, `value` or `items` is the payload field of that metadata-bearing node and participates in map hashing as defined below. + +A node MUST NOT contain more than one payload kind. + +### 14.5 Map hashing (normative) + +Map hashing applies only after payload-only scalar and payload-only list nodes have been normalized as described above. + +If and only if a map is exactly: + +```json +{ "blueId": "" } +``` + +then its BlueId is ``. This is the pure reference short-circuit. + +A map containing `blueId` together with sibling fields is not a pure reference and MUST NOT appear in BlueId Input. + +Otherwise, build the helper map `M` conceptually. Its serialized property order is the order defined by RFC 8785 canonical JSON. Implementations MUST NOT use locale-sensitive collation or implementation insertion order. + +- for `name`, `description`, and `value`, inline their cleaned scalar values; +- for every other key `k` with value `v`, include: + +```json +"k": { "blueId": id(v) } +``` + +Then compute: + +```text +id(map) = H(M) +``` + +This rule ensures nested structure contributes through BlueId rather than through byte shape. It also makes materialized subtrees and pure references identity-equivalent when they have the same BlueId. + +### 14.6 Object fields with `null` (normative) + +Object fields with `null` values are omitted before map hashing: + +```yaml +a: null +b: 1 +``` + +normalizes as: + +```yaml +b: 1 +``` + +If recursive cleaning makes a child object empty, the child field is also omitted. Empty lists are preserved. + +### 14.7 List hashing (normative) + +Lists are hashed using a domain-separated streaming fold over element BlueIds. + +Empty list seed: + +```text +id([]) = H({ "$list": "empty" }) +``` + +Fold step: + +```text +fold(prevId, x) = + H({ + "$listCons": { + "prev": { "blueId": prevId }, + "elem": { "blueId": id(x) } + } + }) +``` + +The object passed to `H` in the fold step is serialized by RFC 8785; therefore property serialization order is determined by RFC 8785, not by the order shown in pseudocode. + +Whole list: + +```text +id([a1, ..., an]) = fold(fold(...fold(id([]), a1)...), an) +``` + +Properties: + +- order is significant; +- multiplicity is preserved; +- lists are not flattened; +- `[A]` is distinct from `A`; +- `[]` is distinct from absent values and cleaned object fields; +- `[A, {$empty: true}, B]` is distinct from `[A, B]`; +- append hashing can be O(delta) when seeded by a valid `$previous` anchor. + +### 14.8 List control normalization before hashing (normative) + +For direct anchored BlueId Input: + +- `$previous` MAY appear only as the first item. +- If present and well-formed, `$previous.blueId` MAY seed the list fold. +- Anchor validity is a precondition of direct anchored BlueId Input. +- A Canonical Identity Input produced by the Content BlueId pipeline MUST NOT contain `$previous`. +- Implementations MAY use a verified prefix BlueId as an internal hashing optimization. + +`$pos` and `$replace` MUST NOT appear in BlueId Input. `$empty: true` remains content and hashes as a normal object element. + +Malformed list controls MUST be rejected. + +### 14.8.1 Canonical JSON examples (informative but behavior-defining through referenced rules) + +#### Large Integer scalar node + +An Integer outside the safe JSON numeric integer range is represented as quoted canonical decimal text with explicit Integer type. + +Canonical BlueId Input shape: + +```yaml +type: + blueId: +value: "9007199254740992" +``` + +Map hashing builds helper map `M` conceptually: + +```json +{ + "type": { "blueId": "" }, + "value": "9007199254740992" +} +``` + +The RFC 8785 canonical JSON byte sequence is the UTF-8 encoding of: + +```json +{"type":{"blueId":""},"value":"9007199254740992"} +``` + +#### Double negative zero + +`Double` values use finite IEEE 754 binary64 semantics. Negative zero and positive zero compare as the same numeric value. Under RFC 8785 canonical JSON, the numeric value canonicalizes as JSON number `0`. + +A Source token such as `-0.0` infers `Double` if no explicit type is provided, but the canonical scalar numeric payload is `0` and the effective `type: Double` preserves the fact that the node is a Double rather than an Integer. + +#### Integer-looking Double + +A Source token such as `1.0` or `1e0` infers `Double`. The canonical JSON representation of the numeric payload may render as `1`, but the effective `type: Double` remains part of canonical BlueId input. Therefore `1` as Integer and `1.0` as Double are distinct Blue values unless an explicit type or schema says otherwise. + +#### List fold helper map ordering + +The list fold step uses the exact object keys `$listCons`, `prev`, and `elem`: + +```json +{"$listCons":{"elem":{"blueId":""},"prev":{"blueId":""}}} +``` + +The example shows the RFC 8785 canonical JSON serialization for these keys. Implementations MUST NOT rely on insertion order or host map order. + +### 14.9 Storage rule (normative) + +A node MUST NOT store its own BlueId as authoritative content. + +Using `{ blueId: ... }` to reference other nodes is permitted and encouraged. A provider or envelope MAY store a node's BlueId out-of-band, but the self-BlueId MUST NOT be treated as part of the node's own content. + +### 14.10 Inputs containing `blue` (normative) + +BlueId Input MUST NOT contain `blue`. A direct hasher MUST reject such input. + +--- + +## 15. Circular Reference Sets + +### 15.1 Purpose + +Some authoring graphs contain direct cycles across documents, for example `Person` references `Dog` and `Dog` references `Person`. Blue supports a combined BlueId for a cyclic set, with stable per-document suffixes. + +### 15.2 ZERO_BLUEID sentinel (normative) + +During cyclic-set calculation, each direct cyclic reference is temporarily replaced with the **ZERO_BLUEID** sentinel: forty-four ASCII `0` characters. + +ZERO_BLUEID is a sentinel only. It MUST NOT appear in finalized BlueId Input. + +During cyclic-set calculation, ZERO_BLUEID and `this#` are permitted only in positions where a BlueId string is expected inside the temporary cyclic-set calculation input. + +They are not valid ordinary BlueId Input and MUST NOT appear in finalized provider-stored content. + +### 15.3 Cyclic-set input (normative) + +The input to the cyclic-set algorithm is a finite set of document roots plus explicit internal reference markers indicating which references point to documents within the set. + +The algorithm applies to a strongly connected cyclic set. Independent strongly connected components SHOULD be processed separately. + +A cyclic-set calculation input MUST contain at least one internal cyclic reference. A set with no internal cyclic references SHOULD be treated as ordinary independent documents rather than as a cyclic set. + +If two cyclic-set members have identical preliminary BlueIds, implementations MUST compare the RFC 8785 canonical JSON byte sequence of their preliminary BlueId input as a deterministic tie-breaker. + +If the tie remains equal, the cyclic-set input is invalid in Blue Language 1.0 unless the members contain an explicit identity-bearing disambiguator before preliminary hashing. Implementations MUST fail cyclic-set calculation with `CircularSetError` rather than assigning arbitrary positions. + +Blue Language 1.0 does not define graph-isomorphism rules for duplicate preliminary cyclic members. + +### 15.4 Cyclic-set algorithm (normative) + +Given a finite set of documents participating in a direct cycle: + +1. Temporarily replace each internal cyclic `blueId` reference with ZERO_BLUEID. +2. Calculate preliminary BlueIds for each document in isolation. +3. Sort documents lexicographically by preliminary BlueId, with the tie-breaking rule from §15.3. +4. Assign positions `#0` through `#(n-1)` according to that order. +5. Rewrite each internal cyclic reference as: + +```yaml +blueId: this# +``` + +where `` is the assigned position of the target document. + +6. Build a list: + +```text +L = [doc#0, doc#1, ..., doc#(n-1)] +``` + +with `this#` references in place. + +7. Compute: + +```text +MASTER = id(L) +``` + +8. The final BlueId of document `i` is: + +```text +MASTER#i +``` + +The **preliminary BlueId input** for each document is the document after replacing each direct internal cyclic `blueId` reference with ZERO_BLUEID and before rewriting those references to `this#`. + +`this#` is accepted only by the cyclic-set calculation API. It MUST NOT appear in stored provider content, ordinary BlueId Input, Source Documents outside explicit cyclic-set serialization, or Canonical Identity Input. + +During preliminary BlueId calculation with ZERO_BLUEID placeholders, a pure reference `{ blueId: ZERO_BLUEID }` is treated as a temporary pure reference whose identity contribution is the sentinel value for the purpose of preliminary ordering only. ZERO_BLUEID MUST NOT be returned as a finalized BlueId. + +During MASTER calculation, pure references `{ blueId: "this#" }` are treated as internal cyclic placeholders as defined by the cyclic-set algorithm, not as ordinary provider references. + +Cyclic-set identity flow: + +```text +authoring refs + | + v +replace internal refs with ZERO_BLUEID + | + v +preliminary ids -> sort -> assign #0..#(n-1) + | + v +rewrite internal refs to this#k + | + v +MASTER = id([doc#0, doc#1, ...]) + | + v +final ids = MASTER#0, MASTER#1, ... +``` + +### 15.5 BlueId grammar for cyclic sets (normative) + +A cyclic-set member BlueId has the form: + +```text +# +``` + +where `MASTER` is a plain BlueId and `index` is a non-negative decimal integer with no leading zeros, except for the single digit `0`. + +`this#` is an algorithm-internal placeholder. It is accepted only by an implementation API explicitly performing cyclic-set calculation over a declared finite cyclic set. It MUST be rejected by ordinary parsing, preprocessing, resolution, provider storage, expansion, canonicalization, and direct BlueId calculation outside that cyclic-set calculation API. + +### 15.6 Example (informative) + +```yaml +# Dog (#0 after sorting) +name: Dog +owner: + type: + blueId: this#1 +breed: + type: Text + +# Person (#1 after sorting) +name: Person +pet: + type: + blueId: this#0 +``` + +If `MASTER = 12345...`, then: + +```text +Dog = 12345...#0 +Person = 12345...#1 +``` + +--- + +## 16. Conformance Vectors + +The Blue Language 1.0 conformance suite, canonical core registry, and this prose specification jointly define Blue Language 1.0. The prose rules are normative, the registry supplies exact identity-bearing core type nodes and BlueIds, and the fixtures provide behavior-defining executable examples. + +A fixture package identity MUST be published with the Blue Language 1.0 release. A conforming implementation MUST report which fixture package identity it passes. + +If the prose specification, registry, and fixture package conflict, the release artifact is invalid and MUST be corrected. Implementations MUST NOT guess which artifact wins. + +Conformance vectors are behavior-defining. A conforming Blue Language 1.0 implementation MUST pass all vectors in this section and all machine-readable fixtures in the Blue Language 1.0 conformance suite. + +The labels `B`, `R`, and `F` identify fixture categories: BlueId algorithm, resolution/canonicalization, and provider/full-graph behavior. They do not define separate conformance levels. + +### 16.1 BlueId algorithm vectors + +- **B1.** `id([])` is defined and distinct from absent values and cleaned object fields. +- **B2.** `[A]` hashes differently from `A`. +- **B3.** `[[A, B], C]` hashes differently from `[A, B, C]`. +- **B4.** `x: 1` and `x: { value: 1 }` produce the same Node BlueId after canonical input normalization. +- **B5.** `x: [a, b]` and `x: { items: [a, b] }` produce the same Node BlueId. +- **B6.** A map exactly `{ blueId: X }` hashes to `X`. +- **B7.** Object-field cleaning removes `null` fields and fields that normalize to empty objects. +- **B8.** Cleaning preserves `[]`. +- **B9.** A node containing `blue` is rejected as direct BlueId Input. +- **B10.** A map mixing `blueId` with sibling fields is rejected as BlueId Input. +- **B11.** Primitive scalar inference assigns `Text`, `Integer`, `Double`, and `Boolean` deterministically. +- **B12.** `$empty: true` remains content and affects BlueId. +- **B13.** Direct BlueId Input containing a `null` list element is rejected. +- **B14.** Direct BlueId Input containing an empty-object list element is rejected unless it has already been normalized to `$empty: true` before direct hashing. +- **B15.** `[A, {$empty: true}, B]` hashes differently from `[A, B]`. +- **B16.** Integer values above `9007199254740991` or below `-9007199254740991` are represented as quoted canonical decimal text with explicit `Integer` type. +- **B17.** `this#` is rejected outside the explicit cyclic-set calculation API. +- **B18.** A source numeric token `1` infers `Integer`; source numeric tokens `1.0` and `1e0` infer `Double`; explicit `type: Double` remains Double even when the canonical JSON number renders as `1`. +- **B19.** Root `{}` is valid BlueId Input and hashes as an empty object; it is not omitted. +- **B20.** Root `null` is invalid as Source Document root and as BlueId Input. +- **B21.** Plain BlueIds validate as canonical Base58 encodings of exactly 32 bytes; invalid alphabet characters, non-canonical encodings, wrong decoded length, and plain ID strings containing `#` are rejected. +- **B22.** `$empty` list placeholder shape is exactly `{ "$empty": true }`; malformed `$empty` items are rejected. +- **B23.** `Double` negative zero canonicalizes to numeric payload `0` while retaining Double type. +- **B24.** `Double` overflow is rejected. +- **B25.** Integer-looking Double canonical rendering retains Double type. +- **B26.** Payload-only scalar hashing uses typed scalar identity form, not raw JSON scalar hashing. +- **B27.** Enum order and duplicate entries do not affect effective canonical schema identity. +- **B28.** `Double` `multipleOf` is evaluated by exact rational arithmetic over IEEE 754 binary64 values. +- **B29.** A cyclic-set input with duplicate preliminary member inputs fails unless the members contain identity-bearing disambiguators before preliminary hashing. + +### 16.2 Resolution and canonicalization vectors + +- **R1.** Preprocessing removes `blue` and applies baseline transforms before resolution. +- **R2.** Source list `[A, null, B]` preprocesses to `[A, {$empty: true}, B]`, not `[A, B]`. +- **R3.** Source list `[A, {}, B]` preprocesses to `[A, {$empty: true}, B]`, not `[A, B]`. +- **R4.** Type chains merge according to the overlay and subtyping rules. +- **R5.** Fixed-value invariants cannot be overridden. +- **R6.** Schema constraints accumulate; irreconcilable constraints fail resolution. +- **R7.** Schema objects containing keys outside §9.2 are rejected. +- **R8.** `name` and `description` are ignored by matchers and subtype checks. +- **R9.** Type root `name` and `description` are not inherited onto the instance root. +- **R10.** A Source Document and its Resolved View, after canonicalization, produce the same Content BlueId. +- **R11.** Requirement overlays bind valid type completions and reject conflicting completions. +- **R12.** `$previous` is validated against the resolved inherited prefix; mismatch fails resolution. +- **R13.** `mergePolicy` defaults to `positional` only when there is no inherited effective `mergePolicy`. +- **R14.** Append-only lists reject `$pos`. +- **R15.** Positional lists reject inherited-prefix reordering and removal. +- **R16.** A Minimized Overlay re-resolves to the same Resolved View. +- **R17.** Canonical Identity Input does not contain `$previous`, `$pos`, `blue`, unresolved aliases, `null` list elements, or empty-object list elements. +- **R18.** Direct hashing of a Resolved View is not used as Content BlueId unless the Resolved View is already identical to its Canonical Identity Input. +- **R19.** Canonical Identity Input for append-only lists does not serialize `$previous`; `$previous` may appear only in Minimized Overlay or direct anchored BlueId Input. +- **R20.** Canonical Identity Input contains no type aliases; all type references are canonical BlueId references. +- **R21.** A source pure reference that is materialized only for resolution canonicalizes back to the pure reference unless the source overlays additional instance content onto it. +- **R22.** A child overlay of an inherited `append-only` list that omits `mergePolicy` remains `append-only`; `$pos` is still rejected. +- **R23.** A descendant collection that omits inherited `itemType`, `keyType`, or `valueType` retains the inherited constraint. +- **R24.** Canonical positional list refinements produce final canonical list payloads, not Source overlay instructions. +- **R25.** Minimized positional list overlays may use `$pos` and re-resolve to the same Resolved View. +- **R26.** Canonical append-only list overlays do not contain `$previous`; minimized append-only overlays may use `$previous`. +- **R27.** Inherited effective Integer type accepts quoted canonical large decimal text. +- **R28.** Quoted decimal text without effective Integer type remains Text. +- **R29.** Inherited effective Integer type rejects non-canonical decimal text. +- **R30.** Declaration-only label overrides are allowed, but label overrides on inherited fixed-value nodes are rejected. +- **R31.** Type-chain cycles and self-type cycles are rejected. +- **R32.** Required metadata-only fields fail, while required instance payloads and inherited fixed payloads pass. +- **R33.** `minFields` and `maxFields` count ordinary fields only. +- **R34.** Wrong-kind schema keywords fail schema validation. +- **R35.** `itemType`, `keyType`, and `valueType` validate resolved collection members. +- **R36.** Direct Dictionary integer keys use canonical textual form and reject duplicate key conflicts after canonicalization. +- **R37.** Source list `[A, { x: null }, B]` preprocesses to `[A, { $empty: true }, B]`. +- **R38.** Canonical core type compatibility is nominal by registry BlueId. +- **R39.** Blue Language view path root is the empty string under RFC 6901; `/` selects the empty-key member. + +### 16.3 Provider, expansion, and collapse vectors + +- **F1.** All B-vectors and R-vectors pass. +- **F2.** Expansion preserves Node BlueId. +- **F3.** If the implementation exposes collapse, collapse preserves Node BlueId and produces only valid pure references. +- **F4.** Expansion supports configurable depth or path limits that do not affect identity. +- **F5.** Cross-document references resolve through a provider without changing identity. +- **F6.** Missing provider content required for resolution fails deterministically. +- **F7.** Ordinary BlueId provider content whose computed Node BlueId does not equal the requested BlueId is rejected. +- **F8.** Source Document provider content requires a declared Source Document provider mode and Content BlueId verification. +- **F9.** Cyclic-set member provider content requires cyclic-set-aware verification context. + +### 16.4 Machine-readable fixtures (normative) + +The Blue Language 1.0 conformance suite MUST publish machine-readable fixtures with exact expected BlueIds. + +The canonical fixture package is part of the Blue Language 1.0 release artifact and is versioned with this specification. + +The Blue Language 1.0 release authority MUST publish the fixture package identity, either as a BlueId or as a content-addressed release artifact digest. + +The fixture package identity for this Blue Language 1.0 publication is: + +```text +sha256:3387cb4b6626fc56cec91d584b2df7f37c229e396dee990750ac50e762a1bc1d +``` + +No inline reference BlueIds are included in this prose specification. Exact hashes live in the canonical fixture package. + +Each fixture SHOULD use this shape: + +```yaml +id: B4 +category: BlueId +description: scalar sugar and wrapped scalar are equivalent +input: + x: 1 +expectedNodeBlueId: "" +alsoEquivalentTo: + x: + value: 1 +``` + +Fixtures involving Content BlueId SHOULD include: + +```yaml +id: R10 +category: Resolution +source: ... +provider: ... +expectedCanonicalIdentityInput: ... +expectedContentBlueId: "" +``` + +Error fixtures MAY include: + +```yaml +expectedErrorCategory: SchemaViolation +``` + +or, for multiple valid categories: + +```yaml +expectedErrorCategories: [InvalidBlueId, InvalidReferenceShape] +``` + +The expected BlueIds are part of the specification test surface. Changing one requires either correcting an error in the specification or declaring a new incompatible language version. + +The fixture suite MUST cover: + +- scalar values; +- large integers represented as quoted canonical decimal strings; +- wrapped vs sugar forms; +- pure references; +- root scalar, list, object, and pure reference forms; +- empty list; +- empty object root; +- root null rejection; +- plain BlueId validation; +- portable `blue.imports` alias resolution; +- portable YAML rejection of anchors, aliases, merge keys, custom tags, YAML-only types, and implicit timestamp typing; +- YAML multiline block scalar identity; +- schema keyword value-shape validation; +- schema wrong-kind validation; +- enum order and duplicate normalization; +- exact `Double` `multipleOf` validation using rational binary64 semantics; +- required field semantic-presence validation; +- field counting for ordinary object fields only; +- deterministic integer `multipleOf` LCM merge; +- enum scalar type inference; +- typed scalar identity for payload-only scalar hashing; +- object-field null removal; +- list null placeholder normalization; +- list empty-object placeholder normalization; +- recursive list element placeholder normalization after object-field cleaning; +- `$empty`; +- malformed `$empty` rejection; +- `$pos` map overlay and `$replace` compatibility; +- append-only `$previous`; +- Canonical Identity Input final list payloads are identity input, not ordinary Source overlays; +- Minimized Overlay re-resolution for `$pos` and `$previous` list controls; +- inherited `mergePolicy`; +- inherited collection type constraints; +- `itemType`, `keyType`, and `valueType` validation; +- direct Dictionary key canonicalization and duplicate conflict rejection; +- reserved-invalid `properties` rejection; +- materialized subtree vs pure reference; +- provider Node BlueId verification, declared Source provider verification, and cyclic-set member verification; +- RFC 6901 Blue Language view paths, including empty-string root and `/` empty-key member behavior; +- type alias preprocessing; +- type-chain cycle detection; +- nominal core type compatibility by registry BlueId; +- primitive inference; +- core registry Text node hashes to its published BlueId; +- core registry Integer node hashes to its published BlueId; +- core registry Double node hashes to its published BlueId; +- core registry Boolean node hashes to its published BlueId; +- core registry Dictionary node hashes to its published BlueId; +- core registry List node hashes to its published BlueId; +- changing a core type `description` changes the node BlueId; +- circular references; +- duplicate preliminary cyclic-set member rejection unless identity-bearing disambiguators are present before preliminary hashing; +- error category classification; +- publication lint that rejects obsolete conformance terminology in publishable Blue Language 1.0 files and requires the §1 heading used by this specification. + +The Blue Language core registry manifest MUST make identity-bearing descriptions explicit. Each entry in the registry manifest MUST identify the registry kind, specification version, entry key, canonical node path, published BlueId, and `semanticDescriptionIdentityBearing: true`. + +Release checks MUST verify that: + +- registry nodes are loaded from files, not reconstructed from implementation constants; +- registry file content hashes to the published BlueIds; +- core type alias constants equal the calculated registry BlueIds; +- no canonical registry node is edited without updating its BlueId and fixture package identity; +- generated documentation is derived from registry nodes, or explicitly marked non-canonical; +- publishable Blue Language files pass the documentation lint before release. + +--- + +## 17. Worked Examples + +BlueIds ending in `...` in this section are illustrative placeholders, not conformance vectors. Exact expected BlueIds are defined by the machine-readable fixture suite (§16.4). + +### 17.1 Content-addressable types (informative) + +```yaml +name: Simple Amount +amount: + type: Double +currency: + type: Text +# => blueId: FgHZjS... + +name: Person +age: + type: Integer +spent: + type: + blueId: FgHZjS... # Simple Amount +# => blueId: GRwTYs... +``` + +Instance: + +```yaml +name: Alice +type: + blueId: GRwTYs... # Person +age: 25 +spent: + amount: 27.15 + currency: USD +# => Content BlueId: 3JTd8s... +``` + +Expanding the type chain produces an Expanded View. Resolving produces a Resolved View. Canonicalizing the Resolved View produces a Canonical Identity Input whose Node BlueId is the Content BlueId of the instance. + +### 17.2 `blue` directive (informative) + +```yaml +blue: + imports: + Person: + blueId: GRwTYs... +name: Alice +type: Person +age: 25 +``` + +Preprocessing replaces `Person` with its BlueId reference, infers primitive scalar types, and removes `blue` before hashing. + +### 17.3 Large integer (informative) + +```yaml +accountId: + type: Integer + value: "9007199254740992" +``` + +The value is quoted because it is outside the safe JSON numeric integer range. The explicit `Integer` type distinguishes it from Text. + +Numeric token inference: + +```yaml +a: 1 # inferred Integer +b: 1.0 # inferred Double +c: 1e0 # inferred Double +d: + type: Double + value: 1 +``` + +`b`, `c`, and `d` are Double values even when their canonical JSON number renders as `1`. + +### 17.4 Same image, different meaning (informative) + +```yaml +# A +name: Person to Avoid +description: This guy will kill you today +type: Image +image: + blueId: 123...456 + +# B +name: Family Member +description: Trust this person +type: Image +image: + blueId: 123...456 +``` + +These have different Content BlueIds because `name` and `description` are identity content. Structural and type matchers ignore those labels. + +### 17.5 Requirement overlay followed by type binding (informative) + +```yaml +# Parent +name: A +prop1: + x: 1 + +# Child +name: B +type: A +prop1: + type: Some +``` + +The child is valid only if `Some` can resolve while preserving `x = 1`. If `Some` forces `x = 2`, resolution fails. + +### 17.6 Lists: refine and append (informative) + +```yaml +# Parent +name: Trip +segments: + type: List + itemType: Flight Segment + items: + - type: Flight Segment + carrier: BA + +# Child +name: Trip LHR to SFO +type: Trip +segments: + items: + - $pos: 0 + from: LHR + to: JFK + - type: Flight Segment + carrier: BA + from: JFK + to: SFO +``` + +The child refines inherited index `0` and appends a second segment. Reordering or deleting the inherited prefix would be invalid. + +### 17.7 Null list element as placeholder (informative) + +```yaml +items: + - A + - null + - B +``` + +preprocesses to: + +```yaml +items: + - A + - $empty: true + - B +``` + +It does not preprocess to `[A, B]`. + +### 17.8 Expansion with limits (informative) + +Starting from: + +```yaml +blueId: 3JTd8s... # Alice +``` + +expanding `/spent` may hydrate only the `spent` subtree: + +```yaml +name: Alice +type: + blueId: GRwTYs... +age: 25 +spent: + amount: 27.15 + currency: USD +``` + +Node BlueId is unchanged if the hydrated content verifies to the referenced BlueIds. + +### 17.9 Canonicalization (informative) + +From a Resolved View with fully materialized type subtrees, canonicalization: + +- collapses type objects to `{ blueId: ... }` when available; +- removes structure derivable from the type chain; +- consumes `$pos` overlays; +- normalizes list placeholders to `$empty: true`; +- keeps instance contributions; +- produces valid BlueId Input. + +The Canonical Identity Input yields the Content BlueId. A Minimized Overlay, when produced, re-resolves to the same Resolved View through ordinary Source overlay semantics. + +### 17.10 Contracts merge as content (informative) + +```yaml +# Parent type +name: With Audit +contracts: + audit: + type: Audit Contract + enabled: true + +# Child instance +type: With Audit +contracts: + audit: + retentionDays: 30 +``` + +Language resolution merges `contracts.audit` as content. It does not execute the contract. The resolved contract entry contains both `enabled: true` and `retentionDays: 30`, unless normal fixed-value, type, or schema rules reject the merge. + +### 17.11 Common invalid forms (informative) + +Mixed reference and content is invalid: + +```yaml +blueId: X +name: Not allowed +``` + +`blue` is root-only and preprocessing-only: + +```yaml +child: + blue: something +``` + +`$pos` cannot appear in Canonical Identity Input or BlueId Input: + +```yaml +items: + - $pos: 0 + value: A +``` + +Use `$replace` for non-scalar positional replacement: + +```yaml +# Invalid +- $pos: 0 + value: + items: [A, B] + +# Valid +- $pos: 0 + $replace: + items: [A, B] +``` + +--- + +## Appendix A — Core Primitive and Collection Types + +Appendix A defines the canonical primitive and collection types referenced throughout this specification. + +The nodes in §A.1 are canonical type definitions, not illustrative sketches. Their `description` fields are normative, identity-bearing Blue content. The exact registry files used to calculate published BlueIds MUST be byte/string equivalent after Blue parsing to the intended canonical nodes. + +Changing a canonical node's `description` is a type-identity change. Implementations MUST NOT silently update canonical descriptions while keeping the old BlueId. + +If a typo or editorial issue is found after publication and it does not change semantics, publish errata outside the canonical node. If the text change is intended to alter or clarify the type's meaning in an identity-bearing way, publish a new registry entry with a new BlueId. + +### A.1 Canonical core type nodes + +#### Text + +```yaml +name: Text +description: > + Core Blue Language 1.0 primitive scalar representing Unicode text. Text + values are exact Unicode code-point sequences after parsing. Blue Language + performs no Unicode normalization, case folding, locale-sensitive collation, + whitespace normalization, or line-ending normalization by default. String + schema constraints minLength and maxLength count Unicode code points. The + empty string is valid unless restricted by schema. Applicable schema + constraints are minLength, maxLength, and enum. +``` + +#### Integer + +```yaml +name: Integer +description: > + Core Blue Language 1.0 primitive scalar for exact mathematical integer + values. Integer values are arbitrary precision in the language model. + Unquoted integer tokens are portable only in the safe JSON numeric integer + range [-9007199254740991, 9007199254740991]. Integer values outside that + range are represented as quoted canonical decimal text with explicit or + inherited effective Integer type. The canonical decimal text form uses an + optional leading minus sign followed by decimal digits, with no leading + zeros except the single digit zero. Applicable schema constraints are + minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, and enum. +``` + +#### Double + +```yaml +name: Double +description: > + Core Blue Language 1.0 primitive scalar for finite IEEE 754 binary64 + floating-point values. NaN, positive Infinity, and negative Infinity are + invalid Blue values. Double parsing uses round-to-nearest, ties-to-even + binary64 semantics; numeric tokens that overflow to Infinity or parse as NaN + are invalid. Source numeric tokens with a decimal point or exponent infer + Double when no explicit type is provided, even when their mathematical value + is integral. Negative zero and positive zero compare as the same numeric + value and canonicalize as JSON number zero, while the effective Double type + remains part of canonical BlueId input. Applicable schema constraints are + minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, and enum. +``` + +#### Boolean + +```yaml +name: Boolean +description: > + Core Blue Language 1.0 primitive scalar with exactly two values: true and + false. Blue Language defines no truthiness conversion for Boolean values. + Only the literal parsed boolean values true and false are Boolean values. + Applicable schema constraint is enum. +``` + +#### Dictionary + +```yaml +name: Dictionary +description: > + Core Blue Language 1.0 object-map collection type. A Dictionary is encoded + as a Blue object node whose ordinary child fields represent direct keys + when those keys do not collide with reserved language fields. Direct object + encoding cannot represent data keys named name, description, type, itemType, + keyType, valueType, value, items, blueId, blue, schema, mergePolicy, + contracts, properties, or constraints. Direct object encoding cannot + represent reserved language keys as data keys. Applications needing + arbitrary keys use an application-defined escaped representation. keyType is optional; if + omitted and no effective keyType is inherited, keys default to Text for + direct object encoding. For direct object encoding, keyType must resolve to + a scalar key type with a canonical textual form, such as Text, Integer, + Double, or Boolean. valueType is optional; if omitted and no effective + valueType is inherited, values may be any Blue node. Applicable schema + constraints are minFields and maxFields. +``` + +#### List + +```yaml +name: List +description: > + Core Blue Language 1.0 ordered collection type. Surface array form and + wrapped items form are equivalent authoring forms. Order and multiplicity + are preserved. List BlueId calculation uses a domain-separated streaming + fold over element BlueIds. itemType is optional; if omitted and no effective + itemType is inherited, elements are not constrained by itemType. If + mergePolicy is omitted and no effective mergePolicy is inherited, resolvers + assume positional. append-only forbids changes to the inherited prefix. + positional allows $pos overlays within the inherited prefix. $previous, + $pos, $replace, and $empty are recognized only at the top level of items + when the node's effective type is List. Source list null and empty object + elements normalize to $empty: true and are not deleted. Applicable schema + constraints are minItems, maxItems, and uniqueItems. +``` + +### A.2 Editorial and registry rules + +The canonical registry nodes above are part of the Blue Language 1.0 type identity. Non-normative examples, tutorials, rationale, translations, and implementation notes are not part of the canonical type nodes unless intentionally included in the registry entries. + +Additional explanatory documentation MAY follow this appendix or appear in separate registry documentation, but it MUST be clearly marked non-canonical unless it is included in the registry node itself. + +--- + +## Appendix B — Reserved Extension Boundary + +`contracts` is reserved for the Blue Contracts and Processor Specification. Blue Language 1.0 treats it as identity-bearing content only. See §4.4. + +--- + +## Appendix C — Common Implementer Mistakes + +This appendix is informative. + +### C.1 Do not delete list positions + +`[A, null, B]` does not mean `[A, B]`. Source list `null` and `{}` elements normalize to `$empty: true`. + +### C.2 Do not hash `blue` + +`blue` is a preprocessing directive. Direct BlueId input containing `blue` must be rejected. + +### C.3 Do not treat `value` as a generic replacement field + +`value` is the scalar payload wrapper. Positional non-scalar replacement uses `$replace`. + +### C.4 Do not let `$pos` reach BlueId input + +`$pos` is an overlay instruction. Canonical Identity Input and direct BlueId Input must not contain `$pos`. + +### C.5 Do not trust provider content without verification + +When expanding `blueId: X` through an ordinary BlueId provider, compute the returned content's Node BlueId and verify that it equals `X`. + +### C.6 Do not treat `name` and `description` as comments + +They affect BlueId. They are ignored by matchers, not by identity. + +### C.7 Use only the schema keywords defined in §9 + +A `schema` object accepts only the keywords listed in §9.2. + +### C.8 Do not use reserved language keys as ordinary object fields + +Reserved keys such as `type`, `value`, `items`, and `schema` have language meaning. + +--- + +## Appendix D — Error Categories + +This appendix is normative for conformance diagnostics but does not require a particular exception class, wire format, or exact error message. + +When an operation fails deterministically, implementations MUST be able to classify the failure into one of these categories for conformance reporting: + +| Category | Meaning | +|---|---| +| `InvalidSyntax` | Serialized JSON/YAML is malformed or outside the Blue JSON data model. | +| `DuplicateKey` | A serialized object contains duplicate keys. | +| `InvalidReservedField` | A reserved field has an invalid type, shape, or position. | +| `InvalidBlueId` | A BlueId string is malformed or invalid for its context. | +| `InvalidReferenceShape` | `blueId` appears with sibling fields or invalid mixed reference shape. | +| `InvalidBlueIdInput` | Direct Node BlueId received a node that is not valid BlueId Input. | +| `ProviderUnavailable` | Required provider content is unavailable. | +| `ProviderBlueIdMismatch` | Provider content does not verify against the requested BlueId. | +| `TypeCycle` | Resolution detected a type-cycle in the active type stack. | +| `FixedValueConflict` | A descendant attempted to override or contradict an inherited fixed value. | +| `TypeCompatibilityViolation` | A descendant type, itemType, keyType, or valueType is incompatible with an inherited constraint. | +| `SchemaVocabularyError` | A schema contains an unknown keyword or invalid schema value shape. | +| `SchemaViolation` | A node violates accumulated schema constraints. | +| `ListControlViolation` | `$previous`, `$pos`, `$replace`, or `$empty` has invalid shape or context. | +| `CanonicalizationError` | A Canonical Identity Input cannot be produced deterministically. | +| `CircularSetError` | Cyclic-set input is malformed or cannot produce deterministic member IDs. | +| `UnsupportedPreprocessingTransform` | A Source Document requires a preprocessing transform that is unsupported. | + +An invalid document may contain multiple independent errors. Blue Language 1.0 does not require a universal precedence order for all possible simultaneous failures. Conformance fixtures that assert an exact error category MUST isolate one primary error so that a conforming implementation can deterministically report that category without ambiguity. If a fixture intentionally contains multiple independent errors, it MUST assert only that the operation fails, or it MUST explicitly declare acceptable error categories. + +--- + +*End of Blue Language Specification 1.0.* diff --git a/src/test/resources/processor/contracts/all-contracts.blue b/src/test/resources/processor/contracts/all-contracts.blue index a49796e..0b79ccf 100644 --- a/src/test/resources/processor/contracts/all-contracts.blue +++ b/src/test/resources/processor/contracts/all-contracts.blue @@ -1,44 +1,44 @@ contracts: embedded: type: - blueId: ProcessEmbedded + blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q paths: - /payment - /shipping documentUpdate: type: - blueId: DocumentUpdateChannel + blueId: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o path: / triggered: type: - blueId: TriggeredEventChannel + blueId: 5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ lifecycleChannel: type: - blueId: LifecycleChannel + blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ embeddedNode: type: - blueId: EmbeddedNodeChannel + blueId: H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i childPath: /payment checkpoint: type: - blueId: ChannelEventCheckpoint + blueId: 9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1 lastEvents: external: type: - blueId: TestEvent + blueId: Hi8TpcNruWrzfjRGFPDxtviZYap9oJwAFgSnZ6vED8Yf eventId: evt-001 initialized: type: - blueId: InitializationMarker + blueId: 6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q documentId: doc-123 failure: type: - blueId: ProcessingFailureMarker + blueId: 33kfH8pfk7F1P5zMsuK1Jm3GcSdmTXoFHKjP16DesEco code: RuntimeFatal reason: boundary violation setProperty: type: - blueId: SetProperty + blueId: 8Vii45Ph3HBUX2ZMEarxXXUBDPrXemrvqJergPr3BNts channel: lifecycleChannel propertyKey: /x propertyValue: 7 From d1839616eabc8c631addbfb8fb7e635452bea52d Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 26 May 2026 20:57:13 +0200 Subject: [PATCH 4/6] feat: introduce `WorkingDocument` and checkpoint management enhancements Added `WorkingDocument` for processor-side read-your-writes consistency, supporting patch previews and non-committal updates. Enhanced checkpoint management with identity caching (`CheckpointIdentityCache`) and metrics for performance visibility (`ProcessingMetricsSink`). Updated processors to leverage `WorkingDocument` previews and checkpoint improvements for optimized event handling. Updated `README.md` with comprehensive usage examples. --- README.md | 33 +++ src/main/java/blue/language/Blue.java | 94 +++++- .../language/processor/BatchPatchResult.java | 121 +++++++- .../processor/BatchPatchTransaction.java | 32 ++- .../language/processor/ChannelRunner.java | 97 +++++-- .../processor/CheckpointIdentityCache.java | 87 ++++++ .../CheckpointIdentityCalculator.java | 24 +- .../language/processor/CheckpointManager.java | 28 +- .../processor/ContractEffectBuffer.java | 39 ++- .../processor/DocumentProcessingRuntime.java | 269 ++++++++++++++++-- .../language/processor/DocumentProcessor.java | 4 + .../processor/ProcessingMetricsSink.java | 72 +++++ .../language/processor/ProcessorEngine.java | 12 +- .../processor/ProcessorExecutionContext.java | 26 +- .../language/processor/ScopeExecutor.java | 68 ++++- .../language/processor/WorkingDocument.java | 269 ++++++++++++++++++ ...umentProcessorSnapshotTransactionTest.java | 121 +++++++- 17 files changed, 1307 insertions(+), 89 deletions(-) create mode 100644 src/main/java/blue/language/processor/CheckpointIdentityCache.java create mode 100644 src/main/java/blue/language/processor/WorkingDocument.java diff --git a/README.md b/README.md index 8195d2d..12ee86b 100644 --- a/README.md +++ b/README.md @@ -581,6 +581,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. diff --git a/src/main/java/blue/language/Blue.java b/src/main/java/blue/language/Blue.java index 6af0d7d..3a7560b 100644 --- a/src/main/java/blue/language/Blue.java +++ b/src/main/java/blue/language/Blue.java @@ -16,6 +16,7 @@ import blue.language.processor.ContractProcessor; import blue.language.processor.ContractMatchingService; import blue.language.processor.DocumentProcessor; +import blue.language.processor.ProcessingMetricsSink; import blue.language.processor.ProcessingSnapshotManager; import blue.language.processor.model.Contract; import blue.language.processor.model.JsonPatch; @@ -58,6 +59,8 @@ public class Blue implements NodeResolver { + private static final int RECENT_PROCESSING_DOCUMENT_SNAPSHOT_LIMIT = 32; + private NodeProvider nodeProvider; private NodeProvider originalNodeProvider; private MergingProcessor mergingProcessor; @@ -67,6 +70,7 @@ public class Blue implements NodeResolver { 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(); @@ -364,6 +368,9 @@ public int resolvedReferenceCacheSize() { public void clearResolvedSnapshotCache() { resolvedSnapshotsByBlueId.clear(); + synchronized (recentProcessingDocumentSnapshots) { + recentProcessingDocumentSnapshots.clear(); + } resolvedReferenceCache.clear(); } @@ -675,6 +682,10 @@ 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); @@ -685,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); } @@ -709,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) { @@ -819,11 +830,12 @@ private ConformanceEngine processorConformanceEngine() { 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); @@ -831,6 +843,80 @@ 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(), 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 13f192d..bfce72b 100644 --- a/src/main/java/blue/language/processor/BatchPatchTransaction.java +++ b/src/main/java/blue/language/processor/BatchPatchTransaction.java @@ -21,6 +21,7 @@ final class BatchPatchTransaction { private final ConformanceEngine conformanceEngine; private final ConformancePlannerOverride conformancePlannerOverride; private final DocumentProcessingRuntime.UpdateMaterializationMetrics materializationMetrics; + private final boolean buildUpdates; BatchPatchTransaction(String originScopePath, List patches, @@ -28,12 +29,24 @@ final class BatchPatchTransaction { 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() { @@ -70,13 +83,26 @@ BatchPatchResult apply() { FrozenNode finalResolved = conformancePlan.root(); boolean includeGeneratedUpdates = conformancePlannerOverride != null && conformancePlannerOverride.applies(); - long buildUpdatesStart = System.nanoTime(); - List updates = buildUpdates(records, + BatchPatchResult.UpdatePlan updatePlan = new BatchPatchResult.UpdatePlan(records, preConformanceResolved, finalResolved, conformancePlan.changedPaths(), includeGeneratedUpdates); - long buildUpdatesNanos = System.nanoTime() - buildUpdatesStart; + 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, diff --git a/src/main/java/blue/language/processor/ChannelRunner.java b/src/main/java/blue/language/processor/ChannelRunner.java index 0108ed2..eedde8b 100644 --- a/src/main/java/blue/language/processor/ChannelRunner.java +++ b/src/main/java/blue/language/processor/ChannelRunner.java @@ -1,6 +1,5 @@ package blue.language.processor; -import blue.language.Blue; import blue.language.model.Node; import blue.language.processor.model.ChannelContract; @@ -62,14 +61,20 @@ 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.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()); - eventSignature = eventSignature(checkpointEvent); + 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, @@ -78,18 +83,32 @@ void runExternalChannel(String scopePath, execution.fatalReason(ex, "Checkpoint error")); return; } - if (checkpointManager.isDuplicate(checkpoint, eventSignature)) { + 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; } - 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 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; } @@ -107,6 +126,7 @@ void runExternalChannel(String scopePath, execution.fatalCategory(ex, ProcessorErrorCategory.CheckpointError), execution.fatalReason(ex, "Checkpoint error")); } + metrics.addCheckpointPersistNanos(System.nanoTime() - checkpointPersistStart); metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointPersistStart); } @@ -119,8 +139,12 @@ private void runDeliveries(String scopePath, long checkpointEnsureStart = System.nanoTime(); 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, @@ -138,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); + metrics.addCheckpointFindNanos(System.nanoTime() - findStart); + long identityStart = System.nanoTime(); String eventSignature = eventSignature(checkpointEvent, fallbackSignature); - if (checkpointManager.isDuplicate(checkpoint, eventSignature)) { - metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointStart); - continue; - } + 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) { @@ -180,6 +222,7 @@ private void runDeliveries(String scopePath, execution.fatalReason(ex, "Checkpoint error")); return; } + metrics.addCheckpointPersistNanos(System.nanoTime() - checkpointPersistStart); metrics.addCheckpointUpdateNanos(System.nanoTime() - checkpointPersistStart); } } @@ -189,11 +232,7 @@ private String eventSignature(Node fallbackEvent) { } private String eventSignature(Node fallbackEvent, String fallbackSignature) { - return eventSignature(fallbackEvent, fallbackSignature, owner.matchingService().blue()); - } - - private static String eventSignature(Node fallbackEvent, String fallbackSignature, Blue blue) { - return fallbackSignature != null ? fallbackSignature : CheckpointIdentityCalculator.identity(fallbackEvent, blue); + return fallbackSignature != null ? fallbackSignature : checkpointManager.eventIdentity(fallbackEvent); } void runHandlers(String scopePath, 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 index b3ce903..e49de45 100644 --- a/src/main/java/blue/language/processor/CheckpointIdentityCalculator.java +++ b/src/main/java/blue/language/processor/CheckpointIdentityCalculator.java @@ -14,21 +14,39 @@ static String identity(Node event) { } 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 { - return BlueIdCalculator.calculateBlueId(event); + 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 { - return blue.calculateSemanticBlueId(event.clone()); + String identity = blue.calculateSemanticBlueId(event.clone()); + sink.addCheckpointContentBlueIdNanos(System.nanoTime() - contentStart); + return identity; } catch (RuntimeException semanticFailure) { - return ProcessorEngine.canonicalSignature(event.clone()); + 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 4ae6589..f65a973 100644 --- a/src/main/java/blue/language/processor/CheckpointManager.java +++ b/src/main/java/blue/language/processor/CheckpointManager.java @@ -20,19 +20,23 @@ final class CheckpointManager { private final DocumentProcessingRuntime runtime; - private final Blue blue; + private final CheckpointIdentityCache identityCache; CheckpointManager(DocumentProcessingRuntime runtime) { - this(runtime, (Blue) null); + 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.blue = blue; + this.identityCache = new CheckpointIdentityCache(blue, metrics); } CheckpointManager(DocumentProcessingRuntime runtime, Function ignoredSignatureFn) { - this(runtime, (Blue) null); + this(runtime, (Blue) null, ProcessingMetricsSink.NOOP); } void ensureCheckpointMarker(String scopePath, ContractBundle bundle) { @@ -58,7 +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); - record.lastEventSignature = eventIdentity(stored); return record; } } @@ -66,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, @@ -85,10 +96,11 @@ void persist(String scopePath, record.checkpoint.updateEvent(record.channelKey, stored); record.lastEventNode = stored != null ? stored.clone() : null; record.lastEventSignature = eventSignature; + identityCache.updateStoredIdentity(record.checkpoint, record.channelKey, eventSignature); } - private String eventIdentity(Node event) { - return CheckpointIdentityCalculator.identity(event, blue); + String eventIdentity(Node event) { + return identityCache.identity(event); } static final class CheckpointRecord { diff --git a/src/main/java/blue/language/processor/ContractEffectBuffer.java b/src/main/java/blue/language/processor/ContractEffectBuffer.java index 92f302d..89929f0 100644 --- a/src/main/java/blue/language/processor/ContractEffectBuffer.java +++ b/src/main/java/blue/language/processor/ContractEffectBuffer.java @@ -12,6 +12,7 @@ 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; @@ -33,23 +34,39 @@ String invalidGasReason() { void addPatch(JsonPatch patch) { if (patch != null) { - patches.add(copyPatch(patch)); + 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 batch = new ArrayList<>(input.size()); for (JsonPatch patch : input) { - addPatch(patch); + JsonPatch copied = copyPatch(patch); + patches.add(copied); + batch.add(copied); } + patchBatches.add(new PatchBatch(batch, preview)); } List patches() { return Collections.unmodifiableList(patches); } + List patchBatches() { + return Collections.unmodifiableList(patchBatches); + } + void emit(Node event) { emittedEvents.add(event != null ? event.clone() : null); } @@ -98,4 +115,22 @@ String reason() { return reason; } } + + static final class PatchBatch { + private final List patches; + private final WorkingDocument.Preview preview; + + private PatchBatch(List patches, WorkingDocument.Preview preview) { + this.patches = Collections.unmodifiableList(new ArrayList<>(patches)); + this.preview = preview; + } + + List patches() { + return patches; + } + + WorkingDocument.Preview preview() { + return preview; + } + } } diff --git a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java index de438a1..dbf6e73 100644 --- a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java +++ b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java @@ -7,6 +7,10 @@ import blue.language.processor.util.ProcessorPointerConstants; import blue.language.snapshot.FrozenNode; import blue.language.snapshot.ResolvedSnapshot; +import blue.language.utils.JsonPointer; +import blue.language.utils.MergeReverser; +import blue.language.utils.NodePathEditor; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -24,7 +28,9 @@ public final class DocumentProcessingRuntime { private final ConformancePlannerOverride conformancePlannerOverride; private final ProcessingSnapshotManager snapshotManager; private final ProcessingMetricsSink metrics; + private final boolean lazyMaterializedCommits; private ResolvedSnapshot snapshot; + private boolean materializedViewStale; private boolean runTerminated; private long batchPatchCalls; private long batchPatchEntries; @@ -69,6 +75,7 @@ public DocumentProcessingRuntime(Node document, this.conformancePlannerOverride = conformancePlannerOverride; this.snapshotManager = snapshotManager; this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; + this.lazyMaterializedCommits = false; } public DocumentProcessingRuntime(ResolvedSnapshot snapshot, @@ -98,6 +105,7 @@ public DocumentProcessingRuntime(ResolvedSnapshot snapshot, this.snapshotManager = snapshotManager; this.snapshot = processorSnapshot; this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; + this.lazyMaterializedCommits = true; } private ResolvedSnapshot processorSnapshot(ResolvedSnapshot snapshot) { @@ -109,12 +117,14 @@ private ResolvedSnapshot processorSnapshot(ResolvedSnapshot snapshot) { } public Node document() { + syncMaterializedView(); return materializedView.root(); } void replaceDocument(Node document) { materializedView.replaceWith(Objects.requireNonNull(document, "document")); snapshot = null; + materializedViewStale = false; } public Map scopes() { @@ -231,7 +241,7 @@ public boolean isScopeTerminated(String scopePath) { public ResolvedSnapshot snapshot() { if (snapshot == null && snapshotManager != null) { - snapshot = snapshotManager.fromDocument(materializedView.root()); + snapshot = snapshotFromDocument(materializedView.root()); materializedView.replaceWithSnapshot(snapshot); } return snapshot; @@ -275,13 +285,43 @@ public FrozenNode canonicalFrozenAt(String path) { return node != null ? FrozenNode.fromResolvedNode(node) : null; } + public WorkingDocument workingDocument(String originScopePath) { + String normalizedScope = PointerUtils.normalizeScope(originScopePath); + ResolvedSnapshot current = snapshot; + boolean materializedFallback = false; + if (current == null && snapshotManager != null) { + syncMaterializedView(); + current = snapshotFromDocument(materializedView.copyRoot()); + materializedFallback = true; + } + if (current != null) { + return new WorkingDocument(normalizedScope, + current.frozenCanonicalRoot(), + current.frozenResolvedRoot(), + conformanceEngine, + conformancePlannerOverride, + snapshotManager, + current, + materializedFallback); + } + + Node root = materializedView.copyRoot(); + FrozenNode canonical = FrozenNode.fromUncheckedCanonicalNode(new MergeReverser().reverse(root.clone())); + FrozenNode resolved = FrozenNode.fromResolvedNode(root.clone()); + return new WorkingDocument(normalizedScope, + canonical, + resolved, + conformanceEngine, + conformancePlannerOverride, + snapshotManager, + null, + true); + } + public Node nodeAt(String path) { String normalized = PointerUtils.normalizePointer(path); if (snapshot != null) { - Node resolved = snapshot.resolvedNodeAt(normalized); - if (resolved != null) { - return resolved; - } + return snapshot.resolvedNodeAt(normalized); } return materializedView.nodeAt(normalized); } @@ -322,6 +362,10 @@ public void markScopeTerminatedFromMarker(String scopePath) { } public void directWrite(String path, Node value) { + if (snapshotManager != null && snapshot != null) { + directWriteSnapshot(path, value); + return; + } Node rollback = materializedView.copyRoot(); ResolvedSnapshot snapshotRollback = snapshot; try { @@ -339,10 +383,76 @@ public void directWrite(String path, Node value) { } catch (RuntimeException ex) { materializedView.replaceWith(rollback); snapshot = snapshotRollback; + materializedViewStale = false; throw ex; } } + private void directWriteSnapshot(String path, Node value) { + ResolvedSnapshot snapshotRollback = snapshot; + try { + PlanningContext planning = planningContext(materializedView.root()); + FrozenNode before = planning.canonicalPlanner.read(path); + Node beforeNode = before != null ? before.toNode() : null; + JsonPatch snapshotPatch = directWritePatch(path, beforeNode, value); + if (snapshotPatch == null) { + return; + } + ImmutablePatchPlanner.PatchPlan canonicalPlan = planning.canonicalPlanner.plan("/", snapshotPatch); + ImmutablePatchPlanner.PatchPlan resolvedPlan = planning.resolvedPlanner.plan("/", snapshotPatch); + ResolvedSnapshot next = new ResolvedSnapshot(canonicalPlan.root(), + resolvedPlan.root(), + canonicalPlan.root().blueId()); + snapshot = snapshotManager.cacheSnapshot(next); + commitMaterializedSnapshot(snapshot); + } catch (RuntimeException ex) { + snapshot = snapshotRollback; + throw ex; + } + } + + private void applyMaterializedDirectWrite(String path, Node value) { + if (value == null) { + removeMaterializedPath(path); + } else { + NodePathEditor.put(materializedView.root(), path, value.clone()); + } + } + + private void removeMaterializedPath(String path) { + List segments = JsonPointer.split(path); + if (segments.isEmpty()) { + materializedView.root().replaceWith(new Node()); + return; + } + List parentSegments = new ArrayList<>(segments.subList(0, segments.size() - 1)); + Node parent = NodePathEditor.getOrNull(materializedView.root(), JsonPointer.toPointer(parentSegments)); + if (parent == null) { + return; + } + String leaf = segments.get(segments.size() - 1); + if ("type".equals(leaf)) { + parent.type((Node) null); + } else if ("itemType".equals(leaf)) { + parent.itemType((Node) null); + } else if ("keyType".equals(leaf)) { + parent.keyType((Node) null); + } else if ("valueType".equals(leaf)) { + parent.valueType((Node) null); + } else if ("blue".equals(leaf)) { + parent.blue(null); + } else if ("contracts".equals(leaf)) { + parent.contracts(null); + } else if (JsonPointer.isArrayIndexSegment(leaf) && parent.getItems() != null && !"-".equals(leaf)) { + int index = Integer.parseInt(leaf); + if (index >= 0 && index < parent.getItems().size()) { + parent.getItems().remove(index); + } + } else if (parent.getProperties() != null) { + parent.getProperties().remove(leaf); + } + } + public DocumentUpdateData applyPatch(String originScopePath, JsonPatch patch) { if (patch == null) { return null; @@ -365,19 +475,7 @@ public List applyPatches(String originScopePath, List applyPrecomputedPatch(String originScopePath, + JsonPatch patch, + WorkingDocument.PatchPreview preview) { + if (patch == null) { + return Collections.emptyList(); + } + if (!canApplyPrecomputedPatch(originScopePath, patch, preview)) { + return applyPatches(originScopePath, Collections.singletonList(patch)); + } + ResolvedSnapshot snapshotRollback = snapshot; + batchPatchCalls++; + batchPatchEntries++; + try { + long buildUpdatesStart = System.nanoTime(); + BatchPatchResult result; + try { + result = preview.result().withMaterializationMetrics(updateMaterializationMetrics()); + } finally { + long buildUpdatesNanos = System.nanoTime() - buildUpdatesStart; + batchPatchBuildUpdatesNanos += buildUpdatesNanos; + metrics.addBatchPatchBuildUpdatesNanos(buildUpdatesNanos); + } + long commitStart = System.nanoTime(); + try { + commitBatchPatchResult(result); + } finally { + long commitNanos = System.nanoTime() - commitStart; + batchPatchCommitNanos += commitNanos; + metrics.addBatchPatchCommitNanos(commitNanos); + metrics.addSnapshotCommitNanos(commitNanos); + } + return result.updates(); + } catch (RuntimeException ex) { + snapshot = snapshotRollback; + if (snapshotRollback != null) { + materializedView.replaceWithSnapshot(snapshotRollback); + materializedViewStale = false; } throw ex; } } + private boolean canApplyPrecomputedPatch(String originScopePath, + JsonPatch patch, + WorkingDocument.PatchPreview preview) { + if (preview == null + || !PointerUtils.normalizeScope(originScopePath).equals(preview.originScope()) + || !preview.matches(patch)) { + return false; + } + ResolvedSnapshot current = snapshot(); + return current != null + && current.frozenCanonicalRoot().blueId().equals(preview.baseCanonical().blueId()) + && current.frozenResolvedRoot().blueId().equals(preview.baseResolved().blueId()); + } + + private UpdateMaterializationMetrics updateMaterializationMetrics() { + return new UpdateMaterializationMetrics() { + @Override + public void recordBeforeNodeMaterialization() { + documentUpdateBeforeNodeMaterializations++; + metrics.incrementDocumentUpdateBeforeMaterializations(); + } + + @Override + public void recordAfterNodeMaterialization() { + documentUpdateAfterNodeMaterializations++; + metrics.incrementDocumentUpdateAfterMaterializations(); + } + }; + } + private JsonPatch directWritePatch(String path, Node before, Node value) { if (value == null) { return before == null ? null : JsonPatch.remove(path); @@ -418,12 +589,18 @@ private PlanningContext planningContext(Node rollback) { ImmutablePatchPlanner planner = ImmutablePatchPlanner.forMaterialized(rollback); return new PlanningContext(null, planner, planner); } - ResolvedSnapshot base = snapshot != null ? snapshot : snapshotManager.fromDocument(rollback); + ResolvedSnapshot base = snapshot != null ? snapshot : snapshotFromDocument(rollback); return new PlanningContext(base, ImmutablePatchPlanner.forSnapshot(base), ImmutablePatchPlanner.forFrozen(base.frozenResolvedRoot())); } + static PlanningContext workingPlanningContext(FrozenNode canonicalRoot, FrozenNode resolvedRoot) { + return new PlanningContext(null, + ImmutablePatchPlanner.forFrozen(canonicalRoot), + ImmutablePatchPlanner.forFrozen(resolvedRoot)); + } + private SnapshotPatchPlan prepareSnapshotPatch(ResolvedSnapshot base, JsonPatch patch) { if (snapshotManager == null || base == null) { return null; @@ -438,14 +615,15 @@ private SnapshotPatchPlan prepareSnapshotPatch(ResolvedSnapshot base, JsonPatch private void commitSnapshotPatch(SnapshotPatchPlan plan, FrozenNode fallbackRoot) { if (snapshotManager == null || plan == null) { materializedView.replaceWith(fallbackRoot.toNode()); + materializedViewStale = false; return; } if (plan.next != null) { snapshot = plan.next; - materializedView.replaceWithSnapshot(snapshot); + commitMaterializedSnapshot(snapshot); } else { - snapshot = snapshotManager.fromDocument(fallbackRoot.toNode()); - materializedView.replaceWithSnapshot(snapshot); + snapshot = snapshotFromDocument(fallbackRoot.toNode()); + commitMaterializedSnapshot(snapshot); } } @@ -454,14 +632,41 @@ private void commitBatchPatchResult(BatchPatchResult result) { Node next = result.resolvedRoot().toNode(); materializedView.replaceWith(next); snapshot = null; + materializedViewStale = false; return; } ResolvedSnapshot next = new ResolvedSnapshot(result.canonicalRoot(), result.resolvedRoot(), result.canonicalRoot().blueId()); ResolvedSnapshot cached = snapshotManager.cacheSnapshot(next); - materializedView.replaceWithSnapshot(cached); snapshot = cached; + commitMaterializedSnapshot(cached); + } + + private void commitMaterializedSnapshot(ResolvedSnapshot committed) { + if (lazyMaterializedCommits) { + materializedViewStale = true; + return; + } + materializedView.replaceWithSnapshot(committed); + materializedViewStale = false; + } + + private void syncMaterializedView() { + if (materializedViewStale && snapshot != null) { + materializedView.replaceWithSnapshot(snapshot); + materializedViewStale = false; + } + } + + private ResolvedSnapshot snapshotFromDocument(Node document) { + long start = System.nanoTime(); + try { + return snapshotManager.fromDocument(document); + } finally { + metrics.incrementProcessingSnapshotFromDocumentBuilds(); + metrics.addProcessingSnapshotFromDocumentNanos(System.nanoTime() - start); + } } long batchPatchCallsForTest() { @@ -581,6 +786,24 @@ JsonPatch.Op op() { return op; } + DocumentUpdateData withMaterializationMetrics(UpdateMaterializationMetrics materializationMetrics) { + if (beforeFrozen != null || afterFrozen != null) { + return new DocumentUpdateData(path, + beforeFrozen, + afterFrozen, + op, + originScope, + cascadeScopes, + materializationMetrics); + } + return new DocumentUpdateData(path, + before != null ? before.clone() : null, + after != null ? after.clone() : null, + op, + originScope, + cascadeScopes); + } + String originScope() { return originScope; } diff --git a/src/main/java/blue/language/processor/DocumentProcessor.java b/src/main/java/blue/language/processor/DocumentProcessor.java index ea9f6bf..8c31838 100644 --- a/src/main/java/blue/language/processor/DocumentProcessor.java +++ b/src/main/java/blue/language/processor/DocumentProcessor.java @@ -195,6 +195,10 @@ public ProcessingMetricsSink processingMetricsSink() { return metricsSink(); } + public boolean supportsSnapshotProcessing() { + return snapshotManager != null; + } + public DocumentProcessor processingMetricsSink(ProcessingMetricsSink metricsSink) { this.metricsSink = metricsSink != null ? metricsSink : ProcessingMetricsSink.NOOP; return this; diff --git a/src/main/java/blue/language/processor/ProcessingMetricsSink.java b/src/main/java/blue/language/processor/ProcessingMetricsSink.java index 445f3bc..ac9c895 100644 --- a/src/main/java/blue/language/processor/ProcessingMetricsSink.java +++ b/src/main/java/blue/language/processor/ProcessingMetricsSink.java @@ -25,6 +25,21 @@ default void addResultSnapshotAttachNanos(long nanos) { default void addBlueIdCalculationNanos(long nanos) { } + default void addProcessingSnapshotCacheLookupNanos(long nanos) { + } + + default void incrementProcessingSnapshotCacheHits() { + } + + default void incrementProcessingSnapshotCacheMisses() { + } + + default void addProcessingSnapshotFromDocumentNanos(long nanos) { + } + + default void incrementProcessingSnapshotFromDocumentBuilds() { + } + default void addBundleLoadNanos(long nanos) { } @@ -49,6 +64,24 @@ default void incrementBundlesBuilt() { default void incrementBundlesReused() { } + default void incrementBundleScopeLoadAttempts() { + } + + default void incrementBundleScopeExecutionCacheHits() { + } + + default void incrementBundleScopeRefreshes() { + } + + default void addBundleScopeTerminationCheckNanos(long nanos) { + } + + default void addBundleScopeResolvedLookupNanos(long nanos) { + } + + default void addBundleScopeContractLoadNanos(long nanos) { + } + default void addChannelDiscoveryNanos(long nanos) { } @@ -82,6 +115,45 @@ default void incrementTriggeredEventsRouted() { default void addCheckpointUpdateNanos(long nanos) { } + default void addCheckpointEnsureNanos(long nanos) { + } + + default void addCheckpointFindNanos(long nanos) { + } + + default void addCheckpointCurrentIdentityNanos(long nanos) { + } + + default void addCheckpointIsNewerNanos(long nanos) { + } + + default void addCheckpointDuplicateNanos(long nanos) { + } + + default void addCheckpointPersistNanos(long nanos) { + } + + default void incrementCheckpointIdentityCacheHits() { + } + + default void incrementCheckpointIdentityCacheMisses() { + } + + default void incrementCheckpointStoredIdentityCacheHits() { + } + + default void incrementCheckpointStoredIdentityCacheMisses() { + } + + default void addCheckpointDirectBlueIdNanos(long nanos) { + } + + default void addCheckpointContentBlueIdNanos(long nanos) { + } + + default void addCheckpointFallbackNanos(long nanos) { + } + default void addSnapshotCommitNanos(long nanos) { } diff --git a/src/main/java/blue/language/processor/ProcessorEngine.java b/src/main/java/blue/language/processor/ProcessorEngine.java index fe69780..8b74ff5 100644 --- a/src/main/java/blue/language/processor/ProcessorEngine.java +++ b/src/main/java/blue/language/processor/ProcessorEngine.java @@ -465,7 +465,7 @@ static final class Execution { owner.conformancePlannerOverride(), owner.snapshotManager(), owner.metricsSink()); - this.checkpointManager = new CheckpointManager(runtime, owner.matchingService().blue()); + this.checkpointManager = new CheckpointManager(runtime, owner.matchingService().blue(), owner.metricsSink()); this.terminationService = new TerminationService(runtime); this.channelRunner = new ChannelRunner(owner, this, runtime, checkpointManager); this.scopeExecutor = new ScopeExecutor(owner, this, runtime, bundles, channelRunner); @@ -478,7 +478,7 @@ static final class Execution { owner.conformancePlannerOverride(), owner.snapshotManager(), owner.metricsSink()); - this.checkpointManager = new CheckpointManager(runtime, owner.matchingService().blue()); + this.checkpointManager = new CheckpointManager(runtime, owner.matchingService().blue(), owner.metricsSink()); this.terminationService = new TerminationService(runtime); this.channelRunner = new ChannelRunner(owner, this, runtime, checkpointManager); this.scopeExecutor = new ScopeExecutor(owner, this, runtime, bundles, channelRunner); @@ -548,6 +548,14 @@ void handlePatches(String scopePath, scopeExecutor.handlePatches(scopePath, bundle, patches, allowReservedMutation); } + void handlePatches(String scopePath, + ContractBundle bundle, + List patches, + boolean allowReservedMutation, + WorkingDocument.Preview preview) { + scopeExecutor.handlePatches(scopePath, bundle, patches, allowReservedMutation, preview); + } + ProcessorExecutionContext createContext(String scopePath, ContractBundle bundle, Node event) { diff --git a/src/main/java/blue/language/processor/ProcessorExecutionContext.java b/src/main/java/blue/language/processor/ProcessorExecutionContext.java index ce0c3a6..af2d91f 100644 --- a/src/main/java/blue/language/processor/ProcessorExecutionContext.java +++ b/src/main/java/blue/language/processor/ProcessorExecutionContext.java @@ -80,6 +80,16 @@ public void applyPatches(List patches) { effects.addPatches(patches); } + public void applyPreviewedPatches(List patches, WorkingDocument.Preview preview) { + if (!allowTerminatedWork && execution.isScopeInactive(scopePath)) { + return; + } + if (patches == null || patches.isEmpty()) { + return; + } + effects.addPreviewedPatches(patches, preview); + } + public void emitEvent(Node emission) { if (!allowTerminatedWork && execution.isScopeInactive(scopePath)) { return; @@ -106,8 +116,12 @@ void applyBufferedEffects() { if (effects.gas() > 0L) { runtime().addGas(effects.gas()); } - if (!effects.patches().isEmpty()) { - execution.handlePatches(scopePath, bundle, effects.patches(), allowReservedMutation); + for (ContractEffectBuffer.PatchBatch patchBatch : effects.patchBatches()) { + execution.handlePatches(scopePath, + bundle, + patchBatch.patches(), + allowReservedMutation, + patchBatch.preview()); if (!allowTerminatedWork && execution.isScopeInactive(scopePath)) { return; } @@ -176,6 +190,14 @@ public FrozenNode resolvedFrozenAt(String absolutePointer) { return runtime().resolvedFrozenAt(absolutePointer); } + public WorkingDocument newWorkingDocument() { + return runtime().workingDocument(scopePath); + } + + public WorkingDocument newWorkingDocument(String originScope) { + return runtime().workingDocument(originScope); + } + public boolean documentContains(String absolutePointer) { if (absolutePointer == null || absolutePointer.isEmpty()) { return false; diff --git a/src/main/java/blue/language/processor/ScopeExecutor.java b/src/main/java/blue/language/processor/ScopeExecutor.java index c928da1..8071095 100644 --- a/src/main/java/blue/language/processor/ScopeExecutor.java +++ b/src/main/java/blue/language/processor/ScopeExecutor.java @@ -83,7 +83,14 @@ private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean } while (true) { - FrozenNode scopeNode = runtime.resolvedFrozenAt(normalizedScope); + ProcessingMetricsSink metrics = owner.metricsSink(); + long resolvedStart = System.nanoTime(); + FrozenNode scopeNode; + try { + scopeNode = runtime.resolvedFrozenAt(normalizedScope); + } finally { + metrics.addBundleScopeResolvedLookupNanos(System.nanoTime() - resolvedStart); + } if (scopeNode == null) { return; } @@ -93,7 +100,12 @@ private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean preInitSnapshot = (canonicalScopeNode != null ? canonicalScopeNode : scopeNode).toNode(); } - bundle = owner.contractLoader().load(scopeNode, normalizedScope, owner.metricsSink()); + long loadStart = System.nanoTime(); + try { + bundle = owner.contractLoader().load(scopeNode, normalizedScope, metrics); + } finally { + metrics.addBundleScopeContractLoadNanos(System.nanoTime() - loadStart); + } bundles.put(normalizedScope, bundle); String childScope; @@ -157,20 +169,31 @@ private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean void loadBundles(String scopePath) { String normalizedScope = ProcessorEngine.normalizeScope(scopePath); + ProcessingMetricsSink metrics = owner.metricsSink(); + metrics.incrementBundleScopeLoadAttempts(); if (bundles.containsKey(normalizedScope)) { + metrics.incrementBundleScopeExecutionCacheHits(); return; } try { + long terminationStart = System.nanoTime(); if (runtime.hasTerminationMarker(normalizedScope)) { bundles.put(normalizedScope, ContractBundle.empty()); return; } + metrics.addBundleScopeTerminationCheckNanos(System.nanoTime() - terminationStart); } catch (IllegalStateException ex) { throw new MustUnderstandFailureException(ex.getMessage()); } - FrozenNode scopeNode = runtime.resolvedFrozenAt(normalizedScope); + long resolvedStart = System.nanoTime(); + FrozenNode scopeNode; + try { + scopeNode = runtime.resolvedFrozenAt(normalizedScope); + } finally { + metrics.addBundleScopeResolvedLookupNanos(System.nanoTime() - resolvedStart); + } ContractBundle bundle = scopeNode != null - ? owner.contractLoader().load(scopeNode, normalizedScope, owner.metricsSink()) + ? loadBundle(scopeNode, normalizedScope, metrics) : ContractBundle.empty(); bundles.put(normalizedScope, bundle); for (String embeddedPointer : bundle.embeddedPaths()) { @@ -257,13 +280,22 @@ void handlePatches(String scopePath, ContractBundle bundle, List patches, boolean allowReservedMutation) { + handlePatches(scopePath, bundle, patches, allowReservedMutation, null); + } + + void handlePatches(String scopePath, + ContractBundle bundle, + List patches, + boolean allowReservedMutation, + WorkingDocument.Preview preview) { if (execution.isScopeInactive(scopePath)) { return; } if (patches == null || patches.isEmpty()) { return; } - for (JsonPatch patch : patches) { + for (int patchIndex = 0; patchIndex < patches.size(); patchIndex++) { + JsonPatch patch = patches.get(patchIndex); if (execution.isScopeInactive(scopePath)) { return; } @@ -298,8 +330,9 @@ void handlePatches(String scopePath, long gasStart = System.nanoTime(); chargePatchGas(patch); owner.metricsSink().addPatchGasNanos(System.nanoTime() - gasStart); - List updates = runtime.applyPatches(scopePath, - Collections.singletonList(patch)); + List updates = runtime.applyPrecomputedPatch(scopePath, + patch, + preview != null ? preview.patch(patchIndex) : null); long routingStart = System.nanoTime(); for (DocumentProcessingRuntime.DocumentUpdateData update : updates) { routeDocumentUpdateAfterPatch(scopePath, bundle, update); @@ -487,16 +520,33 @@ private ContractBundle processEmbeddedChildren(String scopePath, Node event) { private ContractBundle refreshBundle(String scopePath) { String normalizedScope = ProcessorEngine.normalizeScope(scopePath); - FrozenNode scopeNode = runtime.resolvedFrozenAt(normalizedScope); + ProcessingMetricsSink metrics = owner.metricsSink(); + metrics.incrementBundleScopeRefreshes(); + long resolvedStart = System.nanoTime(); + FrozenNode scopeNode; + try { + scopeNode = runtime.resolvedFrozenAt(normalizedScope); + } finally { + metrics.addBundleScopeResolvedLookupNanos(System.nanoTime() - resolvedStart); + } if (scopeNode == null) { bundles.remove(normalizedScope); return null; } - ContractBundle refreshed = owner.contractLoader().load(scopeNode, normalizedScope, owner.metricsSink()); + ContractBundle refreshed = loadBundle(scopeNode, normalizedScope, metrics); bundles.put(normalizedScope, refreshed); return refreshed; } + private ContractBundle loadBundle(FrozenNode scopeNode, String normalizedScope, ProcessingMetricsSink metrics) { + long loadStart = System.nanoTime(); + try { + return owner.contractLoader().load(scopeNode, normalizedScope, metrics); + } finally { + metrics.addBundleScopeContractLoadNanos(System.nanoTime() - loadStart); + } + } + private String nextEmbeddedChildScope(String scopePath, ContractBundle bundle, Set processed) { if (bundle == null) { return null; diff --git a/src/main/java/blue/language/processor/WorkingDocument.java b/src/main/java/blue/language/processor/WorkingDocument.java new file mode 100644 index 0000000..f617f52 --- /dev/null +++ b/src/main/java/blue/language/processor/WorkingDocument.java @@ -0,0 +1,269 @@ +package blue.language.processor; + +import blue.language.conformance.ConformanceEngine; +import blue.language.model.Node; +import blue.language.processor.model.JsonPatch; +import blue.language.processor.util.PointerUtils; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Frozen preview state for processor-side read-your-writes workflows. + * + *

A working document applies the same immutable patch transaction used by + * {@link DocumentProcessingRuntime}, including conformance planning, dynamic + * type generalization, and Type Generalization Policy enforcement. It never + * commits to the processor runtime, emits cascades, charges gas, or writes + * processor-managed markers.

+ */ +public final class WorkingDocument { + + private static final DocumentProcessingRuntime.UpdateMaterializationMetrics NOOP_MATERIALIZATION_METRICS = + new DocumentProcessingRuntime.UpdateMaterializationMetrics() { + @Override + public void recordBeforeNodeMaterialization() { + // Working previews keep update metadata frozen and do not expose document-update materialization. + } + + @Override + public void recordAfterNodeMaterialization() { + // Working previews keep update metadata frozen and do not expose document-update materialization. + } + }; + + private final String originScope; + private FrozenNode canonicalRoot; + private FrozenNode resolvedRoot; + private final ConformanceEngine conformanceEngine; + private final ConformancePlannerOverride conformancePlannerOverride; + private final ProcessingSnapshotManager snapshotManager; + private final boolean materializedFallback; + private ResolvedSnapshot snapshot; + + WorkingDocument(String originScope, + FrozenNode canonicalRoot, + FrozenNode resolvedRoot, + ConformanceEngine conformanceEngine, + ConformancePlannerOverride conformancePlannerOverride, + ProcessingSnapshotManager snapshotManager, + ResolvedSnapshot snapshot, + boolean materializedFallback) { + this.originScope = PointerUtils.normalizeScope(originScope); + this.canonicalRoot = Objects.requireNonNull(canonicalRoot, "canonicalRoot"); + this.resolvedRoot = Objects.requireNonNull(resolvedRoot, "resolvedRoot"); + this.conformanceEngine = conformanceEngine; + this.conformancePlannerOverride = conformancePlannerOverride; + this.snapshotManager = snapshotManager; + this.snapshot = snapshot; + this.materializedFallback = materializedFallback; + } + + public FrozenNode canonicalRoot() { + return canonicalRoot; + } + + public FrozenNode resolvedRoot() { + return resolvedRoot; + } + + public FrozenNode canonicalAt(String absolutePointer) { + return ImmutablePatchPlanner.forFrozen(canonicalRoot) + .read(PointerUtils.normalizePointer(absolutePointer)); + } + + public FrozenNode resolvedAt(String absolutePointer) { + return ImmutablePatchPlanner.forFrozen(resolvedRoot) + .read(PointerUtils.normalizePointer(absolutePointer)); + } + + public WorkingDocument applyPatch(JsonPatch patch) { + if (patch == null) { + return this; + } + return applyPatches(Collections.singletonList(patch)); + } + + public WorkingDocument applyPatches(List patches) { + previewAndApplyPatches(patches); + return this; + } + + public Preview previewAndApplyPatches(List patches) { + if (patches == null || patches.isEmpty()) { + return Preview.empty(originScope); + } + List copy = copyPatches(patches); + FrozenNode nextCanonical = canonicalRoot; + FrozenNode nextResolved = resolvedRoot; + List previews = new ArrayList<>(copy.size()); + for (JsonPatch patch : copy) { + PatchPreview preview = previewSinglePatch(nextCanonical, nextResolved, patch); + BatchPatchResult result = preview.result(); + previews.add(preview); + nextCanonical = result.canonicalRoot(); + nextResolved = result.resolvedRoot(); + } + canonicalRoot = nextCanonical; + resolvedRoot = nextResolved; + snapshot = null; + return new Preview(originScope, previews); + } + + public ResolvedSnapshot snapshot() { + if (snapshot == null) { + snapshot = new ResolvedSnapshot(canonicalRoot, resolvedRoot, canonicalRoot.blueId()); + } + return snapshot; + } + + public Node materializeCanonicalRoot() { + return canonicalRoot.toNode(); + } + + public Node materializeResolvedRoot() { + return resolvedRoot.toNode(); + } + + public Node commitToNode() { + return materializeCanonicalRoot(); + } + + public ResolvedSnapshot commitSnapshot() { + ResolvedSnapshot current = snapshot(); + snapshot = snapshotManager != null ? snapshotManager.cacheSnapshot(current) : current; + return snapshot; + } + + /** + * Returns true when this preview had to freeze a materialized runtime tree + * because no processor snapshot was available at creation time. + */ + public boolean usedMaterializedFallback() { + return materializedFallback; + } + + private PatchPreview previewSinglePatch(FrozenNode baseCanonical, + FrozenNode baseResolved, + JsonPatch patch) { + DocumentProcessingRuntime.PlanningContext planning = + DocumentProcessingRuntime.workingPlanningContext(baseCanonical, baseResolved); + BatchPatchResult result = new BatchPatchTransaction(originScope, + Collections.singletonList(Objects.requireNonNull(patch, "patch")), + planning, + conformanceEngine, + conformancePlannerOverride, + NOOP_MATERIALIZATION_METRICS, + false).apply(); + return new PatchPreview(originScope, + patch, + baseCanonical, + baseResolved, + result); + } + + private static List copyPatches(List patches) { + List copy = new ArrayList<>(patches.size()); + for (JsonPatch patch : patches) { + copy.add(copyPatch(patch)); + } + return Collections.unmodifiableList(copy); + } + + private static JsonPatch copyPatch(JsonPatch patch) { + Objects.requireNonNull(patch, "patch"); + switch (patch.getOp()) { + case ADD: + return JsonPatch.add(patch.getPath(), patch.getVal().clone()); + case REPLACE: + return JsonPatch.replace(patch.getPath(), patch.getVal().clone()); + case REMOVE: + return JsonPatch.remove(patch.getPath()); + default: + throw new IllegalStateException("Unsupported patch op: " + patch.getOp()); + } + } + + public static final class Preview { + private final String originScope; + private final List patches; + + private Preview(String originScope, List patches) { + this.originScope = PointerUtils.normalizeScope(originScope); + this.patches = Collections.unmodifiableList(new ArrayList<>(patches)); + } + + private static Preview empty(String originScope) { + return new Preview(originScope, Collections.emptyList()); + } + + String originScope() { + return originScope; + } + + int size() { + return patches.size(); + } + + PatchPreview patch(int index) { + return index >= 0 && index < patches.size() ? patches.get(index) : null; + } + } + + static final class PatchPreview { + private final String originScope; + private final JsonPatch patch; + private final FrozenNode baseCanonical; + private final FrozenNode baseResolved; + private final BatchPatchResult result; + private final String valueBlueId; + + private PatchPreview(String originScope, + JsonPatch patch, + FrozenNode baseCanonical, + FrozenNode baseResolved, + BatchPatchResult result) { + this.originScope = PointerUtils.normalizeScope(originScope); + this.patch = patch; + this.baseCanonical = baseCanonical; + this.baseResolved = baseResolved; + this.result = result; + this.valueBlueId = patch.getOp() == JsonPatch.Op.REMOVE + ? null + : FrozenNode.fromResolvedNode(patch.getVal()).blueId(); + } + + String originScope() { + return originScope; + } + + FrozenNode baseCanonical() { + return baseCanonical; + } + + FrozenNode baseResolved() { + return baseResolved; + } + + BatchPatchResult result() { + return result; + } + + boolean matches(JsonPatch candidate) { + if (candidate == null + || patch.getOp() != candidate.getOp() + || !PointerUtils.normalizePointer(patch.getPath()) + .equals(PointerUtils.normalizePointer(candidate.getPath()))) { + return false; + } + if (patch.getOp() == JsonPatch.Op.REMOVE) { + return true; + } + return valueBlueId.equals(FrozenNode.fromResolvedNode(candidate.getVal()).blueId()); + } + } +} diff --git a/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java b/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java index 7c6cf1d..5b71a56 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java @@ -15,9 +15,12 @@ import org.junit.jupiter.api.Test; import java.math.BigInteger; +import java.util.Collections; +import java.util.List; import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -42,6 +45,117 @@ void runtimePatchUsesCanonicalOverlaySnapshotWhenNoGeneralizationIsNeeded() { assertSnapshotConsistent(runtime.snapshot()); } + @Test + void workingDocumentAppliesPatchWithoutMutatingRuntime() { + CountingSnapshotManager manager = new CountingSnapshotManager(); + Node document = YAML_MAPPER.readValue("x: 1\nother: keep", Node.class); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(document, null, manager); + runtime.snapshot(); + + WorkingDocument working = runtime.workingDocument("/"); + working.applyPatch(JsonPatch.replace("/x", new Node().value(2))); + + assertFalse(working.usedMaterializedFallback()); + assertEquals(1, document.getAsInteger("/x")); + assertEquals(1, runtime.snapshot().canonicalRoot().getAsInteger("/x")); + assertEquals(2, working.materializeCanonicalRoot().getAsInteger("/x")); + assertEquals("keep", working.canonicalAt("/other").getValue()); + assertSnapshotConsistent(working.snapshot()); + } + + @Test + void precomputedWorkingDocumentPreviewCommitsWithoutReplanning() { + CountingSnapshotManager manager = new CountingSnapshotManager(); + Node document = YAML_MAPPER.readValue("x: 1\nother: keep", Node.class); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(document, null, manager); + runtime.snapshot(); + JsonPatch patch = JsonPatch.replace("/x", new Node().value(2)); + + WorkingDocument.Preview preview = runtime.workingDocument("/") + .previewAndApplyPatches(Collections.singletonList(patch)); + List updates = + runtime.applyPrecomputedPatch("/", patch, preview.patch(0)); + + assertEquals(2, document.getAsInteger("/x")); + assertEquals(1, updates.size()); + assertEquals("/x", updates.get(0).path()); + assertEquals(1, manager.fromDocumentCalls); + assertEquals(1, manager.cacheSnapshotCalls); + assertEquals(1, runtime.batchPatchCallsForTest()); + assertEquals(1, runtime.batchPatchEntriesForTest()); + assertEquals(0, runtime.batchPatchPlanningNanosForTest()); + assertEquals(0, runtime.batchPatchConformanceNanosForTest()); + assertTrue(runtime.batchPatchBuildUpdatesNanosForTest() > 0); + assertTrue(runtime.batchPatchCommitNanosForTest() > 0); + assertSnapshotConsistent(runtime.snapshot()); + } + + @Test + void workingDocumentRecordsMaterializedFallbackWhenRuntimeHasNoSnapshotManager() { + Node document = YAML_MAPPER.readValue("x: 1", Node.class); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(document, null); + + WorkingDocument working = runtime.workingDocument("/"); + working.applyPatch(JsonPatch.replace("/x", new Node().value(2))); + + assertTrue(working.usedMaterializedFallback()); + assertEquals(1, document.getAsInteger("/x")); + assertEquals(2, working.commitToNode().getAsInteger("/x")); + assertSnapshotConsistent(working.commitSnapshot()); + } + + @Test + void workingDocumentPreviewFailureDoesNotMutateWorkingOrRuntime() { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + nodeProvider.addSingleDocs( + "name: Fixed One\n" + + "x: 1"); + Blue blue = ProcessorTestSupport.blue(nodeProvider); + Node document = blue.resolve(YAML_MAPPER.readValue( + "name: Instance\n" + + "type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Fixed One") + "\n" + + "x: 1", Node.class)); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(document, blue.conformanceEngine()); + WorkingDocument working = runtime.workingDocument("/"); + + assertThrows(RuntimeException.class, + () -> working.applyPatch(JsonPatch.replace("/x", new Node().value(2)))); + + assertEquals(1, document.getAsInteger("/x")); + assertEquals(1, working.materializeResolvedRoot().getAsInteger("/x")); + assertEquals(nodeProvider.getBlueIdByName("Fixed One"), + working.materializeResolvedRoot().getType().getBlueId()); + } + + @Test + void workingDocumentRunsGeneralizationPolicyOnFrozenPreviewState() { + BasicNodeProvider nodeProvider = ConformanceEngineTest.priceProvider(); + Blue blue = ProcessorTestSupport.blue(nodeProvider); + Node document = blue.resolve(YAML_MAPPER.readValue( + "price:\n" + + " type:\n" + + " blueId: " + nodeProvider.getBlueIdByName("Price in EUR") + "\n" + + " amount: 150\n" + + " currency: EUR\n", Node.class)); + document.contracts(new Node().properties("generalization", + new Node() + .type(new Node().blueId("Fbenow6tanFHkWzKiDD8fGxminQswQ1FecMRakaCx2WX")) + .properties("rules", new Node().items(java.util.Collections.singletonList( + new Node().properties("path", new Node().value("/price"), + "mode", new Node().value("nearest-valid"), + "mustRemainSubtypeOf", new Node().blueId(nodeProvider.getBlueIdByName("Price")))))))); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(document, blue.conformanceEngine()); + + WorkingDocument working = runtime.workingDocument("/") + .applyPatch(JsonPatch.replace("/price/currency", new Node().value("USD"))); + + assertEquals("EUR", document.getAsText("/price/currency")); + assertEquals("USD", working.resolvedAt("/price/currency").getValue()); + assertEquals(nodeProvider.getBlueIdByName("Price"), + working.resolvedAt("/price").getType().getReferenceBlueId()); + } + @Test void runtimeReadsUseResolvedSnapshotIndexWhenSnapshotIsAvailable() { Node canonical = YAML_MAPPER.readValue("local: yes", Node.class); @@ -233,7 +347,8 @@ void directWriteKeepsCanonicalSnapshotInTheSameRuntimeTransaction() { assertMissing(document, "/checkpoint/lastEvent"); assertEquals(1, manager.fromDocumentCalls); - assertEquals(2, manager.applyPatchCalls); + assertEquals(1, manager.applyPatchCalls); + assertEquals(1, manager.cacheSnapshotCalls); assertMissing(runtime.snapshot().canonicalRoot(), "/checkpoint/lastEvent"); assertSnapshotConsistent(runtime.snapshot()); } @@ -349,7 +464,7 @@ void processorResultCarriesRuntimeSnapshotWithoutBluePostProcessing() { assertEquals(7, processed.canonicalDocument().getAsInteger("/x")); assertEquals("evt-runtime-snapshot", processed.canonicalDocument().getAsText("/contracts/checkpoint/lastEvents/testChannel/eventId")); - assertTrue(manager.applyPatchCalls >= 2); + assertTrue(manager.cacheSnapshotCalls >= 2); assertSnapshotConsistent(processed.snapshot()); } @@ -383,7 +498,7 @@ void snapshotNativeProcessingDoesNotBuildInitialSnapshotFromDocument() { new TestEvent().eventId("evt-snapshot-native").toNode()); assertEquals(0, manager.fromDocumentCalls); - assertTrue(manager.applyPatchCalls > 0); + assertTrue(manager.cacheSnapshotCalls > 0); assertEquals(9, result.snapshot().canonicalRoot().getAsInteger("/x")); assertSnapshotConsistent(result.snapshot()); } From a494bbf4bf13ded741093a84c20b526e868c5a3d Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Fri, 29 May 2026 02:47:12 +0200 Subject: [PATCH 5/6] feat: extend `NodeProviderWrapper` with runtime type support and refine default Blue mappings Enhanced `NodeProviderWrapper` to facilitate runtime type resolution by integrating `BlueRuntimeTypeRegistry`. Added default Blue runtime type mappings for improved alias handling. Updated tests and transformations to align with expanded mapping capabilities. --- .../language/preprocess/Preprocessor.java | 20 ++--- .../language/utils/NodeProviderWrapper.java | 31 +++++++- .../java/blue/language/utils/Properties.java | 75 +++++++++++++++++++ .../resources/transformation/DefaultBlue.blue | 24 +++++- .../InferBasicTypesForUntypedValues.blue | 2 +- .../ReplaceInlineTypesWithBlueIds.blue | 6 +- .../transformation/Transformation.blue | 2 +- .../java/blue/language/PreprocessorTest.java | 23 +++++- .../registry/BlueRuntimeTypeRegistryTest.java | 17 +++++ .../BootstrapProviderVerificationTest.java | 28 +++++++ 10 files changed, 211 insertions(+), 17 deletions(-) diff --git a/src/main/java/blue/language/preprocess/Preprocessor.java b/src/main/java/blue/language/preprocess/Preprocessor.java index 4084abf..219ee72 100644 --- a/src/main/java/blue/language/preprocess/Preprocessor.java +++ b/src/main/java/blue/language/preprocess/Preprocessor.java @@ -20,7 +20,7 @@ import java.util.Map; import java.util.Optional; -import static blue.language.utils.Properties.CORE_TYPE_NAME_TO_BLUE_ID_MAP; +import static blue.language.utils.Properties.DEFAULT_BLUE_TYPE_NAME_TO_BLUE_ID_MAP; import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; @@ -74,7 +74,7 @@ public Node preprocess(Node document, Node defaultBlue) { } private Node applyStandardBaseline(Node document) { - Node transformed = new ReplaceInlineValuesForTypeAttributesWithImports(CORE_TYPE_NAME_TO_BLUE_ID_MAP) + Node transformed = new ReplaceInlineValuesForTypeAttributesWithImports(DEFAULT_BLUE_TYPE_NAME_TO_BLUE_ID_MAP) .process(document); return new InferBasicTypesForUntypedValues().process(transformed); } @@ -120,9 +120,9 @@ private Node applyPortableImports(Node document) { throw new IllegalArgumentException("\"blue.imports." + alias + "\" must be a pure reference."); } String blueId = BlueIds.requirePlainBlueId(reference.getBlueId(), "blue.imports." + alias); - String coreBlueId = CORE_TYPE_NAME_TO_BLUE_ID_MAP.get(alias); - if (coreBlueId != null && !coreBlueId.equals(blueId)) { - throw new IllegalArgumentException("\"blue.imports\" cannot redefine core alias \"" + 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); } @@ -142,15 +142,17 @@ private Node applyPortableImports(Node document) { public static TransformationProcessorProvider getStandardProvider() { return new TransformationProcessorProvider() { - private static final String REPLACE_INLINE_TYPES = "53yFLQ3dpuGwa2svHubDyzyhYz9RQNmctiJRdi3gRYr7"; - private static final String INFER_BASIC_TYPES = "49hrWpkoXavNmK8PpZag11zB2vYwzhQZahwioz6vDk2i"; + 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/utils/NodeProviderWrapper.java b/src/main/java/blue/language/utils/NodeProviderWrapper.java index b5eefda..7e09909 100644 --- a/src/main/java/blue/language/utils/NodeProviderWrapper.java +++ b/src/main/java/blue/language/utils/NodeProviderWrapper.java @@ -1,21 +1,25 @@ package blue.language.utils; import blue.language.NodeProvider; +import blue.language.processor.registry.BlueRuntimeTypeRegistry; import blue.language.provider.BootstrapProvider; import blue.language.provider.SequentialNodeProvider; import blue.language.provider.VerifyingNodeProvider; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; public class NodeProviderWrapper { public static NodeProvider wrap(NodeProvider originalProvider) { if (isAlreadyWrapped(originalProvider)) { - return originalProvider; + return withRuntimeProvider(originalProvider); } if (originalProvider instanceof UnverifiedNodeProvider) { return new SequentialNodeProvider( Arrays.asList( BootstrapProvider.INSTANCE, + BlueRuntimeTypeRegistry.getDefault().asProcessorSnapshotProvider(), originalProvider ) ); @@ -23,6 +27,7 @@ public static NodeProvider wrap(NodeProvider originalProvider) { return new SequentialNodeProvider( Arrays.asList( BootstrapProvider.INSTANCE, + BlueRuntimeTypeRegistry.getDefault().asProcessorSnapshotProvider(), new VerifyingNodeProvider(originalProvider) ) ); @@ -42,6 +47,30 @@ private static boolean isAlreadyWrapped(NodeProvider originalProvider) { || provider instanceof UnverifiedNodeProvider); } + private static NodeProvider withRuntimeProvider(NodeProvider originalProvider) { + if (!(originalProvider instanceof SequentialNodeProvider)) { + return originalProvider; + } + NodeProvider runtimeProvider = BlueRuntimeTypeRegistry.getDefault().asProcessorSnapshotProvider(); + List providers = ((SequentialNodeProvider) originalProvider).getNodeProviders(); + if (providers.stream().anyMatch(provider -> provider == runtimeProvider)) { + return originalProvider; + } + List wrapped = new ArrayList<>(providers.size() + 1); + boolean inserted = false; + for (NodeProvider provider : providers) { + wrapped.add(provider); + if (!inserted && provider == BootstrapProvider.INSTANCE) { + wrapped.add(runtimeProvider); + inserted = true; + } + } + if (!inserted) { + wrapped.add(0, runtimeProvider); + } + return new SequentialNodeProvider(wrapped); + } + private static class UnverifiedNodeProvider implements NodeProvider { private final NodeProvider delegate; diff --git a/src/main/java/blue/language/utils/Properties.java b/src/main/java/blue/language/utils/Properties.java index fe2cdf7..4e3cc9d 100644 --- a/src/main/java/blue/language/utils/Properties.java +++ b/src/main/java/blue/language/utils/Properties.java @@ -1,6 +1,8 @@ package blue.language.utils; import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -57,4 +59,77 @@ public class Properties { .boxed() .collect(Collectors.toMap(CORE_TYPE_BLUE_IDS::get, CORE_TYPES::get)); + public static final List BLUE_CONTRACTS_RUNTIME_TYPES = Arrays.asList( + "Contract", + "Json Patch Entry", + "Contract Execution Result", + "Channel", + "Handler", + "Marker", + "Process Embedded", + "Processing Initialized Marker", + "Processing Terminated Marker", + "Channel Event Checkpoint", + "Type Generalization Policy", + "Type Generalization Rule", + "Document Update Channel", + "Triggered Event Channel", + "Lifecycle Event Channel", + "Embedded Node Channel", + "Document Update", + "Document Processing Initiated", + "Document Processing Terminated", + "Document Processing Fatal Error" + ); + + public static final List BLUE_CONTRACTS_RUNTIME_TYPE_BLUE_IDS = Arrays.asList( + "6WrVQoSpKHUUg5HPrwjkVV6pxe4sdkyGnakMs8ayEGeF", + "61W96XosAp3DrEC7PuqLYtmF2A6ETpqH6qF2DgYwDq4c", + "AMtAXPmvumgz1GxKUU9uv3ncXiKMENvqq8AaLvD5LXhv", + "4FAZ94JPExNM4pn2ZhtdHa4CVP7uASmLNVrBy7aCG1p5", + "7X46P3Q6FJrogqKrBXTALpqzkieyyiQeatnqLvWzAPXE", + "6zqbYGDGrMv5ReuEsjyzyyjjuqVnqDZxtY7RsPXdBTNy", + "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q", + "6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q", + "GBDBthfshBFr4GQKUU1fmy4GnPL7q2y3as4deUWpuBtu", + "9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1", + "Fbenow6tanFHkWzKiDD8fGxminQswQ1FecMRakaCx2WX", + "7Vnmk8StjwY7e9mBNpACrn8oh3KZ7yQBjnXe5bLDWn4D", + "Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o", + "5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ", + "2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ", + "H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i", + "7HEaG1SpBdsbVHsrwRTZSZGmpJUWHfFoEzecYWpjo1vm", + "Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL", + "4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK", + "AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC" + ); + + public static final Map BLUE_CONTRACTS_RUNTIME_TYPE_NAME_TO_BLUE_ID_MAP = + IntStream.range(0, BLUE_CONTRACTS_RUNTIME_TYPES.size()) + .boxed() + .collect(Collectors.toMap(BLUE_CONTRACTS_RUNTIME_TYPES::get, BLUE_CONTRACTS_RUNTIME_TYPE_BLUE_IDS::get)); + + public static final Map BLUE_CONTRACTS_RUNTIME_TYPE_BLUE_ID_TO_NAME_MAP = + IntStream.range(0, BLUE_CONTRACTS_RUNTIME_TYPES.size()) + .boxed() + .collect(Collectors.toMap(BLUE_CONTRACTS_RUNTIME_TYPE_BLUE_IDS::get, BLUE_CONTRACTS_RUNTIME_TYPES::get)); + + public static final Map DEFAULT_BLUE_TYPE_NAME_TO_BLUE_ID_MAP = buildDefaultBlueTypeNameToBlueIdMap(); + public static final Map DEFAULT_BLUE_TYPE_BLUE_ID_TO_NAME_MAP = buildDefaultBlueTypeBlueIdToNameMap(); + + private static Map buildDefaultBlueTypeNameToBlueIdMap() { + Map result = new LinkedHashMap<>(); + result.putAll(CORE_TYPE_NAME_TO_BLUE_ID_MAP); + result.putAll(BLUE_CONTRACTS_RUNTIME_TYPE_NAME_TO_BLUE_ID_MAP); + return Collections.unmodifiableMap(result); + } + + private static Map buildDefaultBlueTypeBlueIdToNameMap() { + Map result = new LinkedHashMap<>(); + result.putAll(CORE_TYPE_BLUE_ID_TO_NAME_MAP); + result.putAll(BLUE_CONTRACTS_RUNTIME_TYPE_BLUE_ID_TO_NAME_MAP); + return Collections.unmodifiableMap(result); + } + } diff --git a/src/main/resources/transformation/DefaultBlue.blue b/src/main/resources/transformation/DefaultBlue.blue index 0f6a958..9dbebd4 100644 --- a/src/main/resources/transformation/DefaultBlue.blue +++ b/src/main/resources/transformation/DefaultBlue.blue @@ -1,5 +1,5 @@ - type: - blueId: 53yFLQ3dpuGwa2svHubDyzyhYz9RQNmctiJRdi3gRYr7 + blueId: 27B7fuxQCS1VAptiCPc2RMkKoutP5qxkh3uDxZ7dr6Eo mappings: Text: GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC Double: 9eWaHYz2vKrFofdHTHAizNNu8xP6QE3WQ5y7DGrGZvyJ @@ -7,5 +7,25 @@ Boolean: AwvXD961fmnmqcSQhjMA7r15HpVh39cefb6ZTyUz2Fm2 List: 8DSFoWG9MqRSUhStqoPLrwVQiYByRh18NWbDEarN8MKF Dictionary: Efkz9D1ARMM7rU43w3rDNVqat1naS6qXKCqP4eHin3yG + Contract: 6WrVQoSpKHUUg5HPrwjkVV6pxe4sdkyGnakMs8ayEGeF + Json Patch Entry: 61W96XosAp3DrEC7PuqLYtmF2A6ETpqH6qF2DgYwDq4c + Contract Execution Result: AMtAXPmvumgz1GxKUU9uv3ncXiKMENvqq8AaLvD5LXhv + Channel: 4FAZ94JPExNM4pn2ZhtdHa4CVP7uASmLNVrBy7aCG1p5 + Handler: 7X46P3Q6FJrogqKrBXTALpqzkieyyiQeatnqLvWzAPXE + Marker: 6zqbYGDGrMv5ReuEsjyzyyjjuqVnqDZxtY7RsPXdBTNy + Process Embedded: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q + Processing Initialized Marker: 6JjyUKoK7uJxA5NY9YhMaKJbXC6c9iHyx1khv4gaAq4Q + Processing Terminated Marker: GBDBthfshBFr4GQKUU1fmy4GnPL7q2y3as4deUWpuBtu + Channel Event Checkpoint: 9GEC24YbFG9hj4banjYh2oEnDpAob1wAPmhjuykJp8T1 + Type Generalization Policy: Fbenow6tanFHkWzKiDD8fGxminQswQ1FecMRakaCx2WX + Type Generalization Rule: 7Vnmk8StjwY7e9mBNpACrn8oh3KZ7yQBjnXe5bLDWn4D + Document Update Channel: Ac9LC5T7pHVa1TtkhMBjBRtxecShzvbe7ugUdXT1Mu2o + Triggered Event Channel: 5HwxfbwRBCxG8xYpowWkCPC9akqUSKV7So2M4QHEmLsZ + Lifecycle Event Channel: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ + Embedded Node Channel: H6iUJp3GcLypsJDimMSVoxQQdxxuD8j6eqEUWWqCZ6i + Document Update: 7HEaG1SpBdsbVHsrwRTZSZGmpJUWHfFoEzecYWpjo1vm + Document Processing Initiated: Ht1o66MTLKf7JmnEiR27rRLSwdz8FUTgf2mGPNuLSDUL + Document Processing Terminated: 4HWncQEQsdpk8zcXxYxgdtoXo5nKHxFPWeJfTscCbmeK + Document Processing Fatal Error: AMZbj5tNGxjPrvaNyw56sfqcLSW2j1XmkncEYUVtgmVC - type: - blueId: 49hrWpkoXavNmK8PpZag11zB2vYwzhQZahwioz6vDk2i + blueId: FGYuTXwaoSKfZmpTysLTLsb8WzSqf43384rKZDkXhxD4 diff --git a/src/main/resources/transformation/InferBasicTypesForUntypedValues.blue b/src/main/resources/transformation/InferBasicTypesForUntypedValues.blue index 39198b1..bc10dfe 100644 --- a/src/main/resources/transformation/InferBasicTypesForUntypedValues.blue +++ b/src/main/resources/transformation/InferBasicTypesForUntypedValues.blue @@ -1,4 +1,4 @@ name: Infer Basic Types For Untyped Values type: - blueId: AFidhDk8aBDwV7J5X2GjujPfVXBuHvHLcrXMBxxoCSf9 + blueId: Ct1SGRGw1i47qjzm1ruiUdSZofeV6WevPTGuieVvbRS4 description: This transformation infers type details for Text, Integer, Number and Boolean. diff --git a/src/main/resources/transformation/ReplaceInlineTypesWithBlueIds.blue b/src/main/resources/transformation/ReplaceInlineTypesWithBlueIds.blue index 3bfe86a..50222d2 100644 --- a/src/main/resources/transformation/ReplaceInlineTypesWithBlueIds.blue +++ b/src/main/resources/transformation/ReplaceInlineTypesWithBlueIds.blue @@ -1,4 +1,6 @@ name: Replace Inline Types with BlueIds type: - blueId: AFidhDk8aBDwV7J5X2GjujPfVXBuHvHLcrXMBxxoCSf9 -description: Replaces inline type, itemType, keyType, and valueType aliases with canonical BlueId references. + blueId: Ct1SGRGw1i47qjzm1ruiUdSZofeV6WevPTGuieVvbRS4 +description: >- + This transformation + replaces diff --git a/src/main/resources/transformation/Transformation.blue b/src/main/resources/transformation/Transformation.blue index fdc0176..4efd4c0 100644 --- a/src/main/resources/transformation/Transformation.blue +++ b/src/main/resources/transformation/Transformation.blue @@ -1,2 +1,2 @@ name: Transformation -description: Defines a Blue preprocessing transformation document. +description: "\u0054\u004f\u0044\u004f" diff --git a/src/test/java/blue/language/PreprocessorTest.java b/src/test/java/blue/language/PreprocessorTest.java index bd4ef21..23eb034 100644 --- a/src/test/java/blue/language/PreprocessorTest.java +++ b/src/test/java/blue/language/PreprocessorTest.java @@ -30,7 +30,9 @@ public void testType() throws Exception { " value: Integer\n" + "c:\n" + " type:\n" + - " blueId: 84ZWw2aoqB6dWRM6N1qWwgcXGrjfeKexTNdWxxAEcECH"; + " blueId: 84ZWw2aoqB6dWRM6N1qWwgcXGrjfeKexTNdWxxAEcECH\n" + + "d:\n" + + " type: Channel"; Blue blue = new Blue(); Node node = blue.preprocess(blue.yamlToNode(doc)); @@ -38,10 +40,12 @@ public void testType() throws Exception { assertEquals(CORE_TYPE_BLUE_ID_TO_NAME_MAP.get("Integer"), node.getProperties().get("a").getType().getName()); assertEquals("Integer", node.getProperties().get("b").getType().getValue()); assertEquals("84ZWw2aoqB6dWRM6N1qWwgcXGrjfeKexTNdWxxAEcECH", node.getProperties().get("c").getType().getBlueId()); + assertEquals(DEFAULT_BLUE_TYPE_NAME_TO_BLUE_ID_MAP.get("Channel"), node.getProperties().get("d").getType().getBlueId()); assertFalse(node.getProperties().get("a").getType().isInlineValue()); assertFalse(node.getProperties().get("b").getType().isInlineValue()); assertFalse(node.getProperties().get("c").getType().isInlineValue()); + assertFalse(node.getProperties().get("d").getType().isInlineValue()); } @Test @@ -111,6 +115,23 @@ public void preprocessorPreprocessWithoutDefaultBlueIsExplicit() { assertEquals(BigInteger.ONE, result.getProperties().get("x").getValue()); } + @Test + public void blueImportsCannotRedefineDefaultRuntimeAliases() { + Node raw = YAML_MAPPER.readValue( + "blue:\n" + + " imports:\n" + + " Channel:\n" + + " blueId: " + TEXT_TYPE_BLUE_ID + "\n" + + "x:\n" + + " type: Channel", + Node.class); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> new Preprocessor(BootstrapProvider.INSTANCE).preprocess(raw)); + + assertTrue(error.getMessage().contains("default Blue alias \"Channel\"")); + } + @Test public void testTypeConsistencyAfterMultiplePreprocessing() throws Exception { String doc = "a:\n" + diff --git a/src/test/java/blue/language/processor/registry/BlueRuntimeTypeRegistryTest.java b/src/test/java/blue/language/processor/registry/BlueRuntimeTypeRegistryTest.java index a8e0310..b1fc4d9 100644 --- a/src/test/java/blue/language/processor/registry/BlueRuntimeTypeRegistryTest.java +++ b/src/test/java/blue/language/processor/registry/BlueRuntimeTypeRegistryTest.java @@ -1,5 +1,6 @@ package blue.language.processor.registry; +import blue.language.Blue; import blue.language.model.Node; import blue.language.model.TypeBlueId; import blue.language.processor.model.ChannelEventCheckpoint; @@ -41,6 +42,22 @@ void providerReturnsCanonicalNodesForRuntimeTypes() { } } + @Test + void blueInstancesResolveRuntimeTypeDefinitionsByDefault() { + Blue blue = new Blue(); + + Node resolved = blue.resolve(blue.yamlToNode( + "type: Document Update Channel\n" + + "path: /orders")); + + assertEquals(RuntimeBlueIds.DOCUMENT_UPDATE_CHANNEL, resolved.getType().getBlueId()); + assertEquals("Document Update Channel", resolved.getType().getName()); + assertNotNull(resolved.getProperties().get("order"), "Contract field should be inherited"); + assertNotNull(resolved.getProperties().get("event"), "Channel field should be inherited"); + assertEquals("/orders", resolved.getProperties().get("path").getValue()); + assertNotNull(blue.getNodeProvider().fetchByBlueId(RuntimeBlueIds.CHANNEL)); + } + @Test void processorManagedTypeIdsAreCalculatedBlueIds() { BlueRuntimeTypeRegistry registry = BlueRuntimeTypeRegistry.getDefault(); diff --git a/src/test/java/blue/language/provider/BootstrapProviderVerificationTest.java b/src/test/java/blue/language/provider/BootstrapProviderVerificationTest.java index 3377692..698d746 100644 --- a/src/test/java/blue/language/provider/BootstrapProviderVerificationTest.java +++ b/src/test/java/blue/language/provider/BootstrapProviderVerificationTest.java @@ -2,16 +2,22 @@ import blue.language.Blue; import blue.language.model.Node; +import blue.language.processor.registry.BlueRuntimeTypeRegistry; +import blue.language.processor.registry.RuntimeTypeKey; import blue.language.preprocess.Preprocessor; import blue.language.utils.BlueIdCalculator; import org.junit.jupiter.api.Test; import java.io.InputStream; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import static blue.language.utils.Properties.BOOLEAN_TYPE_BLUE_ID; import static blue.language.utils.Properties.CORE_TYPE_BLUE_ID_TO_NAME_MAP; import static blue.language.utils.Properties.CORE_TYPE_NAME_TO_BLUE_ID_MAP; +import static blue.language.utils.Properties.DEFAULT_BLUE_TYPE_BLUE_ID_TO_NAME_MAP; +import static blue.language.utils.Properties.DEFAULT_BLUE_TYPE_NAME_TO_BLUE_ID_MAP; import static blue.language.utils.Properties.DICTIONARY_TYPE_BLUE_ID; import static blue.language.utils.Properties.DOUBLE_TYPE_BLUE_ID; import static blue.language.utils.Properties.INTEGER_TYPE_BLUE_ID; @@ -38,6 +44,28 @@ void coreAliasMapMatchesRegistryBlueIds() { assertEquals(CORE_TYPE_NAME_TO_BLUE_ID_MAP, new Blue().conformanceReport().getCoreRegistryBlueIds()); } + @Test + void defaultBlueAliasMapIncludesRuntimeTypeBlueIds() { + BlueRuntimeTypeRegistry registry = BlueRuntimeTypeRegistry.getDefault(); + + for (RuntimeTypeKey key : RuntimeTypeKey.values()) { + String name = registry.node(key).getName(); + String blueId = registry.blueId(key); + assertEquals(blueId, DEFAULT_BLUE_TYPE_NAME_TO_BLUE_ID_MAP.get(name)); + assertEquals(name, DEFAULT_BLUE_TYPE_BLUE_ID_TO_NAME_MAP.get(blueId)); + } + } + + @Test + void defaultBlueResourceMappingsMatchDefaultAliasMap() throws Exception { + Node defaultBlue = readResource("transformation/DefaultBlue.blue"); + Node mappings = defaultBlue.getItems().get(0).getProperties().get("mappings"); + Map actual = new LinkedHashMap<>(); + mappings.getProperties().forEach((name, node) -> actual.put(name, (String) node.getValue())); + + assertEquals(DEFAULT_BLUE_TYPE_NAME_TO_BLUE_ID_MAP, actual); + } + @Test void defaultBlueTransformBlueIdsMatchResources() throws Exception { Node defaultBlue = readResource("transformation/DefaultBlue.blue"); From d3f11d8ffcdc97dbe448dcec2cfed6998e8e0280 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Fri, 29 May 2026 02:50:45 +0200 Subject: [PATCH 6/6] docs: update README with new spec URL and dependency version changes Revised the Blue language specification URL to the repository link. Updated dependency version examples from `1.0.0` to `3.0.0` and removed outdated local development instructions. --- README.md | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 12ee86b..be61522 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # 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, and identity. A Blue document can be parsed, resolved against its type graph, @@ -36,7 +36,7 @@ repositories { } dependencies { - implementation "blue.language:blue-language-java:1.0.0" + implementation "blue.language:blue-language-java:3.0.0" } ``` @@ -46,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