diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6823a..cb8d68c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 8.4.0 - 2026-07-31 + +### Added + +- SHACL validation issues now carry a `node_pointer` (JSON Pointer to the + offending JSON-LD node) alongside `value` and `constraint_component`, so + hosts can point at the exact node that failed instead of a graph identifier. +- `assign_stable_jsonld_ids` and `format_shacl_path` are exported from + `wordlift_sdk.validation` for callers rendering their own issue output. + +### Changed + +- JSON-LD input is given deterministic `@id`s before validation, so report + focus nodes stay stable across runs instead of varying with randomly + generated blank-node labels. +- `ValidationIssue.result_path` is now a readable property name (`price`, + `address.addressCountry`) rather than a raw IRI, and `focus_node` is `None` + for nodes without an `@id` instead of an internal blank-node label. + ## 8.3.8 - 2026-07-15 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 1fada22..25eca62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wordlift-sdk" -version = "8.3.8" +version = "8.4.0" description = "Python toolkit for orchestrating WordLift imports and structured data workflows." authors = ["David Riccitelli "] readme = "README.md" diff --git a/tests/test_schemaorg_domain_validation.py b/tests/test_schemaorg_domain_validation.py index 437cb2f..6e2bbc3 100644 --- a/tests/test_schemaorg_domain_validation.py +++ b/tests/test_schemaorg_domain_validation.py @@ -47,7 +47,7 @@ def test_schemaorg_domain_rejects_is_part_of_on_service(tmp_path: Path) -> None: assert _MESSAGE in result.report_text assert issues[0].level == "warning" assert issues[0].focus_node == "https://example.org/items/1" - assert issues[0].result_path == "http://schema.org/isPartOf" + assert issues[0].result_path == "isPartOf" assert issues[0].rule_set == "schemaorg-grammar" assert issues[0].message == _MESSAGE diff --git a/tests/test_validation_generator_more.py b/tests/test_validation_generator_more.py index c0ebbdd..ed8c632 100644 --- a/tests/test_validation_generator_more.py +++ b/tests/test_validation_generator_more.py @@ -479,7 +479,7 @@ def test_generate_schema_shacls_and_schema_main(monkeypatch, tmp_path: Path): invalid = shacl.validate_file(invalid_path.as_posix(), shape_specs=[str(out)]) issues = shacl.extract_validation_issues(invalid) assert len(issues) == 1 - assert issues[0].result_path == "http://schema.org/isPartOf" + assert issues[0].result_path == "isPartOf" assert issues[0].message.endswith("object of type Service.") valid_path = tmp_path / "valid.jsonld" diff --git a/tests/test_validation_stable_ids.py b/tests/test_validation_stable_ids.py new file mode 100644 index 0000000..ff7aaab --- /dev/null +++ b/tests/test_validation_stable_ids.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import re + +from rdflib import BNode, Graph, URIRef +from rdflib.collection import Collection + +from wordlift_sdk.validation import shacl + +_BNODE_HASH = re.compile(r"[Nn][0-9a-f]{8,}") + + +# --------------------------------------------------------------------------- +# assign_stable_jsonld_ids +# --------------------------------------------------------------------------- + + +def test_assign_stable_ids_adds_pointer_based_ids() -> None: + data = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Product", + "name": "Widget", + "offers": {"@type": "Offer", "price": "9.99"}, + } + ], + } + out = shacl.assign_stable_jsonld_ids(data) + product = out["@graph"][0] + assert product["@id"] == "urn:wl:node:/@graph/0" + assert product["offers"]["@id"] == "urn:wl:node:/@graph/0/offers" + # the input is not mutated + assert "@id" not in data["@graph"][0] + + +def test_assign_stable_ids_is_deterministic() -> None: + data = {"@type": "Product", "brand": {"@type": "Brand", "name": "X"}} + assert shacl.assign_stable_jsonld_ids(data) == shacl.assign_stable_jsonld_ids(data) + + +def test_assign_stable_ids_preserves_existing_id_and_skips_value_objects() -> None: + data = { + "@type": "Product", + "@id": "https://example.com/p", + "released": { + "@value": "2024-01-01", + "@type": "http://www.w3.org/2001/XMLSchema#date", + }, + } + out = shacl.assign_stable_jsonld_ids(data) + assert out["@id"] == "https://example.com/p" # existing id kept + assert "@id" not in out["released"] # literal value object untouched + + +def test_assign_stable_ids_handles_lists_of_nodes() -> None: + data = [{"@type": "Offer"}, {"@type": "Offer"}] + out = shacl.assign_stable_jsonld_ids(data) + assert out[0]["@id"] == "urn:wl:node:/0" + assert out[1]["@id"] == "urn:wl:node:/1" + + +def test_assign_stable_ids_ignores_scalars() -> None: + assert shacl.assign_stable_jsonld_ids("plain") == "plain" + assert shacl.assign_stable_jsonld_ids({"name": "x"})["@id"] == "urn:wl:node:/" + + +def test_assign_stable_ids_rfc6901_escapes_keys() -> None: + # A node nested under a key containing '/' must produce a valid RFC 6901 + # pointer ('/' -> '~1', '~' -> '~0') so it can be resolved unambiguously. + data = { + "@type": "Product", + "https://ref.gs1.org/voc/isNestedUnder": {"@type": "Offer"}, + } + out = shacl.assign_stable_jsonld_ids(data) + nested_id = out["https://ref.gs1.org/voc/isNestedUnder"]["@id"] + assert nested_id == "urn:wl:node:/https:~1~1ref.gs1.org~1voc~1isNestedUnder" + + +# --------------------------------------------------------------------------- +# build_node_pointer_map +# --------------------------------------------------------------------------- + + +def test_pointer_map_covers_real_ids_and_id_less_nodes() -> None: + data = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Product", + "@id": "https://acme.example/product/widget", + "name": "Widget", + "offers": {"@type": "Offer", "priceCurrency": "USD"}, + } + ], + } + mapping = shacl._build_node_pointer_map(data) + # a node with a real @id maps to its own document position + assert mapping["https://acme.example/product/widget"] == "/@graph/0" + # an @id-less node is keyed by the same urn: id the injector would give it + assert mapping["urn:wl:node:/@graph/0/offers"] == "/@graph/0/offers" + + +def test_pointer_map_keys_match_injected_ids() -> None: + # The map's synthetic keys must equal the @ids assign_stable_jsonld_ids + # injects for the same document — otherwise findings would not resolve. + data = { + "@context": "https://schema.org", + "@graph": [{"@type": "Product", "offers": {"@type": "Offer"}}], + } + injected = shacl.assign_stable_jsonld_ids(data) + mapping = shacl._build_node_pointer_map(data) + synthetic_ids = [ + node["@id"] + for _pointer, node in shacl._iter_jsonld_nodes(injected) + if node.get("@id", "").startswith("urn:wl:node:") + ] + assert synthetic_ids # the fixture has @id-less nodes + for node_id in synthetic_ids: + assert node_id in mapping + + +def test_pointer_map_richest_occurrence_wins_for_duplicate_ids() -> None: + # The same @id may appear as a bare reference stub and a full definition; SHACL + # sees one merged resource, so we map it to the richest occurrence (the node + # someone would actually edit), not blindly to the first one. + data = { + "@graph": [ + {"@id": "https://acme.example/brand"}, # stub, first but empty + { + "@type": "Product", + "brand": {"@id": "https://acme.example/brand", "name": "Acme"}, + }, + ] + } + mapping = shacl._build_node_pointer_map(data) + assert mapping["https://acme.example/brand"] == "/@graph/1/brand" + + +def test_pointer_map_richest_occurrence_wins_regardless_of_order() -> None: + # The definition wins even when it precedes the stub — the choice is by + # richness, not document order. + data = { + "@graph": [ + { + "@type": "Product", + "brand": {"@id": "https://acme.example/brand", "name": "Acme"}, + }, + {"@id": "https://acme.example/brand"}, # stub, last + ] + } + mapping = shacl._build_node_pointer_map(data) + assert mapping["https://acme.example/brand"] == "/@graph/0/brand" + + +def test_pointer_map_richness_ignores_type_and_context_keys() -> None: + # Non-keyword properties should decide which node is the richest. + data = { + "@graph": [ + {"@id": "brand", "@type": "Brand", "@context": "https://schema.org"}, # 0 real props + {"@id": "brand", "@type": "Brand", "name": "Acme"}, # 1 real prop + ] + } + mapping = shacl._build_node_pointer_map(data) + assert mapping["brand"] == "/@graph/1" + + +def test_pointer_map_ties_keep_first_occurrence() -> None: + # Equally rich occurrences fall back to first document order so the map stays + # deterministic across runs. + data = { + "@graph": [ + {"@id": "https://acme.example/brand", "name": "Acme"}, # richness 1, first + { + "@type": "Product", + "brand": {"@id": "https://acme.example/brand", "logo": "x"}, # richness 1 + }, + ] + } + mapping = shacl._build_node_pointer_map(data) + assert mapping["https://acme.example/brand"] == "/@graph/0" + + +def test_pointer_map_escapes_rfc6901_keys() -> None: + data = { + "@type": "Product", + "https://ref.gs1.org/voc/isNestedUnder": {"@type": "Offer"}, + } + mapping = shacl._build_node_pointer_map(data) + assert ( + mapping["urn:wl:node:/https:~1~1ref.gs1.org~1voc~1isNestedUnder"] + == "/https:~1~1ref.gs1.org~1voc~1isNestedUnder" + ) + + +def test_pointer_map_root_node() -> None: + assert shacl._build_node_pointer_map({"name": "x"})["urn:wl:node:/"] == "/" + + +# --------------------------------------------------------------------------- +# resolve_focus_node +# --------------------------------------------------------------------------- + + +def test_resolve_focus_node_keeps_real_id() -> None: + # A real @id is a public identifier; it is returned as-is alongside its pointer. + public_id, pointer = shacl._resolve_focus_node( + "https://acme.example/product", {"https://acme.example/product": "/@graph/0"} + ) + assert public_id == "https://acme.example/product" + assert pointer == "/@graph/0" + + +def test_resolve_focus_node_hides_synthetic_id() -> None: + # A synthetic urn:wl:node: id is internal: public_id is None, only the pointer + # crosses the boundary. Consumers never see the convention. + public_id, pointer = shacl._resolve_focus_node( + "urn:wl:node:/@graph/0/offers", {"urn:wl:node:/@graph/0/offers": "/@graph/0/offers"} + ) + assert public_id is None + assert pointer == "/@graph/0/offers" + + +def test_resolve_focus_node_handles_none_and_unmapped() -> None: + assert shacl._resolve_focus_node(None, {}) == (None, None) + # An @id absent from the map yields no pointer, but the id still passes through. + assert shacl._resolve_focus_node("https://acme.example/x", {}) == ( + "https://acme.example/x", + None, + ) + + +# --------------------------------------------------------------------------- +# format_shacl_path +# --------------------------------------------------------------------------- + + +def test_format_shacl_path_simple_uri() -> None: + assert shacl.format_shacl_path(URIRef("http://schema.org/price")) == "price" + assert shacl.format_shacl_path(URIRef("https://schema.org/price")) == "price" + + +def test_format_shacl_path_sequence_path() -> None: + shapes = Graph() + head = BNode() + Collection( + shapes, + head, + [ + URIRef("http://schema.org/address"), + URIRef("http://schema.org/addressCountry"), + ], + ) + assert shacl.format_shacl_path(head, shapes) == "address.addressCountry" + + +def test_format_shacl_path_blank_node_without_shapes_returns_none() -> None: + # Never emit a raw blank-node label as a property path. + assert shacl.format_shacl_path(BNode(), None) is None + assert shacl.format_shacl_path(BNode(), Graph()) is None + assert shacl.format_shacl_path(None) is None + + +# --------------------------------------------------------------------------- +# End-to-end: stable, resolvable focus nodes through the real validator +# --------------------------------------------------------------------------- + + +def _validate(payload: dict) -> shacl.ValidationResult: + graph = shacl._load_graph_from_jsonld(payload) + validator = shacl.PreparedShaclValidator.from_shape_specs() + result = validator.validate_graph(graph) + return shacl.ValidationResult( + conforms=result.conforms, + report_text=result.report_text, + report_graph=result.report_graph, + data_graph=result.data_graph, + shape_source_map=validator.prepared_shapes.shape_source_map, + warning_count=result.warning_count, + shapes_graph=validator.prepared_shapes.shapes_graph, + ) + + +def _resolved_pointers(payload: dict) -> set[str]: + """Resolve every finding's raw ``sh:focusNode`` to its JSON pointer, the way a + consumer does: report focus + :func:`_build_node_pointer_map` over the original + document.""" + result = _validate(payload) + pointer_map = shacl._build_node_pointer_map(payload) + pointers: set[str] = set() + for node in result.report_graph.subjects(shacl.SH.resultSeverity, None): + focus = result.report_graph.value(node, shacl.SH.focusNode) + _, pointer = shacl._resolve_focus_node( + str(focus) if focus is not None else None, pointer_map + ) + if pointer is not None: + pointers.add(pointer) + return pointers + + +def test_focus_nodes_and_paths_are_stable_and_never_blank_hashes() -> None: + payload = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Product", + "name": "Widget", + # nested, @id-less Offer -> would be a random blank node without the fix + "offers": {"@type": "Offer", "priceCurrency": "USD"}, + } + ], + } + + issues = shacl.extract_validation_issues(_validate(payload)) + assert issues, "expected at least one validation issue" + + # Both nodes are anonymous, so focus_node stays anonymous: never the internal + # urn:wl:node: id, never a random blank-node label — just None. result_path is a + # readable property name, never a blank-node hash. + for issue in issues: + assert issue.focus_node is None + assert not _BNODE_HASH.fullmatch(issue.result_path or "") + + # The stable, resolvable handle is the JSON pointer. It is identical across + # independent parses (blank-node labels are not), and makes the nested @id-less + # Offer individually addressable. + pointers_first = _resolved_pointers(payload) + pointers_second = _resolved_pointers(payload) + assert pointers_first == pointers_second + assert "/@graph/0/offers" in pointers_first + + +def test_scrub_synthetic_ids_strips_prefix_to_pointer() -> None: + assert ( + shacl._scrub_synthetic_ids( + "Less than 1 values on ->" + ) + == "Less than 1 values on ->" + ) + assert shacl._scrub_synthetic_ids("urn:wl:node:/@graph/0/offers") == "/@graph/0/offers" + assert shacl._scrub_synthetic_ids("no synthetic id here") == "no synthetic id here" + assert shacl._scrub_synthetic_ids(None) is None + + +def test_urn_never_leaks_into_reported_output() -> None: + # A payload that triggers a MinCount message ("Less than 1 values on ") + # and a nested @id-less value node — both classic urn leak vectors. + payload = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Product", + "name": "Widget", + "offers": {"@type": "Offer", "priceCurrency": "USD"}, + } + ], + } + result = _validate(payload) + + # Human-readable report is output -> scrubbed. + assert "urn:wl:node:" not in result.report_text + + # Structured issues -> no urn in any user-facing field. + issues = shacl.extract_validation_issues(result) + assert issues + for issue in issues: + assert "urn:wl:node:" not in (issue.message or "") + assert "urn:wl:node:" not in (issue.focus_node or "") + + # ...but the internal report graph still carries the urn focus nodes: the SDK + # resolves JSON pointers from them, so they must survive there (not user-facing). + assert any( + "urn:wl:node:" in str(obj) + for obj in result.report_graph.objects(None, shacl.SH.focusNode) + ) + + +def test_extract_resolves_node_pointer_and_stays_urn_free() -> None: + # @id-less Product + nested @id-less Offer: focus_node has no real @id, so the + # only handle is the resolved node_pointer. + payload = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Product", + "name": "Widget", + "offers": {"@type": "Offer", "priceCurrency": "USD"}, + } + ], + } + result = _validate(payload) + issues = shacl.extract_validation_issues(result, payload) + assert issues + + # Every field the SDK hands out is fully resolved and urn-free. + for issue in issues: + for field in (issue.focus_node, issue.node_pointer, issue.value, issue.message): + assert "urn:wl:node:" not in (field or "") + assert issue.focus_node is None # all nodes here are anonymous + + # The SDK — not the consumer — resolved the pointers, so the nested Offer is + # addressable and the constraint component is carried through. + pointers = {i.node_pointer for i in issues} + assert "/@graph/0" in pointers + assert "/@graph/0/offers" in pointers + assert any( + (i.constraint_component or "").endswith("MinCountConstraintComponent") + for i in issues + ) + + # Without source_jsonld there is nothing to resolve against -> node_pointer is + # None, but findings are still urn-free. + no_source = shacl.extract_validation_issues(result) + assert no_source + assert all(i.node_pointer is None for i in no_source) + assert all("urn:wl:node:" not in (i.message or "") for i in no_source) diff --git a/wordlift_sdk/validation/__init__.py b/wordlift_sdk/validation/__init__.py index 40701c1..3a6c3ea 100644 --- a/wordlift_sdk/validation/__init__.py +++ b/wordlift_sdk/validation/__init__.py @@ -10,8 +10,10 @@ "PreparedValidationResult", "ValidationIssue", "ValidationResult", + "assign_stable_jsonld_ids", "extract_validation_issues", "filter_validation_issues", + "format_shacl_path", "list_shape_names", "resolve_shape_specs", "prepare_shapes", @@ -35,10 +37,18 @@ ), "ValidationIssue": ("wordlift_sdk.validation.shacl", "ValidationIssue"), "ValidationResult": ("wordlift_sdk.validation.shacl", "ValidationResult"), + "assign_stable_jsonld_ids": ( + "wordlift_sdk.validation.shacl", + "assign_stable_jsonld_ids", + ), "extract_validation_issues": ( "wordlift_sdk.validation.shacl", "extract_validation_issues", ), + "format_shacl_path": ( + "wordlift_sdk.validation.shacl", + "format_shacl_path", + ), "filter_validation_issues": ( "wordlift_sdk.validation.shacl", "filter_validation_issues", diff --git a/wordlift_sdk/validation/shacl.py b/wordlift_sdk/validation/shacl.py index fcf9d39..93b2578 100644 --- a/wordlift_sdk/validation/shacl.py +++ b/wordlift_sdk/validation/shacl.py @@ -2,6 +2,7 @@ from __future__ import annotations +from copy import deepcopy from dataclasses import dataclass, replace from functools import lru_cache from importlib import resources @@ -37,6 +38,17 @@ "allow_warnings": True, } +# Prefix for synthetic @ids injected into @id-less JSON-LD nodes. The suffix is an +# RFC 6901 JSON Pointer to the node, so a focus node like +# ``urn:wl:node:/@graph/0/offers`` can be resolved against the *original* +# (un-injected) document by walking that pointer. +_SYNTHETIC_ID_PREFIX = "urn:wl:node:" + + +def _json_pointer_escape(key: str) -> str: + """Escape a JSON object key for use in an RFC 6901 JSON Pointer segment.""" + return key.replace("~", "~0").replace("/", "~1") + @dataclass class ValidationResult: @@ -58,6 +70,11 @@ class ValidationIssue: rule_id: str | None rule_set: str | None message: str + # node_pointer/value/message are resolved and urn-free — the raw report_graph + # keeps the internal urn:wl:node: ids, a ValidationIssue never does. + node_pointer: str | None = None + value: str | None = None + constraint_component: str | None = None @dataclass(frozen=True) @@ -128,9 +145,112 @@ def _detect_format_from_response(response: Response) -> str | None: return None +def _is_jsonld_node(node: dict) -> bool: + return "@type" in node or any(not key.startswith("@") for key in node) + + +def _iter_jsonld_nodes(data: object): + """Yield ``(pointer, node)`` for every JSON-LD node object in *data*, in + document order, where *pointer* is the RFC 6901 JSON Pointer to that node + (``""`` for the document root). Literal ``@value`` objects are skipped. + + This is the single traversal that defines how nodes are addressed; + :func:`assign_stable_jsonld_ids` and :func:`_build_node_pointer_map` both walk + through it, so the synthetic ids one injects and the pointers the other maps + cannot drift apart. + """ + + def walk(node: object, pointer: str): + if isinstance(node, list): + for index, item in enumerate(node): + yield from walk(item, f"{pointer}/{index}") + elif isinstance(node, dict): + if "@value" in node: # literal value object, not a node + return + yield pointer, node + for key, value in node.items(): + if key in ("@id", "@type", "@context"): + continue + yield from walk(value, f"{pointer}/{_json_pointer_escape(key)}") + + yield from walk(data, "") + + +def assign_stable_jsonld_ids(data: object) -> object: + """Return a copy of *data* with a deterministic ``@id`` on every ``@id``-less + JSON-LD node, where the id is ``urn:wl:node:`` followed by an RFC 6901 JSON + Pointer to the node. + + Without this, rdflib assigns a fresh random blank-node label to each unnamed + node on every parse, so SHACL report focus nodes are unstable across runs and + cannot be matched back to the source document + """ + result = deepcopy(data) + for pointer, node in _iter_jsonld_nodes(result): + if _is_jsonld_node(node) and "@id" not in node: + node["@id"] = f"{_SYNTHETIC_ID_PREFIX}{pointer or '/'}" + return result + + +def _build_node_pointer_map(data: object) -> dict[str, str]: + """Map each JSON-LD node's SHACL *focus-node identity* to an RFC 6901 JSON + Pointer into *data*. + + When the same ``@id`` appears at more than one position (e.g. a reference stub + plus a full definition elsewhere) pyshacl sees a single merged resource. We map + it to its richest occurrence + """ + mapping: dict[str, str] = {} + richness: dict[str, int] = {} + for pointer, node in _iter_jsonld_nodes(data): + real_id = node.get("@id") + if real_id is not None: + key = real_id + elif _is_jsonld_node(node): + key = f"{_SYNTHETIC_ID_PREFIX}{pointer or '/'}" + else: + continue + props = sum(1 for name in node if not name.startswith("@")) + if key not in mapping or props > richness[key]: + mapping[key] = pointer or "/" + richness[key] = props + return mapping + + +def _public_focus_node(focus_node: str | None) -> str | None: + if focus_node is None or focus_node.startswith(_SYNTHETIC_ID_PREFIX): + return None + return focus_node + + +def _scrub_synthetic_ids(text: str | None) -> str | None: + if text is None: + return None + return text.replace(_SYNTHETIC_ID_PREFIX, "") + + +def _resolve_focus_node( + focus_node: str | None, pointer_map: dict[str, str] +) -> tuple[str | None, str | None]: + """Resolve a report ``sh:focusNode`` to ``(public_id, node_pointer)``. + + *public_id* is the node's real ``@id`` — the identifier a consumer can show and + trust — or ``None`` when the node had no ``@id`` and only carries the synthetic + id :func:`assign_stable_jsonld_ids` injects. *node_pointer* is the RFC 6901 JSON + Pointer into the original document, looked up in *pointer_map* (from + :func:`_build_node_pointer_map`), or ``None`` if the focus node is absent from it. + """ + if focus_node is None: + return None, None + return _public_focus_node(focus_node), pointer_map.get(focus_node) + + def _load_graph_from_text(data: str, fmt: str | None) -> Graph: graph = Graph() try: + if fmt == "json-ld": + parsed = assign_stable_jsonld_ids(json.loads(data)) + data = json.dumps(parsed, ensure_ascii=False) graph.parse(data=data, format=fmt) return graph except Exception as exc: @@ -164,6 +284,8 @@ def _load_graph(path_or_url: str) -> Graph: raise RuntimeError(f"Input file not found: {path}") fmt = _detect_format_from_path(path) + if fmt == "json-ld": + return _load_graph_from_text(path.read_text(encoding="utf-8"), "json-ld") graph = Graph() graph.parse(path.as_posix(), format=fmt) return graph @@ -378,11 +500,6 @@ def _schemaorg_domain_rules(shapes_graph: Graph) -> dict[URIRef, _SchemaOrgDomai return rules -def _schemaorg_local_name(uri: URIRef) -> str: - value = str(uri) - return value[len(_SCHEMAORG_HTTP) :] if value.startswith(_SCHEMAORG_HTTP) else value - - def _rdf_term_sort_key(term: Identifier) -> tuple[str, str, str, str]: return ( term.__class__.__name__, @@ -425,8 +542,8 @@ def _append_schemaorg_domain_results( ): continue - property_name = _schemaorg_local_name(rule.path) - type_name = _schemaorg_local_name(declared_types[0]) + property_name = _local_name(rule.path) + type_name = _local_name(declared_types[0]) message = ( f"The property {property_name} is not recognized by the schema " f"(e.g. schema.org) for an object of type {type_name}." @@ -664,7 +781,9 @@ def validate_graph( return PreparedValidationResult( conforms=conforms, report_graph=report_graph, - report_text=report_text, + # report_graph keeps the urn:wl:node: focus nodes (the SDK resolves + # pointers from them internally); the human-readable text is output. + report_text=_scrub_synthetic_ids(report_text), data_graph=data_graph, warning_count=warning_count, ) @@ -724,7 +843,58 @@ def _severity_to_level(severity: Identifier | None) -> str: return "warning" -def extract_validation_issues(result: ValidationResult) -> list[ValidationIssue]: +def _local_name(term: Identifier | None) -> str | None: + """Local name of an IRI — the segment after the last ``#`` or ``/`` — for any + vocabulary. Returns ``None`` for non-IRI terms.""" + if not isinstance(term, URIRef): + return None + text = str(term) + if "#" in text: + return text.rsplit("#", 1)[-1] or text + if "/" in text: + return text.rsplit("/", 1)[-1] or text + return text + + +def format_shacl_path( + path_term: Identifier | None, shapes_graph: Graph | None = None +) -> str | None: + """Render a SHACL ``sh:path`` value as a readable string. + + Simple property paths become their local name (``"price"``); SHACL sequence + paths — encoded as RDF lists whose head is a blank node in the shapes graph — + become a dotted chain (``"address.addressCountry"``). Never returns a raw + blank-node label: an unresolvable blank node yields ``None``. + """ + if path_term is None: + return None + if isinstance(path_term, URIRef): + return _local_name(path_term) + if isinstance(path_term, BNode) and shapes_graph is not None: + parts: list[str] = [] + current: Identifier | None = path_term + seen: set[Identifier] = set() + while current is not None and current != RDF.nil and current not in seen: + seen.add(current) + first = shapes_graph.value(current, RDF.first) + if first is None: + break + name = _local_name(first) + if name is None: + return None + parts.append(name) + current = shapes_graph.value(current, RDF.rest) + if parts: + return ".".join(parts) + return None + + +def extract_validation_issues( + result: ValidationResult, source_jsonld: object = None +) -> list[ValidationIssue]: + pointer_map = ( + _build_node_pointer_map(source_jsonld) if source_jsonld is not None else {} + ) issues: list[ValidationIssue] = [] for node in result.report_graph.subjects(SH.resultSeverity, None): if _should_skip_schemaorg_subclass_range_warning( @@ -738,21 +908,32 @@ def extract_validation_issues(result: ValidationResult) -> list[ValidationIssue] severity = result.report_graph.value(node, SH.resultSeverity) source_shape = result.report_graph.value(node, SH.sourceShape) message = result.report_graph.value(node, SH.resultMessage) + focus_term = result.report_graph.value(node, SH.focusNode) + path_term = result.report_graph.value(node, SH.resultPath) + value_term = result.report_graph.value(node, SH.value) + constraint = result.report_graph.value(node, SH.sourceConstraintComponent) + focus = str(focus_term) if focus_term is not None else None + public_id, node_pointer = _resolve_focus_node(focus, pointer_map) issues.append( ValidationIssue( level=_severity_to_level(severity), severity=str(severity) if severity else str(SH.Violation), - focus_node=str(result.report_graph.value(node, SH.focusNode)) - if result.report_graph.value(node, SH.focusNode) is not None - else None, - result_path=str(result.report_graph.value(node, SH.resultPath)) - if result.report_graph.value(node, SH.resultPath) is not None - else None, + focus_node=public_id, + node_pointer=node_pointer, + result_path=format_shacl_path(path_term, result.shapes_graph), rule_id=str(source_shape) if source_shape is not None else None, rule_set=result.shape_source_map.get(source_shape) if source_shape is not None else None, - message=str(message) if message is not None else "", + constraint_component=str(constraint) + if constraint is not None + else None, + value=_scrub_synthetic_ids(str(value_term)) + if value_term is not None + else None, + message=_scrub_synthetic_ids(str(message)) + if message is not None + else "", ) ) return issues