From 0c8b22bc644b3c75e1a4496270c0e4b54020866f Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 21 Jul 2026 21:13:28 +0200 Subject: [PATCH] fix: restore binary api compatibility --- src/main/java/blue/language/merge/Merger.java | 8 +- .../blue/language/snapshot/FrozenNode.java | 116 +++++++++++- .../snapshot/ResolvedReferenceCache.java | 176 +++++++++++++++++- .../blue/language/utils/MergeReverser.java | 19 ++ .../java/blue/language/MergeReverserTest.java | 26 +++ .../language/merge/MergerIntegrationTest.java | 20 +- ...solvedReferenceCacheCompatibilityTest.java | 113 +++++++++++ .../ResolvedReferenceCacheContractTest.java | 12 +- 8 files changed, 479 insertions(+), 11 deletions(-) create mode 100644 src/test/java/blue/language/snapshot/ResolvedReferenceCacheCompatibilityTest.java 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 List freezeLegacyResolvedItems( + List source, + ResolvedReferenceInterner interner) { + if (source == null) { + return null; + } + List result = new ArrayList<>(source.size()); + for (Node item : source) { + result.add(fromLegacyResolvedNode(item, interner, true)); + } + return result; + } + + private static Map freezeLegacyResolvedProperties( + Map source, + ResolvedReferenceInterner interner) { + if (source == null || source.isEmpty()) { + return null; + } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + result.put(entry.getKey(), + fromLegacyResolvedNode(entry.getValue(), interner, false)); + } + return result; + } + public ResolvedStructuralKey resolvedStructuralKey() { ResolvedStructuralKey key = resolvedStructuralKey; if (key == null) { @@ -1846,8 +1936,32 @@ FrozenNode build() { } } - public interface ResolvedStructuralInterner { + /** + * Legacy BlueId-keyed resolved-reference interner. + * + * @deprecated BlueId-keyed graph interning cannot establish that a + * materialized resolved view is the verified standalone content for that + * BlueId. Use {@link ResolvedReferenceCache} and structural interning. + */ + @Deprecated + public interface ResolvedReferenceInterner { + FrozenNode lookup(String blueId); + + FrozenNode intern(String blueId, FrozenNode node); + } + + public interface ResolvedStructuralInterner extends ResolvedReferenceInterner { FrozenNode intern(ResolvedStructuralKey structuralKey, FrozenNode node); + + @Override + default FrozenNode lookup(String blueId) { + return null; + } + + @Override + default FrozenNode intern(String blueId, FrozenNode node) { + return node; + } } /** diff --git a/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java b/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java index 47556dd..1f0a4be 100644 --- a/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java +++ b/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java @@ -32,7 +32,8 @@ * {@code blueId}: inherited schema and other contextual contributions can make * such a node differ from the standalone content addressed by that identity.

*/ -public final class ResolvedReferenceCache implements AutoCloseable { +public final class ResolvedReferenceCache + implements FrozenNode.ResolvedReferenceInterner, AutoCloseable { private final ResolvedReferenceCache readThroughParent; private final CacheGeneration cacheGeneration; @@ -43,6 +44,9 @@ public final class ResolvedReferenceCache implements AutoCloseable { private final ConcurrentMap entriesByBlueId = new ConcurrentHashMap<>(); private final ConcurrentMap transientTrustedCanonicalByBlueId = new ConcurrentHashMap<>(); + /** Isolated compatibility lane; entries here are never verification evidence. */ + private final ConcurrentMap legacyResolvedAliasesByBlueId = + new ConcurrentHashMap<>(); private final ConcurrentMap resolvedGraphNodesByStructure = new ConcurrentHashMap<>(); private final FrozenNode.ResolvedStructuralInterner resolvedGraphInterner; @@ -50,6 +54,7 @@ public final class ResolvedReferenceCache implements AutoCloseable { private final Set pinnedVerifiedBlueIds = new HashSet<>(); private final LinkedHashSet verifiedInsertionOrder = new LinkedHashSet<>(); private final LinkedHashSet trustedInsertionOrder = new LinkedHashSet<>(); + private final LinkedHashSet legacyInsertionOrder = new LinkedHashSet<>(); private final LinkedHashSet structuralInsertionOrder = new LinkedHashSet<>(); private long verifiedCurrentWeight; @@ -60,6 +65,7 @@ public final class ResolvedReferenceCache implements AutoCloseable { private long trustedHighWaterWeight; private long trustedEvictions; private long trustedOversizedRejections; + private long legacyCurrentWeight; private long structuralCurrentWeight; private long structuralHighWaterWeight; private long structuralEvictions; @@ -165,6 +171,7 @@ public ResolvedReferenceCache forkTransient() { fork.entriesByBlueId.putAll(entriesByBlueId); fork.transientTrustedCanonicalByBlueId.putAll( transientTrustedCanonicalByBlueId); + fork.legacyResolvedAliasesByBlueId.putAll(legacyResolvedAliasesByBlueId); fork.resolvedGraphNodesByStructure.putAll(resolvedGraphNodesByStructure); fork.rebuildLocalWeightAccounting(); } @@ -240,6 +247,92 @@ public FrozenNode putTransientTrustedCanonical(String blueId, FrozenNode canonic } } + /** + * Returns the legacy compatibility view: verified resolved content when + * available, otherwise an isolated unverified alias explicitly retained + * through the deprecated API. + * + * @deprecated Use {@link #getVerifiedResolved(String)} whenever provider + * verification matters. A compatibility result is not verification evidence. + */ + @Deprecated + public Optional get(String blueId) { + return Optional.ofNullable(lookup(blueId)); + } + + /** + * Returns a mutable copy of the legacy compatibility view. The source may + * be an isolated unverified alias and must not be treated as provider proof. + * + * @deprecated Use {@link #getVerifiedResolved(String)} and + * {@link FrozenNode#toNode()}. + */ + @Deprecated + public Node mutableCopy(String blueId) { + FrozenNode node = lookup(blueId); + return node != null ? node.toNode() : null; + } + + /** + * Retains a BlueId-keyed legacy alias without certifying the + * candidate as the standalone content addressed by {@code blueId}. + * + * @deprecated Publish provider content with + * {@link #putVerifiedResolved(VerifiedReferenceResolution)}. + */ + @Deprecated + public FrozenNode putIfAbsent(String blueId, FrozenNode node) { + Objects.requireNonNull(blueId, "blueId"); + Objects.requireNonNull(node, "node"); + synchronized (cacheGeneration.mutationLock) { + ensureCurrentGeneration(); + FrozenNode existing = lookup(blueId); + if (existing != null) { + return existing; + } + FrozenNode retained = legacyResolvedAliasesByBlueId.putIfAbsent(blueId, node); + if (retained != null) { + return retained; + } + recordLegacyInsertion(blueId, node); + return node; + } + } + + /** + * Recursively indexes materialized BlueId-bearing nodes in the isolated + * legacy alias lane. + * + * @deprecated Use {@link #rememberResolvedGraph(FrozenNode)}. This method + * never promotes embedded BlueIds to verified provider entries. + */ + @Deprecated + public void indexResolved(FrozenNode node) { + ensureCurrentGeneration(); + indexLegacyResolved(node, new HashSet()); + } + + /** + * Returns verified resolved content when available, otherwise an isolated + * legacy alias. The fallback is not provider verification evidence. + */ + @Override + @Deprecated + public FrozenNode lookup(String blueId) { + FrozenNode verified = getVerifiedResolved(blueId).orElse(null); + return verified != null ? verified : findLegacyResolvedAlias(blueId); + } + + /** + * Implements the legacy interner without treating a materialized resolved + * view as proof of provider identity. + */ + @Override + @Deprecated + public FrozenNode intern(String blueId, FrozenNode node) { + return putIfAbsent(blueId, node); + } + public Optional getVerifiedCanonical(String blueId) { ensureCurrentGeneration(); VerifiedReferenceEntry entry = findEntry(blueId); @@ -725,6 +818,29 @@ private void rememberResolvedGraph(FrozenNode node, } } + private void indexLegacyResolved(FrozenNode node, + Set visited) { + if (node == null || !visited.add(node.resolvedStructuralKey())) { + return; + } + if (node.getReferenceBlueId() != null && !node.isReferenceOnly()) { + putIfAbsent(node.getReferenceBlueId(), node); + } + indexLegacyResolved(node.getType(), visited); + indexLegacyResolved(node.getItemType(), visited); + indexLegacyResolved(node.getKeyType(), visited); + indexLegacyResolved(node.getValueType(), visited); + indexLegacyResolved(node.getBlue(), visited); + indexLegacyResolved(node.getContracts(), visited); + if (node.getItems() != null) { + node.getItems().forEach(item -> indexLegacyResolved(item, visited)); + } + if (node.getProperties() != null) { + node.getProperties().values().forEach(child -> + indexLegacyResolved(child, visited)); + } + } + private void recordVerifiedInsertion(String blueId, VerifiedReferenceEntry entry) { recordVerifiedReplacement(blueId, null, entry); } @@ -793,6 +909,31 @@ private void recordTrustedInsertion(String blueId, FrozenNode node) { evictTrustedToBounds(); } + private void recordLegacyInsertion(String blueId, FrozenNode node) { + long weight = trustedWeight(blueId, node); + if (cachePolicy.transientReferenceMaxEntries() <= 0 + || weight > cachePolicy.maximumDerivedEntryWeightBytes() + || weight > cachePolicy.transientReferenceMaxWeightBytes()) { + legacyResolvedAliasesByBlueId.remove(blueId, node); + return; + } + legacyInsertionOrder.remove(blueId); + legacyInsertionOrder.add(blueId); + legacyCurrentWeight = saturatedAdd(legacyCurrentWeight, weight); + evictLegacyToBounds(); + } + + private void evictLegacyToBounds() { + while (legacyResolvedAliasesByBlueId.size() + > cachePolicy.transientReferenceMaxEntries() + || legacyCurrentWeight > cachePolicy.transientReferenceMaxWeightBytes()) { + if (legacyInsertionOrder.isEmpty()) { + return; + } + removeLegacyEntry(legacyInsertionOrder.iterator().next()); + } + } + private void evictTrustedToBounds() { while (transientTrustedCanonicalByBlueId.size() > cachePolicy.transientReferenceMaxEntries() || trustedCurrentWeight > cachePolicy.transientReferenceMaxWeightBytes()) { @@ -856,6 +997,15 @@ private void removeTrustedEntry(String blueId) { } } + private void removeLegacyEntry(String blueId) { + FrozenNode removed = legacyResolvedAliasesByBlueId.remove(blueId); + legacyInsertionOrder.remove(blueId); + if (removed != null) { + legacyCurrentWeight = subtractFloorZero( + legacyCurrentWeight, trustedWeight(blueId, removed)); + } + } + private void removeStructuralEntry(FrozenNode.ResolvedStructuralKey key) { FrozenNode removed = resolvedGraphNodesByStructure.remove(key); structuralInsertionOrder.remove(key); @@ -883,6 +1033,12 @@ private void rebuildLocalWeightAccounting(Set retainedPinnedBlueIds) { trustedCurrentWeight = saturatedAdd(trustedCurrentWeight, trustedWeight(entry.getKey(), entry.getValue())); } + for (java.util.Map.Entry entry + : legacyResolvedAliasesByBlueId.entrySet()) { + legacyInsertionOrder.add(entry.getKey()); + legacyCurrentWeight = saturatedAdd(legacyCurrentWeight, + trustedWeight(entry.getKey(), entry.getValue())); + } for (java.util.Map.Entry entry : resolvedGraphNodesByStructure.entrySet()) { structuralInsertionOrder.add(entry.getKey()); @@ -898,9 +1054,11 @@ private void clearLocalWeightAccounting() { pinnedVerifiedBlueIds.clear(); verifiedInsertionOrder.clear(); trustedInsertionOrder.clear(); + legacyInsertionOrder.clear(); structuralInsertionOrder.clear(); verifiedCurrentWeight = 0L; trustedCurrentWeight = 0L; + legacyCurrentWeight = 0L; structuralCurrentWeight = 0L; } @@ -1036,7 +1194,9 @@ private CacheStats localCacheStats() { public int size() { ensureCurrentGeneration(); - return entriesByBlueId.size(); + Set retainedBlueIds = new HashSet<>(entriesByBlueId.keySet()); + retainedBlueIds.addAll(legacyResolvedAliasesByBlueId.keySet()); + return retainedBlueIds.size(); } /** Approximate weight of caller-pinned verified entries retained across configuration refresh. */ @@ -1133,6 +1293,7 @@ private void ensureCurrentGeneration() { } entriesByBlueId.clear(); transientTrustedCanonicalByBlueId.clear(); + legacyResolvedAliasesByBlueId.clear(); resolvedGraphNodesByStructure.clear(); clearLocalWeightAccounting(); observedGeneration = current; @@ -1184,6 +1345,7 @@ public void close() { private void clearLocalState() { entriesByBlueId.clear(); transientTrustedCanonicalByBlueId.clear(); + legacyResolvedAliasesByBlueId.clear(); resolvedGraphNodesByStructure.clear(); clearLocalWeightAccounting(); } @@ -1238,6 +1400,16 @@ private VerifiedReferenceEntry inheritedEntry(String blueId) { return readThroughParent != null ? readThroughParent.findEntry(blueId) : null; } + private FrozenNode findLegacyResolvedAlias(String blueId) { + ensureCurrentGeneration(); + FrozenNode local = legacyResolvedAliasesByBlueId.get(blueId); + return local != null + ? local + : readThroughParent != null + ? readThroughParent.findLegacyResolvedAlias(blueId) + : null; + } + private FrozenNode findResolvedGraph(FrozenNode.ResolvedStructuralKey structuralKey) { ensureCurrentGeneration(); FrozenNode local = resolvedGraphNodesByStructure.get(structuralKey); diff --git a/src/main/java/blue/language/utils/MergeReverser.java b/src/main/java/blue/language/utils/MergeReverser.java index eea67e6..9331a06 100644 --- a/src/main/java/blue/language/utils/MergeReverser.java +++ b/src/main/java/blue/language/utils/MergeReverser.java @@ -29,6 +29,25 @@ public Node reverseToMinimizedOverlay(Node mergedNode) { return minimalNode; } + /** + * Reconstructs the historical resolved-only canonical overlay. + * + *

A 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