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