From 856025b1ca28b437e93313c29bcae2b0e085fb57 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:59:42 +0200 Subject: [PATCH 1/2] fix(reference-validation): detect dangling references to missing targets --- .../reference/ReferenceToObjectInstance.java | 10 ++ .../model/validation/ReferenceValidator.java | 2 +- .../validation/ReferenceValidatorTest.java | 123 ++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/daniel/jsoneditor/model/validation/ReferenceValidatorTest.java diff --git a/src/main/java/com/daniel/jsoneditor/model/json/schema/reference/ReferenceToObjectInstance.java b/src/main/java/com/daniel/jsoneditor/model/json/schema/reference/ReferenceToObjectInstance.java index 78effab7..947caa89 100644 --- a/src/main/java/com/daniel/jsoneditor/model/json/schema/reference/ReferenceToObjectInstance.java +++ b/src/main/java/com/daniel/jsoneditor/model/json/schema/reference/ReferenceToObjectInstance.java @@ -59,6 +59,16 @@ public String getRemarks() return remarks; } + /** + * Returns the resolved runtime value of the object's referencing key — the value found at + * the objectReferencingKey JSON pointer in this instance's node. Distinct from + * {@link ReferenceToObject#getObjectReferencingKey()} which returns the schema JSON pointer template. + */ + public String getResolvedObjectReferencingKey() + { + return referencingKey; + } + public boolean refersToObject(ReferenceableObjectInstance objectInstance) { final String objectKey = objectInstance.getKey(); diff --git a/src/main/java/com/daniel/jsoneditor/model/validation/ReferenceValidator.java b/src/main/java/com/daniel/jsoneditor/model/validation/ReferenceValidator.java index f073ea64..5d3cd21e 100644 --- a/src/main/java/com/daniel/jsoneditor/model/validation/ReferenceValidator.java +++ b/src/main/java/com/daniel/jsoneditor/model/validation/ReferenceValidator.java @@ -69,7 +69,7 @@ private static void validateReferenceInstance(ReadableModel model, ReferenceToOb if (resolvedPath == null) { final ReferenceableObject refObject = ReferenceHelper.getReferenceableObject(model, - instance.getReference().getObjectReferencingKey()); + instance.getResolvedObjectReferencingKey()); if (refObject == null) { diff --git a/src/test/java/com/daniel/jsoneditor/model/validation/ReferenceValidatorTest.java b/src/test/java/com/daniel/jsoneditor/model/validation/ReferenceValidatorTest.java new file mode 100644 index 00000000..cc720546 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/model/validation/ReferenceValidatorTest.java @@ -0,0 +1,123 @@ +package com.daniel.jsoneditor.model.validation; + +import com.daniel.jsoneditor.model.impl.ModelFactory; +import com.daniel.jsoneditor.model.impl.ModelImpl; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that ReferenceValidator correctly detects dangling cross-object references. + */ +public class ReferenceValidatorTest +{ + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + + // Schema: /items array referenceable by "item_ref" (keyed by /id). + // /items/*/links entries reference items via /ref_type + /ref_id. + private static final String SCHEMA_JSON = "{\"type\":\"object\"," + + "\"properties\":{\"items\":{\"type\":\"array\",\"items\":{\"type\":\"object\"," + + "\"properties\":{\"id\":{\"type\":\"string\"}," + + "\"links\":{\"type\":\"array\",\"items\":{\"type\":\"object\"," + + "\"properties\":{\"ref_type\":{\"type\":\"string\"}," + + "\"ref_id\":{\"type\":\"string\"},\"label\":{\"type\":\"string\"}}}}}}}}," + + "\"referenceableObjects\":[{\"referencingKey\":\"item_ref\",\"path\":\"/items\",\"key\":\"/id\"}]," + + "\"referencesToObjects\":[{\"path\":\"/items/*/links\"," + + "\"objectReferencingKey\":\"/ref_type\"," + + "\"objectKey\":\"/ref_id\"," + + "\"referenceRemarks\":\"/label\"}]}"; + + @Test + void danglingReferenceIsReportedAsInvalid() + { + // item_a exists; its links point to item_b which does NOT exist -> dangling reference. + final ModelImpl model = buildModel(buildItemWithLink("item_a", "item_b", "item_ref", "lbl_1")); + + final ValidationResult result = ReferenceValidator.validateReferences(model); + + assertFalse(result.isValid(), "Dangling reference should be flagged as invalid"); + assertEquals(1, result.getErrorCount(), "Expected exactly one validation error"); + final ValidationError error = result.getErrors().get(0); + assertTrue(error.getPath().contains("links"), "Error path should reference the links node"); + assertTrue(error.getMessage().contains("item_b"), "Error message should name the missing target key"); + } + + @Test + void validReferencePassesValidation() + { + // item_a references item_b; item_b also exists -> valid reference. + final ObjectNode data = MAPPER.createObjectNode(); + final ArrayNode items = MAPPER.createArrayNode(); + + final ObjectNode itemA = MAPPER.createObjectNode(); + itemA.put("id", "item_a"); + final ArrayNode links = MAPPER.createArrayNode(); + final ObjectNode link = MAPPER.createObjectNode(); + link.put("ref_type", "item_ref"); + link.put("ref_id", "item_b"); + link.put("label", "lbl_1"); + links.add(link); + itemA.set("links", links); + + final ObjectNode itemB = MAPPER.createObjectNode(); + itemB.put("id", "item_b"); + itemB.set("links", MAPPER.createArrayNode()); + + items.add(itemA); + items.add(itemB); + data.set("items", items); + + final ValidationResult result = ReferenceValidator.validateReferences(buildModel(data)); + + assertTrue(result.isValid(), "Valid cross-reference should pass; errors: " + result.getErrorSummary()); + } + + // --- helpers --- + + private static ObjectNode buildItemWithLink( + final String itemId, final String targetId, final String refType, final String label) + { + final ObjectNode data = MAPPER.createObjectNode(); + final ArrayNode items = MAPPER.createArrayNode(); + final ObjectNode item = MAPPER.createObjectNode(); + item.put("id", itemId); + final ArrayNode links = MAPPER.createArrayNode(); + final ObjectNode link = MAPPER.createObjectNode(); + link.put("ref_type", refType); + link.put("ref_id", targetId); + link.put("label", label); + links.add(link); + item.set("links", links); + items.add(item); + data.set("items", items); + return data; + } + + private static ModelImpl buildModel(final ObjectNode data) + { + final JsonSchema schema; + try + { + schema = SCHEMA_FACTORY.getSchema(MAPPER.readTree(SCHEMA_JSON)); + } + catch (final Exception e) + { + throw new RuntimeException("Failed to parse test schema", e); + } + final ModelImpl model = ModelFactory.createEmpty(); + model.jsonAndSchemaSuccessfullyValidated( + new File("dummy.json"), new File("dummy_schema.json"), data, schema); + return model; + } +} From 292b0b3d57acea1aa1d503c99f258d31203b1f7a Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:59:43 +0200 Subject: [PATCH 2/2] chore(docs): use neutral example paths in javadoc and tool descriptions --- src/main/java/com/daniel/jsoneditor/model/diff/DiffEntry.java | 4 ++-- .../com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java | 2 +- .../java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java | 2 +- .../java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java | 2 +- .../com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/model/diff/DiffEntry.java b/src/main/java/com/daniel/jsoneditor/model/diff/DiffEntry.java index 32e5435d..484a65be 100644 --- a/src/main/java/com/daniel/jsoneditor/model/diff/DiffEntry.java +++ b/src/main/java/com/daniel/jsoneditor/model/diff/DiffEntry.java @@ -17,9 +17,9 @@ public enum EntryType { /** Normal JSON node without special schema metadata */ NORMAL, - /** Entity reference (e.g., /processes/0/entityReferences/3) */ + /** Entity reference (e.g., /items/0/links/3) */ REFERENCE_TO_OBJECT, - /** Referenceable object instance (e.g., /processes/0, /fields/5) */ + /** Referenceable object instance (e.g., /items/0, /items/5) */ REFERENCEABLE_OBJECT } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java index a02b4a3a..27115252 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java @@ -35,7 +35,7 @@ public String getDescription() public ObjectNode getInputSchema() { final ObjectNode props = McpToolRegistry.createSchemaWithProperty("path", "string", - "JSON path to a referenceable object instance to find references to (e.g., /processes/0)"); + "JSON path to a referenceable object instance to find references to (e.g., /items/0)"); addSessionIdProperty(props); return props; } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java index e6fa244b..54cccf85 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java @@ -33,7 +33,7 @@ public String getDescription() public ObjectNode getInputSchema() { final ObjectNode props = McpToolRegistry.createSchemaWithProperty("path", "string", - "JSON path to get examples for (e.g., /processes/0)"); + "JSON path to get examples for (e.g., /items/0)"); addSessionIdProperty(props); return props; } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java index e0ebbf57..aa199a67 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java @@ -31,7 +31,7 @@ public String getDescription() @Override public ObjectNode getInputSchema() { - final ObjectNode props = McpToolRegistry.createSchemaWithProperty("path", "string", "JSON path (e.g., /processes/0)"); + final ObjectNode props = McpToolRegistry.createSchemaWithProperty("path", "string", "JSON path (e.g., /items/0)"); addSessionIdProperty(props); return props; } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java index 9c375088..d01928c7 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java @@ -35,7 +35,7 @@ public String getDescription() public ObjectNode getInputSchema() { final ObjectNode props = McpToolRegistry.createSchemaWithProperty("path", "string", - "JSON path to get schema for (e.g., /processes/0)"); + "JSON path to get schema for (e.g., /items/0)"); addSessionIdProperty(props); return props; }