Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@
bumping the range.
- Schema.org grammar checks are deliberately permissive, accepting URL/text literals
for all properties.
- Schema.org grammar generation emits warning-level property-domain checks from
`domainIncludes`, with standard subclass closure and multi-type union semantics;
grammar and subclass ontology artifacts are refreshed from the same source graph.
- Schema.org grammar issue extraction is subclass-aware for range checks:
warnings like `Schema.org range check: priceSpecification.` are suppressed
when the value node type is a valid schema.org subclass of the expected
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ Behavior notes:

## Validation

SHACL validation utilities and generated Google Search Gallery shapes are included. When a feature includes both container types (for example `ItemList`, `BreadcrumbList`, `QAPage`, `FAQPage`, `Quiz`, `ProfilePage`, `Product`, `Recipe`, `Course`, `Review`) and their contained types (`ListItem`, `Question`, `Answer`, `Comment`, `Offer`, `AggregateOffer`, `HowToStep`, `Person`, `Organization`, `Rating`, `AggregateRating`, `Review`, `ItemList`), the generator scopes the contained constraints under the container properties to avoid enforcing them on unrelated nodes. For Product snippets, `offers` is scoped as `Offer` or `AggregateOffer`, matching Google requirements. The generator also captures "one of" requirements expressed in prose lists and emits `sh:or` constraints so any listed property satisfies the requirement. For tables with explicit `Option A` / `Option B` branches, the generator emits branch-level alternatives (a branch can require multiple properties), and it ignores enum URL literals when extracting property alternatives. Schema.org grammar checks are intentionally permissive and accept URL/text literals for all properties.
SHACL validation utilities and generated Google Search Gallery shapes are included. When a feature includes both container types (for example `ItemList`, `BreadcrumbList`, `QAPage`, `FAQPage`, `Quiz`, `ProfilePage`, `Product`, `Recipe`, `Course`, `Review`) and their contained types (`ListItem`, `Question`, `Answer`, `Comment`, `Offer`, `AggregateOffer`, `HowToStep`, `Person`, `Organization`, `Rating`, `AggregateRating`, `Review`, `ItemList`), the generator scopes the contained constraints under the container properties to avoid enforcing them on unrelated nodes. For Product snippets, `offers` is scoped as `Offer` or `AggregateOffer`, matching Google requirements. The generator also captures "one of" requirements expressed in prose lists and emits `sh:or` constraints so any listed property satisfies the requirement. For tables with explicit `Option A` / `Option B` branches, the generator emits branch-level alternatives (a branch can require multiple properties), and it ignores enum URL literals when extracting property alternatives. Schema.org grammar checks warn when a known property is used on an incompatible Schema.org type, including subclass and multi-type handling. Range checks remain intentionally permissive and accept URL/text literals for all properties.
The generator also recognizes explicit fallback wording in required rows (for example, `contentUrl` with supported `url` fallback if `contentUrl` is missing) and emits `sh:or` alternatives instead of hard-requiring only the preferred property.
Recommended-table "choose either ... or ..." alternatives are emitted as warning-level `sh:or` constraints (including scoped/nested shapes; warn only when none of the alternatives is present).
Paragraph-level "one of the following values" lists are treated as value guidance (not property alternatives), and conditional sections phrased as "required when"/"required if" are emitted as warnings instead of unconditional required errors.
Expand Down
6 changes: 6 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ lists.

