diff --git a/src/main/java/blue/language/merge/Merger.java b/src/main/java/blue/language/merge/Merger.java index b679e93..9df65c3 100644 --- a/src/main/java/blue/language/merge/Merger.java +++ b/src/main/java/blue/language/merge/Merger.java @@ -40,11 +40,11 @@ /** * Concrete Blue Language merge engine. * - *
This class is sealed because it issues verified reference-resolution - * evidence. Custom merge behavior must use {@link MergingProcessor}, which is - * the supported extension point.
+ *Custom merge behavior should use {@link MergingProcessor}, which is the + * supported extension point. The class remains extensible for compatibility + * with existing clients.
*/ -public final class Merger implements NodeResolver { +public class Merger implements NodeResolver { private final MergingProcessor mergingProcessor; private final NodeProvider nodeProvider; diff --git a/src/main/java/blue/language/snapshot/FrozenNode.java b/src/main/java/blue/language/snapshot/FrozenNode.java index 8f76e5f..c536e7c 100644 --- a/src/main/java/blue/language/snapshot/FrozenNode.java +++ b/src/main/java/blue/language/snapshot/FrozenNode.java @@ -104,6 +104,19 @@ public static FrozenNode fromResolvedNode(Node node, ResolvedStructuralInterner return fromNode(node, false, interner, false); } + /** + * Freezes a resolved graph using the legacy BlueId-keyed interning contract. + * + *New code should prefer {@link ResolvedReferenceCache#freezeResolved(Node)}, + * which interns by exact resolved structure and keeps provider verification + * separate from graph sharing. This overload remains for binary compatibility + * with clients compiled against the 3.0 API.
+ */ + @Deprecated + public static FrozenNode fromResolvedNode(Node node, ResolvedReferenceInterner interner) { + return fromLegacyResolvedNode(node, interner, false); + } + public static FrozenNode fromUncheckedCanonicalNode(Node node) { return fromNode(node, true, null, false); } @@ -236,6 +249,83 @@ private static FrozenNode fromNode(Node node, return frozen; } + private static FrozenNode fromLegacyResolvedNode(Node node, + ResolvedReferenceInterner interner, + boolean previousAnchorContext) { + Objects.requireNonNull(node, "node"); + if (interner != null && node.getBlueId() != null) { + FrozenNode cached = interner.lookup(node.getBlueId()); + if (cached != null) { + return cached; + } + } + FrozenNode frozen = builder() + .name(node.getName()) + .description(node.getDescription()) + .type(node.getType() != null + ? fromLegacyResolvedNode(node.getType(), interner, false) + : null) + .itemType(node.getItemType() != null + ? fromLegacyResolvedNode(node.getItemType(), interner, false) + : null) + .keyType(node.getKeyType() != null + ? fromLegacyResolvedNode(node.getKeyType(), interner, false) + : null) + .valueType(node.getValueType() != null + ? fromLegacyResolvedNode(node.getValueType(), interner, false) + : null) + .value(node.getValue()) + .items(freezeLegacyResolvedItems(node.getItems(), interner)) + .properties(freezeLegacyResolvedProperties(node.getProperties(), interner)) + .contracts(node.getContracts() != null + ? fromLegacyResolvedNode(node.getContracts(), interner, false) + : null) + .referenceBlueId(node.getBlueId()) + .schema(node.getSchema()) + .mergePolicy(node.getMergePolicy()) + .previousBlueId(node.getPreviousBlueId()) + .position(node.getPosition()) + .blue(node.getBlue() != null + ? fromLegacyResolvedNode(node.getBlue(), interner, false) + : null) + .inlineValue(node.isInlineValue()) + .strictCanonical(false) + .strictBlueIdValidation(false) + .previousAnchorContext(previousAnchorContext) + .build(); + if (interner != null && node.getBlueId() != null && !node.isReferenceOnly()) { + return interner.intern(node.getBlueId(), frozen); + } + return frozen; + } + + private static ListA completed resolved node does not retain all Source provenance. New + * Content BlueId code must use {@link #reverseToCanonicalOverlay(Node, Node)} + * with the corresponding preprocessed Source-equivalent node.
+ * + * @param mergedNode completed resolved view + * @return canonical overlay using the legacy resolved-only behavior + * @deprecated Use {@link #reverseToCanonicalOverlay(Node, Node)} whenever + * source provenance is available. + */ + @Deprecated + public Node reverseToCanonicalOverlay(Node mergedNode) { + Node minimalNode = new Node(); + reverseNode(minimalNode, mergedNode, mergedNode.getType(), true, null); + return minimalNode; + } + /** * Reconstructs a canonical overlay while retaining pure-reference provenance * from the preprocessed source document. diff --git a/src/test/java/blue/language/MergeReverserTest.java b/src/test/java/blue/language/MergeReverserTest.java index fd1e506..ad8f426 100644 --- a/src/test/java/blue/language/MergeReverserTest.java +++ b/src/test/java/blue/language/MergeReverserTest.java @@ -404,6 +404,32 @@ public void canonicalOverlayDoesNotSerializePreviousOrPos() throws Exception { }); } + @Test + @SuppressWarnings("deprecation") + public void resolvedOnlyCanonicalOverlayCompatibilityOverloadRemainsAvailable() 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 resolved = blue.resolve(nodeProvider.getNodeByName("Base")); + + Node canonical = new MergeReverser().reverseToCanonicalOverlay(resolved); + Node canonicalList = canonical.getAsNode("/list"); + + assertEquals(2, canonicalList.getItems().size()); + assertEquals("A", canonicalList.getItems().get(0).getValue()); + assertEquals("B", canonicalList.getItems().get(1).getValue()); + canonicalList.getItems().forEach(item -> { + assertNull(item.getPreviousBlueId()); + assertNull(item.getPosition()); + }); + } + @Test public void canonicalOverlayPreservesExplicitRootLabelsEqualToTypeLabels() { BasicNodeProvider nodeProvider = new BasicNodeProvider(); diff --git a/src/test/java/blue/language/merge/MergerIntegrationTest.java b/src/test/java/blue/language/merge/MergerIntegrationTest.java index b44e925..0f278d4 100644 --- a/src/test/java/blue/language/merge/MergerIntegrationTest.java +++ b/src/test/java/blue/language/merge/MergerIntegrationTest.java @@ -1,11 +1,15 @@ package blue.language.merge; import blue.language.Blue; +import blue.language.merge.processor.SequentialMergingProcessor; import blue.language.model.Node; import blue.language.provider.BasicNodeProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.lang.reflect.Modifier; +import java.util.Collections; + import static org.junit.jupiter.api.Assertions.*; public class MergerIntegrationTest { @@ -54,6 +58,20 @@ public void shouldBeIdempotentWhenResolvingTheSameNodeTwice() { assertEquals(blue.nodeToJson(resolvedNode), blue.nodeToJson(resolvedNode2)); } -} + @Test + public void remainsExtensibleForBinaryCompatibility() { + assertFalse(Modifier.isFinal(Merger.class.getModifiers())); + + Merger merger = new CompatibleMerger(); + assertNotNull(merger); + } + + private static final class CompatibleMerger extends Merger { + + private CompatibleMerger() { + super(new SequentialMergingProcessor(Collections.emptyList()), blueId -> Collections.emptyList()); + } + } +} diff --git a/src/test/java/blue/language/snapshot/ResolvedReferenceCacheCompatibilityTest.java b/src/test/java/blue/language/snapshot/ResolvedReferenceCacheCompatibilityTest.java new file mode 100644 index 0000000..6f9d6cd --- /dev/null +++ b/src/test/java/blue/language/snapshot/ResolvedReferenceCacheCompatibilityTest.java @@ -0,0 +1,113 @@ +package blue.language.snapshot; + +import blue.language.BlueCachePolicy; +import blue.language.model.Node; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +class ResolvedReferenceCacheCompatibilityTest { + + @Test + @SuppressWarnings("deprecation") + void legacyDescriptorsRemainUsableWithoutPublishingVerificationEvidence() { + ResolvedReferenceCache cache = new ResolvedReferenceCache(); + FrozenNode.ResolvedReferenceInterner interner = cache; + Node firstSource = materialized("legacy-id", "first"); + Node secondSource = materialized("legacy-id", "second"); + + FrozenNode first = FrozenNode.fromResolvedNode(firstSource, interner); + FrozenNode second = FrozenNode.fromResolvedNode(secondSource, interner); + + assertSame(first, second, "the explicit legacy interner remains first-by-BlueId"); + assertSame(first, cache.lookup("legacy-id")); + assertSame(first, cache.get("legacy-id").orElse(null)); + assertFalse(cache.getVerifiedCanonical("legacy-id").isPresent()); + assertFalse(cache.getVerifiedResolved("legacy-id").isPresent()); + assertEquals(1, cache.size()); + } + + @Test + @SuppressWarnings("deprecation") + void modernStructuralFreezeDoesNotCollapseContextualNodesByLegacyBlueId() { + ResolvedReferenceCache cache = new ResolvedReferenceCache(); + + FrozenNode first = cache.freezeResolved(materialized("shared-id", "first")); + FrozenNode second = cache.freezeResolved(materialized("shared-id", "second")); + + assertNotSame(first, second); + assertEquals("first", first.getProperties().get("payload").getValue()); + assertEquals("second", second.getProperties().get("payload").getValue()); + assertNull(cache.lookup("shared-id"), + "modern structural freezing must not populate the legacy alias lane"); + assertFalse(cache.getVerifiedResolved("shared-id").isPresent(), + "legacy aliases are never provider verification evidence"); + } + + @Test + @SuppressWarnings("deprecation") + void recursiveLegacyIndexAndMutableCopyRetainHistoricalBehavior() { + FrozenNode child = FrozenNode.fromResolvedNode(materialized("nested-id", "value")); + FrozenNode root = FrozenNode.fromResolvedNode(new Node().properties( + "child", child.toNode())); + ResolvedReferenceCache cache = new ResolvedReferenceCache(); + + cache.indexResolved(root); + Node copy = cache.mutableCopy("nested-id"); + + assertEquals("value", copy.getProperties().get("payload").getValue()); + copy.getProperties().get("payload").value("changed"); + assertEquals("value", cache.mutableCopy("nested-id") + .getProperties().get("payload").getValue()); + + cache.clear(); + assertNull(cache.lookup("nested-id")); + assertEquals(0, cache.size()); + } + + @Test + @SuppressWarnings("deprecation") + void disabledPolicyDoesNotRetainLegacyAliases() { + ResolvedReferenceCache cache = new ResolvedReferenceCache(BlueCachePolicy.disabled()); + FrozenNode candidate = FrozenNode.fromResolvedNode( + materialized("disabled-id", "value")); + + assertSame(candidate, cache.putIfAbsent("disabled-id", candidate)); + assertNull(cache.lookup("disabled-id")); + assertEquals(0, cache.size()); + } + + @Test + @SuppressWarnings("deprecation") + void legacyAliasLaneRespectsConfiguredReferenceBounds() { + ResolvedReferenceCache cache = new ResolvedReferenceCache( + BlueCachePolicy.builder().transientReferences(1, 1024L * 1024L).build()); + FrozenNode first = FrozenNode.fromResolvedNode(materialized("first-id", "first")); + FrozenNode second = FrozenNode.fromResolvedNode(materialized("second-id", "second")); + + cache.putIfAbsent("first-id", first); + cache.putIfAbsent("second-id", second); + + assertNull(cache.lookup("first-id")); + assertSame(second, cache.lookup("second-id")); + assertEquals(1, cache.size()); + } + + @Test + void nullInternerCallRemainsSourceCompatibleAndSelectsStructuralPath() { + FrozenNode frozen = FrozenNode.fromResolvedNode(new Node().value("value"), null); + + assertEquals("value", frozen.getValue()); + assertFalse(frozen.isStrictCanonical()); + } + + private static Node materialized(String blueId, String payload) { + return new Node() + .blueId(blueId) + .properties("payload", new Node().value(payload)); + } +} diff --git a/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java b/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java index 98708a0..be42433 100644 --- a/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java +++ b/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java @@ -75,9 +75,15 @@ void frozenNodeDistinguishesNestedTypedObjectsFromSafeTypeRoots() { } @Test - void verifiedEvidenceProducerIsSealed() { - assertTrue(Modifier.isFinal(Merger.class.getModifiers()), - "Merger must be final because it issues verified resolution evidence"); + void verifiedEvidenceValueRemainsOpaqueWhenMergerIsExtensible() + throws NoSuchMethodException { + assertFalse(Modifier.isFinal(Merger.class.getModifiers()), + "Merger remains extensible for the published 3.0 API"); + assertTrue(Modifier.isFinal(VerifiedReferenceResolution.class.getModifiers())); + assertTrue(Modifier.isPrivate(VerifiedReferenceResolution.class + .getDeclaredConstructor(String.class, FrozenNode.class, FrozenNode.class) + .getModifiers()), + "subclasses must not be able to fabricate verification evidence"); } @Test