`wordlift_sdk.validation.generator` contains the helper scripts used to generate the bundled
SHACL files from the schema.org grammar and Google Search Gallery feature pages.
Schema.org grammar generation also emits property-domain warnings from
`domainIncludes`; core validation combines that metadata with the bundled
standard subclass hierarchy.
The schema generator writes the grammar and sorted direct-subclass ontology from
the same official source graph; `--ontology-output-file` can override the default
bundled-resource refresh path beside `--output-file`.
Google-table parsing supports both property-level "one of" alternatives and explicit
option branches (`Option A` / `Option B`) where each branch can require multiple properties.
Required rows that explicitly document a supported fallback (for example, `url` when
Expand Down
8 changes: 8 additions & 0 deletions specs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ Search Gallery quality gates:
Schema.org grammar checks intentionally allow URL and text literals for every
property (in addition to the documented range types).

Schema.org properties with documented `domainIncludes` values emit warning-level
domain constraints. A property is accepted when any declared Schema.org type is
the documented domain or one of its standard subclasses. Multi-typed nodes use
union semantics, untyped nodes and external predicates are not domain-checked,
and each incompatible subject/property pair produces one deterministic warning.
The grammar and normalized direct-subclass ontology must be regenerated from the
same downloaded Schema.org graph so they remain version-aligned.

## JSON-LD validation from URLs

The validation module can render a URL with Playwright, extract all
Expand Down
106 changes: 106 additions & 0 deletions tests/test_graph_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import textwrap
from pathlib import Path

Expand Down Expand Up @@ -34,6 +35,10 @@
# ---------------------------------------------------------------------------

_SCHEMA = "http://schema.org/"
_DOMAIN_MESSAGE = (
"The property isPartOf is not recognized by the schema (e.g. schema.org) "
"for an object of type Service."
)


def _g(*triples) -> Graph:
Expand Down Expand Up @@ -700,3 +705,104 @@ def test_build_entity_matrix_columns_sorted(tmp_path: Path) -> None:
cols = list(rows[0].keys())
type_cols = cols[1:] # skip "url"
assert type_cols == sorted(type_cols)


def test_graph_auditor_reports_service_is_part_of(tmp_path: Path) -> None:
path = tmp_path / "zurich-regression.jsonld"
site_a = "https://data.wordlift.io/example/web-sites/site-a"
site_b = "https://data.wordlift.io/example/web-sites/site-b"
path.write_text(
json.dumps(
{
"@graph": [
{
"@id": "https://data.wordlift.io/example/services/life-insurance",
"@type": "schema:Service",
"schema:isPartOf": [{"@id": site_b}, {"@id": site_a}],
"schema:name": "Life insurance",
"schema:url": "https://example.org/life-insurance",
},
{
"@id": site_a,
"@type": "schema:WebSite",
"schema:url": "https://example.org/",
},
],
"@context": {"schema": "http://schema.org/"},
}
),
encoding="utf-8",
)

report = GraphAuditor().audit(
path,
AuditOptions(builtin_shapes=["schemaorg-grammar"], max_workers=1),
)
by_url = {entry.url: entry for entry in report.schema_compliance.by_url}
result = by_url["https://example.org/life-insurance"]

assert result.warning_count == 1
assert len(result.warnings) == 1
warning = result.warnings[0]
assert warning.path == "http://schema.org/isPartOf"
assert warning.shape_source == "schemaorg-grammar"
assert warning.message == _DOMAIN_MESSAGE
assert warning.value == site_a

error_only = GraphAuditor().audit(
path,
AuditOptions(
builtin_shapes=["schemaorg-grammar"],
issue_level="error",
max_workers=1,
),
)
error_by_url = {entry.url: entry for entry in error_only.schema_compliance.by_url}
assert error_by_url["https://example.org/life-insurance"].warning_count == 0


def test_graph_auditor_domain_results_are_stable_with_workers(tmp_path: Path) -> None:
path = tmp_path / "concurrent-domain.jsonld"
path.write_text(
json.dumps(
{
"@context": {"@vocab": "http://schema.org/"},
"@graph": [
{
"@id": "https://example.org/services/invalid",
"@type": "Service",
"isPartOf": {"@id": "https://example.org/site"},
"url": "https://example.org/invalid",
},
{
"@id": "https://example.org/articles/valid",
"@type": "Article",
"isPartOf": {"@id": "https://example.org/site"},
"url": "https://example.org/valid",
},
],
}
),
encoding="utf-8",
)

def collect(max_workers: int) -> dict[str, list[str]]:
report = GraphAuditor().audit(
path,
AuditOptions(
builtin_shapes=["schemaorg-grammar"],
max_workers=max_workers,
),
)
return {
entry.url: [issue.message for issue in entry.warnings]
for entry in report.schema_compliance.by_url
}

expected = collect(1)
assert expected == {
"https://example.org/invalid": [_DOMAIN_MESSAGE],
"https://example.org/valid": [],
}
for _ in range(3):
assert collect(2) == expected
101 changes: 101 additions & 0 deletions tests/test_schemaorg_domain_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from __future__ import annotations

import json
from pathlib import Path

import pytest

from wordlift_sdk.validation import shacl


_MESSAGE = (
"The property isPartOf is not recognized by the schema (e.g. schema.org) "
"for an object of type Service."
)


def _validate_payload(tmp_path: Path, types: str | list[str]):
path = tmp_path / "schemaorg-domain.jsonld"
path.write_text(
json.dumps(
{
"@context": {"@vocab": "https://schema.org/"},
"@id": "https://example.org/items/1",
"@type": types,
"isPartOf": {"@id": "https://example.org/site"},
}
),
encoding="utf-8",
)
return shacl.validate_file(path.as_posix(), shape_specs=["schemaorg-grammar"])


def _domain_issues(result):
return [
issue
for issue in shacl.extract_validation_issues(result)
if "is not recognized by the schema" in issue.message
]


def test_schemaorg_domain_rejects_is_part_of_on_service(tmp_path: Path) -> None:
result = _validate_payload(tmp_path, "Service")
issues = _domain_issues(result)

assert len(issues) == 1
assert result.warning_count == 1
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].rule_set == "schemaorg-grammar"
assert issues[0].message == _MESSAGE


@pytest.mark.parametrize(
"types",
["CreativeWork", "Article", ["Service", "CreativeWork"]],
)
def test_schemaorg_domain_accepts_compatible_type_or_subtype(
tmp_path: Path,
types: str | list[str],
) -> None:
assert _domain_issues(_validate_payload(tmp_path, types)) == []


def test_schemaorg_domain_reports_unrelated_multitype_once(tmp_path: Path) -> None:
issues = _domain_issues(_validate_payload(tmp_path, ["Service", "Product"]))

assert len(issues) == 1


def test_schemaorg_domain_ignores_untyped_and_external_predicates(
tmp_path: Path,
) -> None:
path = tmp_path / "domain-boundaries.jsonld"
path.write_text(
json.dumps(
{
"@context": {"@vocab": "http://schema.org/"},
"@graph": [
{
"@id": "https://example.org/untyped",
"isPartOf": "https://example.org/site",
},
{
"@id": "https://example.org/service",
"@type": "Service",
"https://example.org/isPartOf": "https://example.org/site",
"name": "Valid inherited Thing property",
},
],
}
),
encoding="utf-8",
)

result = shacl.validate_file(
path.as_posix(),
shape_specs=["schemaorg-grammar"],
)
assert _domain_issues(result) == []
Loading
Loading