From 80ee29a1c61cb429f8f39d8db70375508f7a8375 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 31 Jul 2026 12:04:23 +0200 Subject: [PATCH 01/29] feat(validation): add native OO-LD schema and instance validator - Ports scripts/validate.mjs, pattern_lint.mjs and schema_to_frame.mjs into src/oold/validation/ - Backs the library API, an `oold validate` CLI (aliased `oold-validate`), and an MCP server - Covers meta-schema, $ref composition, @context lint, RDF round-trip, remote-context and oneOf/anyOf checks, plus the compliance-suite and vocabulary-coverage cross-checks - Meta-schemas are versioned under src/oold/validation/meta/, seeded from tag v0.7.0 - Parity is checked in tests/test_validation/test_parity_live.py against 98 pinned ajv-format outcomes --- .github/workflows/main.yml | 24 + .mcp.json | 8 + Makefile | 6 + README.md | 23 + docs/architecture.md | 12 + docs/how-to/index.md | 6 + docs/how-to/validation.md | 170 +++++ pyproject.toml | 34 + src/oold/cli.py | 36 + src/oold/validation/__init__.py | 57 ++ src/oold/validation/cli.py | 231 +++++++ src/oold/validation/compliance.py | 371 +++++++++++ src/oold/validation/context_graph.py | 110 ++++ src/oold/validation/context_resolution.py | 304 +++++++++ src/oold/validation/formats.py | 236 +++++++ src/oold/validation/frame.py | 124 ++++ src/oold/validation/generate.py | 395 +++++++++++ src/oold/validation/instance_checks.py | 134 ++++ src/oold/validation/loader.py | 165 +++++ src/oold/validation/mcp_server.py | 270 ++++++++ .../meta/0.7.0/oold-meta-schema.json | 106 +++ .../meta/0.7.0/oold-pattern-lint.schema.json | 47 ++ .../meta/0.7.0/oold-ui-meta-schema.json | 93 +++ src/oold/validation/meta/README.md | 46 ++ src/oold/validation/meta/index.json | 27 + src/oold/validation/meta_store.py | 355 ++++++++++ src/oold/validation/pattern_lint.py | 182 ++++++ src/oold/validation/pipeline.py | 613 ++++++++++++++++++ src/oold/validation/predicates.py | 180 +++++ src/oold/validation/report.py | 164 +++++ src/oold/validation/resolve.py | 503 ++++++++++++++ src/oold/validation/roundtrip.py | 221 +++++++ src/oold/validation/schema_checks.py | 129 ++++ tests/data/format_parity.json | 136 ++++ tests/data/oold/Address.schema.json | 20 + tests/data/oold/Contact.schema.json | 49 ++ .../data/oold/ContactSeparateKeys.schema.json | 46 ++ tests/data/oold/Minimal.schema.json | 13 + tests/data/oold/Organization.schema.json | 25 + tests/data/oold/OwlOrganization.instance.json | 7 + tests/data/oold/OwlOrganization.schema.json | 26 + tests/data/oold/Person.schema.json | 26 + tests/data/oold/PersonWithPet.instance.json | 6 + tests/data/oold/PersonWithPet.schema.json | 20 + tests/data/oold/Pet.schema.json | 13 + tests/data/oold/README.md | 47 ++ tests/data/oold/RdfPerson.instance.json | 7 + tests/data/oold/RdfPerson.schema.json | 19 + tests/data/oold/Researcher.schema.json | 31 + tests/data/oold/Thing.schema.json | 21 + tests/data/oold/UiAnnotations.schema.json | 59 ++ tests/data/oold/UiOverlay.json | 27 + .../array_without_container.schema.json | 13 + .../data/oold/broken/invalid_meta.schema.json | 9 + .../broken/missing_context_term.schema.json | 14 + .../oold/broken/undefined_prefix.schema.json | 12 + .../unresolvable_context_ref.schema.json | 11 + .../broken/xsd_string_coercion.schema.json | 12 + .../data/oold/compliance/jsonld-features.json | 27 + tests/data/oold/compliance/oold-vocab.json | 85 +++ .../oold/compliance/roundtrip-patterns.json | 254 ++++++++ .../data/oold/remote_context/Leaf.schema.json | 18 + tests/test_validation/__init__.py | 1 + tests/test_validation/conftest.py | 88 +++ tests/test_validation/test_checks.py | 190 ++++++ tests/test_validation/test_cli.py | 128 ++++ tests/test_validation/test_formats.py | 101 +++ tests/test_validation/test_generate.py | 159 +++++ tests/test_validation/test_jsonld.py | 299 +++++++++ tests/test_validation/test_mcp_server.py | 104 +++ tests/test_validation/test_meta_store.py | 167 +++++ tests/test_validation/test_parity_live.py | 119 ++++ tests/test_validation/test_pipeline.py | 216 ++++++ tests/test_validation/test_resolve.py | 183 ++++++ uv.lock | 535 ++++++++++++++- zensical.toml | 1 + 76 files changed, 8725 insertions(+), 1 deletion(-) create mode 100644 .mcp.json create mode 100644 docs/how-to/validation.md create mode 100644 src/oold/cli.py create mode 100644 src/oold/validation/__init__.py create mode 100644 src/oold/validation/cli.py create mode 100644 src/oold/validation/compliance.py create mode 100644 src/oold/validation/context_graph.py create mode 100644 src/oold/validation/context_resolution.py create mode 100644 src/oold/validation/formats.py create mode 100644 src/oold/validation/frame.py create mode 100644 src/oold/validation/generate.py create mode 100644 src/oold/validation/instance_checks.py create mode 100644 src/oold/validation/loader.py create mode 100644 src/oold/validation/mcp_server.py create mode 100644 src/oold/validation/meta/0.7.0/oold-meta-schema.json create mode 100644 src/oold/validation/meta/0.7.0/oold-pattern-lint.schema.json create mode 100644 src/oold/validation/meta/0.7.0/oold-ui-meta-schema.json create mode 100644 src/oold/validation/meta/README.md create mode 100644 src/oold/validation/meta/index.json create mode 100644 src/oold/validation/meta_store.py create mode 100644 src/oold/validation/pattern_lint.py create mode 100644 src/oold/validation/pipeline.py create mode 100644 src/oold/validation/predicates.py create mode 100644 src/oold/validation/report.py create mode 100644 src/oold/validation/resolve.py create mode 100644 src/oold/validation/roundtrip.py create mode 100644 src/oold/validation/schema_checks.py create mode 100644 tests/data/format_parity.json create mode 100644 tests/data/oold/Address.schema.json create mode 100644 tests/data/oold/Contact.schema.json create mode 100644 tests/data/oold/ContactSeparateKeys.schema.json create mode 100644 tests/data/oold/Minimal.schema.json create mode 100644 tests/data/oold/Organization.schema.json create mode 100644 tests/data/oold/OwlOrganization.instance.json create mode 100644 tests/data/oold/OwlOrganization.schema.json create mode 100644 tests/data/oold/Person.schema.json create mode 100644 tests/data/oold/PersonWithPet.instance.json create mode 100644 tests/data/oold/PersonWithPet.schema.json create mode 100644 tests/data/oold/Pet.schema.json create mode 100644 tests/data/oold/README.md create mode 100644 tests/data/oold/RdfPerson.instance.json create mode 100644 tests/data/oold/RdfPerson.schema.json create mode 100644 tests/data/oold/Researcher.schema.json create mode 100644 tests/data/oold/Thing.schema.json create mode 100644 tests/data/oold/UiAnnotations.schema.json create mode 100644 tests/data/oold/UiOverlay.json create mode 100644 tests/data/oold/broken/array_without_container.schema.json create mode 100644 tests/data/oold/broken/invalid_meta.schema.json create mode 100644 tests/data/oold/broken/missing_context_term.schema.json create mode 100644 tests/data/oold/broken/undefined_prefix.schema.json create mode 100644 tests/data/oold/broken/unresolvable_context_ref.schema.json create mode 100644 tests/data/oold/broken/xsd_string_coercion.schema.json create mode 100644 tests/data/oold/compliance/jsonld-features.json create mode 100644 tests/data/oold/compliance/oold-vocab.json create mode 100644 tests/data/oold/compliance/roundtrip-patterns.json create mode 100644 tests/data/oold/remote_context/Leaf.schema.json create mode 100644 tests/test_validation/__init__.py create mode 100644 tests/test_validation/conftest.py create mode 100644 tests/test_validation/test_checks.py create mode 100644 tests/test_validation/test_cli.py create mode 100644 tests/test_validation/test_formats.py create mode 100644 tests/test_validation/test_generate.py create mode 100644 tests/test_validation/test_jsonld.py create mode 100644 tests/test_validation/test_mcp_server.py create mode 100644 tests/test_validation/test_meta_store.py create mode 100644 tests/test_validation/test_parity_live.py create mode 100644 tests/test_validation/test_pipeline.py create mode 100644 tests/test_validation/test_resolve.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b9fa083..536850c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -57,6 +57,30 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + validate-schemas: + # Runs the OO-LD validator over the committed fixtures, offline, so a regression in the + # validation pipeline fails CI even when the unit tests still pass. + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + + - name: Validate the committed OO-LD fixtures + run: make validate + + - name: Check parity against the reference harness + run: | + # oold-schema owns the reference implementation. Comparing against its current main + # catches drift that the committed fixture snapshot cannot see. + git clone --depth 1 https://github.com/OO-LD/oold-schema.git /tmp/oold-schema + npm --prefix /tmp/oold-schema install --no-audit --no-fund + OOLD_SCHEMA_DIR=/tmp/oold-schema uv run python -m pytest \ + tests/test_validation/test_parity_live.py -v --no-cov + continue-on-error: true + check-docs: runs-on: ubuntu-latest steps: diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..f4e35fa --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "oold-validation": { + "command": "uv", + "args": ["run", "--directory", ".", "python", "-m", "oold.validation.mcp_server"] + } + } +} diff --git a/Makefile b/Makefile index 909d575..ba5285f 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,12 @@ test: ## Test the code with pytest @echo "🚀 Testing code: Running pytest" @uv run python -m pytest --cov --cov-config=pyproject.toml --cov-report=xml +.PHONY: validate +validate: ## Validate the committed OO-LD fixtures with the built-in validator + @echo "🚀 Validating OO-LD schemas: oold validate" + @uv run oold validate tests/data/oold --offline + @uv run oold compliance tests/data/oold/compliance --offline + .PHONY: benchmark benchmark: ## Run performance benchmarks with pytest-benchmark @echo "🚀 Running benchmarks: pytest-benchmark" diff --git a/README.md b/README.md index 91b2c0f..940b20d 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,29 @@ loaded = MyModel["ex:foo"] # resolves via registered backend Custom backends implement the `Backend` interface (`resolve_iris`, `store_json_dicts`). +### Validation + +Check that an OO-LD schema is well formed, and that its `@context` actually carries every +declared property into RDF. A property declared in `properties` but missing from `@context` is +neither a JSON Schema error nor a JSON-LD error - it just quietly disappears, and the data loses +meaning. + +```bash +pip install "oold[validation]" + +oold validate Person.schema.json # one schema +oold validate ./schemas/ # a whole directory +oold validate-instance doc.instance.json # a document against the schema it names +``` + +Exit code is 0 only when every check passes, so it drops straight into CI. The same pipeline is +available as a Python API and as an MCP server (`oold[mcp]`). + +It is a native port of the reference harness in +[oold-schema](https://github.com/OO-LD/oold-schema), verified to agree with it on verdicts, and +it validates against versioned meta-schemas - a released version, the unreleased upstream state, +or several at once. See [docs/how-to/validation.md](docs/how-to/validation.md). + ## Development This project uses [uv](https://docs.astral.sh/uv/) and `make`. Clone and set up: diff --git a/docs/architecture.md b/docs/architecture.md index 9c7c2a6..1e3ea1b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,6 +106,18 @@ Backends are registered per IRI prefix via `set_resolver` / `set_backend`, so mu `oold.ui` contains optional integrations for [Panel](https://panel.holoviz.org/), [NiceGUI](https://nicegui.io/), and [Jupyter anywidget](https://anywidget.dev/). These are not installed by default. +### Validation Layer (optional) + +`oold.validation` checks that a schema is well formed and that its `@context` actually carries +every declared property into RDF. It is a native port of the reference harness in +[oold-schema](https://github.com/OO-LD/oold-schema), and reuses `pyld` from the serialization +layer, so the JSON-LD half of it adds no dependencies. + +One pipeline backs three surfaces - the library API, the `oold validate` CLI, and an MCP server - +so there is a single implementation to keep correct. The meta-schemas it validates against are +versioned: a hand-curated history ships in the package, and a schema can be checked against +several versions in one run. See [Validation](how-to/validation.md). + --- ## Data flow diff --git a/docs/how-to/index.md b/docs/how-to/index.md index 7d1b385..c3ce5e5 100644 --- a/docs/how-to/index.md +++ b/docs/how-to/index.md @@ -34,6 +34,12 @@ New here? Start with [Get Started](../get-started.md) to run your first end-to-e Serialize models to JSON-LD, load them into RDFLib, and query with SPARQL - context injection, cross-object links, and round-trip fidelity. +- :material-check-decagram:{ .lg .middle } **[Validation](validation.md)** + + --- + + Check that a schema is well formed and that its `@context` carries every property into RDF - the CLI, the MCP server, meta-schema version selection, and what each check means. + - :material-tune:{ .lg .middle } **[BaseController](controller.md)** --- diff --git a/docs/how-to/validation.md b/docs/how-to/validation.md new file mode 100644 index 0000000..d58e46d --- /dev/null +++ b/docs/how-to/validation.md @@ -0,0 +1,170 @@ +# Validation + +`oold` can check that an OO-LD schema is well formed and that an instance document conforms to +the schema it names. It is a native Python port of the reference harness in +[oold-schema](https://github.com/OO-LD/oold-schema) (`scripts/validate.mjs`), so the two agree on +verdicts, and it is available three ways: as a library, as a CLI, and as an MCP server. + +Install the extra: + +```bash +pip install "oold[validation]" # CLI and library +pip install "oold[validation,mcp]" # plus the MCP server +``` + +## Why more than JSON Schema + +A property declared in `properties` but missing from `@context` is not a JSON Schema error and +not a JSON-LD error. It simply produces no RDF, and the data quietly loses meaning. There are two +distinct failure modes and only looking for the first misses the worse half: + +| Mode | What happens | Reported as | +|---|---|---| +| **Dropped** | The term has no `@context` definition, so the key vanishes on expansion. | `context.predicates`, `roundtrip.generated` | +| **Suspicious** | The term maps through a prefix that was never defined. JSON-LD reads `schema:latitude` as an absolute IRI whose scheme is literally `schema`, so the key survives, the round-trip is clean, and the predicate means nothing. | `context.predicates` | + +The second is the dangerous one, because nothing about the output looks wrong. + +## CLI + +```bash +oold validate path/to/Schema.schema.json # one schema +oold validate path/to/schemas/ # every schema and instance in a directory +oold validate-instance doc.instance.json # a document against the schema it names +oold compliance path/to/compliance/ # a deterministic fixture suite +oold meta list # tracked meta-schema versions +oold meta fetch # refresh the unreleased ones into the cache +``` + +`oold-validate ` is an alias for `oold validate `, matching the reference harness's +`npx --yes github:OO-LD/oold-schema oold-validate ` so CI snippets carry across. + +Exit code is 0 only when no check failed. Warnings do not fail a run. + +### Options + +| Option | Meaning | +|---|---| +| `--meta VERSION` | `latest` (default), a version such as `0.7.0`, `remote`, or `all`. Repeatable. | +| `--offline` | Never fetch; use local files and the cache only. | +| `--verbose` | Show passing checks too, not just problems. | +| `--json` | Emit the report as JSON. | +| `--output FILE` | Write the JSON report to a file. | + +## Meta-schema versions + +The meta-schemas belong to oold-schema. This package keeps a hand-curated copy of each released +version under `src/oold/validation/meta//`, so validation works offline and so one schema +can be checked against several versions at once. + +```bash +oold validate ./schemas --meta 0.7.0 --meta remote +``` + +Only two checks depend on the version, `schema.meta` and `lint.pattern`, and only those are +repeated per version; everything else runs once. Results carry the version they came from: + +``` +FAIL lint.pattern roundtrip-patterns.json [0.7.0]: expected lint fail, got pass +``` + +`remote` fetches the unreleased `main` state into `~/.cache/oold/meta/` (override with +`OOLD_CACHE_DIR`). It never writes into the tracked history, so a released version cannot change +meaning behind your back. Adding a version is documented in +`src/oold/validation/meta/README.md`. + +## The checks + +| Check | What it asserts | +|---|---| +| `schema.meta` | The schema validates against the OO-LD meta-schema. | +| `schema.refs` | Its `$ref` composition resolves. | +| `lint.pattern` | No term coerces a literal to a datatype JSON encodes natively (`xsd:string`, `xsd:boolean`, `xsd:integer`, `xsd:double`, `xsd:float`). None of those survive a round-trip. | +| `lint.container` | A strictly `type: array` property declares `@container: @set` or `@list`, or a single-element array returns as a scalar. | +| `lint.iri-format` | *(warning)* A bare-IRI-string reference declares an `iri-reference` or stricter `uri*` format. | +| `generate.satisfiable` | A generated instance validates against its own schema, catching unsatisfiable schemas. | +| `roundtrip.generated` | That instance survives instance → RDF → instance with no property lost, and the reconstruction still validates. | +| `context.remote` | The schema works as a remote `@context`. | +| `context.predicates` | Every declared property produces a grounded predicate. | +| `variants` | Each `oneOf`/`anyOf` branch is generated and round-tripped in turn. | +| `instance.schema` | A committed instance validates against its schema, with `format` asserted. | +| `roundtrip.instance` | It round-trips through RDF unchanged. | +| `compliance.*`, `coverage.vocab` | Fixture suites with exact expected outcomes, plus a cross-check that every meta-schema keyword has a test. | + +### Cyclic scoped contexts + +When a schema's `@context` references form a cycle - a type whose scoped context embeds itself - +a JSON-LD processor must eagerly validate the recursive context. Neither PyLD nor jsonld.js bounds +that recursion, so such a schema cannot be round-tripped by either. Affected schemas have their +`roundtrip.*` and `context.remote` checks **skipped** with a note; every other check still runs. +Model cyclic edges as references (`@type: "@id"` plus `x-oold-range`, no scoped context). + +## Library + +```python +from oold.validation import Options, validate_directory, validate_schema + +report = validate_schema("Person.schema.json", Options(meta=("latest",), offline=True)) +if not report.passed: + for check in report.failures(): + print(check.id, check.target, check.message) + +print(report.to_dict("summary")) +``` + +`validate_instance` and `run_compliance` follow the same shape. Every entry point returns a +`Report` rather than raising: a caller asking about a broken schema wants the explanation. + +## MCP server + +A working config is committed at `.mcp.json`: + +```json +{ + "mcpServers": { + "oold-validation": { + "command": "uv", + "args": ["run", "--directory", ".", "python", "-m", "oold.validation.mcp_server"] + } + } +} +``` + +Transport is stdio. Tools: `validate_oold_schema`, `validate_oold_instance`, +`validate_oold_directory`, `run_oold_compliance`, `generate_oold_instance`, +`check_context_mapping`, `list_meta_versions`. Each takes `verbosity` as `"summary"` (default) or +`"full"`, and returns errors as data rather than raising. + +## Differences from the reference harness + +The two are intended to agree on verdicts. Where they differ, it is deliberate: + +| Difference | Why | +|---|---| +| Remote and cross-directory `@context` references resolve | The reference maps only names directly under its own base and refuses everything else, so a schema whose context chain leaves the directory cannot be processed at all. `--offline` reproduces its behaviour. | +| Fetched documents are cached on disk | The reference refetches on every run. | +| `context.predicates` exists | Catches undefined-prefix terms, which round-trip cleanly while meaning nothing. | +| Results are reported per meta-schema version | Multi-version validation is not available upstream. | +| Generation is deterministic | The reference's faker already populates every property (`alwaysFakeOptionals`); making it deterministic removes flaky CI failures. There is no `--seed`. | +| An author-pinned `id` (`const`/`enum`/`default`) is not rewritten | The reference rewrites every generated `id` unconditionally, which would make such an instance violate its own schema. | +| `pattern`-constrained strings get a placeholder | Neither corpus uses `pattern`; this avoids a regex-generation dependency. Reported as a note. | + +Check *counts* differ too, because this port splits some of the reference's combined sections. +Verdicts must not: `tests/test_validation/test_parity_live.py` asserts that against a real +checkout. + +## Testing against the upstream repository + +```bash +OOLD_SCHEMA_DIR=../oold-schema uv run pytest tests/test_validation -q +``` + +Without that variable the parity tests skip and the suite stays self-contained. With it, the full +validator runs over oold-schema's own `examples/` and `examples/compliance/`, and the overall +verdict is compared against `node scripts/validate.mjs`. + +The `format` assertions are pinned separately: `tests/data/format_parity.json` holds 98 outcomes +captured from ajv as the reference configures it (`ajv-formats` in *full* mode, plus the +`iri`/`iri-reference` override `validate.mjs` applies), and the suite asserts Python agrees on +every one. Two are easy to get wrong: in full mode `time` requires an offset and `email` requires +a dotted domain. diff --git a/pyproject.toml b/pyproject.toml index a2f73af..268adb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,26 @@ Documentation = "https://OO-LD.github.io/oold-python/" Repository = "https://github.com/OO-LD/oold-python" Download = "https://pypi.org/project/oold/#files" +[project.scripts] +oold = "oold.cli:main" +# The JS-compatible name, bound to the same implementation, so `oold-validate ` works the +# same way as `npx --yes github:OO-LD/oold-schema oold-validate `. +oold-validate = "oold.validation.cli:validate" + [project.optional-dependencies] +# The validator needs a JSON Schema implementation and a CLI framework. pyld and rdflib are +# already core dependencies, so the whole JSON-LD half of it costs nothing extra. +validation = [ + "jsonschema>=4.20", + "referencing>=0.30", + "click>=8.1", +] +mcp = [ + "mcp>=1.2", + "jsonschema>=4.20", + "referencing>=0.30", + "click>=8.1", +] UI-panel = [ "panel", "jupyter_bokeh", @@ -73,10 +92,20 @@ all = [ "traitlets", "ipython", "nicegui", + "jsonschema>=4.20", + "referencing>=0.30", + "click>=8.1", + "mcp>=1.2", ] [dependency-groups] dev = [ + # The validation extras are dev dependencies too, so `make test` and `make check` see them + # without `--all-extras`. + "jsonschema>=4.20", + "referencing>=0.30", + "click>=8.1", + "mcp>=1.2", "pytest", "pytest-cov>=7.0.0", "pytest-benchmark", @@ -167,6 +196,11 @@ subclass-of-final-class = "ignore" [tool.pytest.ini_options] testpaths = ["tests"] addopts = "--cov --cov-config=pyproject.toml --cov-report=term-missing" +markers = [ + # Opt-in parity tests against a local oold-schema checkout; see + # tests/test_validation/test_parity_live.py. They skip unless OOLD_SCHEMA_DIR is set. + "parity: compares this port against the reference harness", +] [tool.deptry] # The anywidget notebook only runs in the browser (pyodide) and imports diff --git a/src/oold/cli.py b/src/oold/cli.py new file mode 100644 index 0000000..62f260a --- /dev/null +++ b/src/oold/cli.py @@ -0,0 +1,36 @@ +"""The ``oold`` command line entry point. + +A thin group that currently hosts the validation commands and leaves room for future +non-validation subcommands. The implementation lives in :mod:`oold.validation.cli`, which is +also bound directly to ``oold-validate`` for compatibility with the reference harness's +``npx oold-validate ``. + +Validation needs the ``validation`` extra, so an import failure is reported as an actionable +message rather than a traceback. +""" + +from __future__ import annotations + +import sys + +INSTALL_HINT = ( + "The validation commands need extra dependencies.\n" + ' pip install "oold[validation]"\n' + " uv sync --all-extras (in a checkout of this repository)" +) + + +def main() -> None: + try: + import click # noqa: F401 + + from oold.validation.cli import main as validation_main + except ImportError as exc: # pragma: no cover - depends on the install + print(f"oold: {exc}\n\n{INSTALL_HINT}", file=sys.stderr) + raise SystemExit(2) from exc + + validation_main() + + +if __name__ == "__main__": + main() diff --git a/src/oold/validation/__init__.py b/src/oold/validation/__init__.py new file mode 100644 index 0000000..eeaaec4 --- /dev/null +++ b/src/oold/validation/__init__.py @@ -0,0 +1,57 @@ +"""OO-LD schema and instance validation. + +A native Python port of the reference harness in `oold-schema +`_ (``scripts/validate.mjs``). It answers two questions: + +* is this a well-formed OO-LD schema, whose ``@context`` carries every property it declares + into RDF without loss? +* does this instance document conform to the schema it names? + +The same pipeline backs the library API, the ``oold validate`` CLI and the MCP server, so there +is one implementation to keep correct. + +Requires the ``validation`` extra:: + + pip install "oold[validation]" +""" + +from __future__ import annotations + +from .meta_store import ( + MetaBundle, + MetaSchemaError, + describe_store, + fetch_remote, + latest_version, + resolve_selection, + tracked_versions, +) +from .pipeline import ( + Options, + run_compliance, + validate_directory, + validate_instance, + validate_schema, +) +from .report import Check, Report, failure_reasons +from .resolve import Resolver, SchemaResolutionError + +__all__ = [ + "Check", + "MetaBundle", + "MetaSchemaError", + "Options", + "Report", + "Resolver", + "SchemaResolutionError", + "describe_store", + "failure_reasons", + "fetch_remote", + "latest_version", + "resolve_selection", + "run_compliance", + "tracked_versions", + "validate_directory", + "validate_instance", + "validate_schema", +] diff --git a/src/oold/validation/cli.py b/src/oold/validation/cli.py new file mode 100644 index 0000000..06ce163 --- /dev/null +++ b/src/oold/validation/cli.py @@ -0,0 +1,231 @@ +"""Command line interface for OO-LD validation. + +Exposed twice, from one implementation: as ``oold validate ...`` (see :mod:`oold.cli`) and as +``oold-validate ...``, whose name and directory-argument behaviour match the reference +harness's ``npx oold-validate `` so documentation and CI snippets carry across between the +two repositories. + +Exit code is 0 only when no check failed. Warnings do not fail a run, matching the reference. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import click + +from .meta_store import MetaSchemaError, describe_store, fetch_remote +from .pipeline import Options, run_compliance, validate_directory, validate_instance, validate_schema +from .report import FAIL, OK, SKIP, WARN, Report + +EXIT_OK = 0 +EXIT_FAILED = 1 + +_STATUS_STYLE = { + OK: {"fg": "green"}, + FAIL: {"fg": "red", "bold": True}, + WARN: {"fg": "yellow"}, + SKIP: {"fg": "cyan"}, +} + +_meta_option = click.option( + "--meta", + "meta", + multiple=True, + metavar="VERSION", + help="Meta-schema version: latest (default), a version such as 0.7.0, remote, or all. " + "Repeat to validate against several at once.", +) +_offline_option = click.option( + "--offline", is_flag=True, help="Never fetch over the network; use local files and the cache." +) +_json_option = click.option("--json", "as_json", is_flag=True, help="Print the report as JSON.") +_verbose_option = click.option("--verbose", "-v", is_flag=True, help="Show every check, not only problems.") +_output_option = click.option("--output", type=click.Path(path_type=Path), help="Write the JSON report to this file.") + + +def _options(meta, offline, **extra) -> Options: + return Options(meta=tuple(meta) or ("latest",), offline=offline, **extra) + + +def _emit(report: Report, as_json: bool, verbose: bool, output: Path | None) -> None: + verbosity = "full" if verbose else "summary" + if output: + output.write_text(json.dumps(report.to_dict(verbosity), indent=2, default=str), encoding="utf-8") + if as_json: + click.echo(json.dumps(report.to_dict(verbosity), indent=2, default=str)) + else: + _print_human(report, verbose) + + +def _print_human(report: Report, verbose: bool) -> None: + if report.fatal_error: + click.echo(click.style("ERROR", fg="red", bold=True) + f" {report.source}") + click.echo(f" {report.fatal_error}") + return + + counts = report.counts + status = click.style("PASS", fg="green", bold=True) if report.passed else click.style("FAIL", fg="red", bold=True) + versions = ", ".join(report.meta_versions) or "none" + click.echo(f"{status} {report.source}") + click.echo(f" meta-schema: {versions}") + click.echo( + f" {counts[OK]} ok, {counts[FAIL]} failed, {counts[WARN]} warning(s), " + f"{counts[SKIP]} skipped, across {len(report.targets())} target(s)" + ) + + shown = report.checks if verbose else [c for c in report.checks if c.status != OK] + if shown: + click.echo() + for check in shown: + style = _STATUS_STYLE.get(check.status, {}) + label = click.style(check.status.upper().ljust(4), **style) + version = f" [{check.meta_version}]" if check.meta_version else "" + message = f": {check.message}" if check.message else "" + click.echo(f" {label} {check.id:<22} {check.target}{version}{message}") + + for note in report.notes[1:] if report.notes else []: + click.echo(f" note: {note}") + + if not verbose and counts[OK]: + click.echo() + click.echo(f" ({counts[OK]} passing check(s) hidden; use --verbose to see them)") + + +def _run(report: Report, as_json: bool, verbose: bool, output: Path | None) -> None: + _emit(report, as_json, verbose, output) + sys.exit(EXIT_OK if report.passed else EXIT_FAILED) + + +# ---------------------------------------------------------------------------- commands + + +@click.command("validate") +@click.argument("target", type=click.Path(exists=True, path_type=Path)) +@_meta_option +@_offline_option +@_json_option +@_verbose_option +@_output_option +def validate(target: Path, meta, offline: bool, as_json: bool, verbose: bool, output: Path | None): + """Validate an OO-LD schema, or every schema and instance in a directory. + + TARGET is a *.schema.json file or a directory. A directory runs the same general-workflow + tier as `oold-validate ` in the reference harness. + """ + try: + options = _options(meta, offline) + except MetaSchemaError as exc: + raise click.ClickException(str(exc)) from exc + + report = validate_directory(target, options) if target.is_dir() else validate_schema(target, options) + _run(report, as_json, verbose, output) + + +@click.command("validate-instance") +@click.argument("instance", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "--schema", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + help="Validate against this schema instead of the one named by $schema.", +) +@_meta_option +@_offline_option +@_json_option +@_verbose_option +@_output_option +def validate_instance_command( + instance: Path, + schema: Path | None, + meta, + offline: bool, + as_json: bool, + verbose: bool, + output: Path | None, +): + """Validate an instance document against the schema it names. + + The instance names its schema with $schema, resolved relative to the instance itself. + """ + report = validate_instance(instance, schema, _options(meta, offline)) + _run(report, as_json, verbose, output) + + +@click.command("compliance") +@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path)) +@_meta_option +@_offline_option +@_json_option +@_verbose_option +@_output_option +def compliance_command(directory: Path, meta, offline: bool, as_json: bool, verbose: bool, output: Path | None): + """Run a deterministic compliance suite, plus the vocabulary-coverage cross-check. + + DIRECTORY holds the fixture files, for example oold-schema's examples/compliance. + """ + report = run_compliance(directory, _options(meta, offline)) + _run(report, as_json, verbose, output) + + +@click.group("meta") +def meta_group() -> None: + """Inspect and refresh the meta-schema store.""" + + +@meta_group.command("list") +@_json_option +def meta_list(as_json: bool) -> None: + """Show tracked meta-schema versions and the state of the remote cache.""" + store = describe_store() + if as_json: + click.echo(json.dumps(store, indent=2)) + return + + click.echo(f"tracked versions ({store['tracked_dir']}):") + for entry in store["versions"]: + marker = " (latest)" if entry["version"] == store["latest"] else "" + tag = entry.get("tag", "?") + added = entry.get("added", "?") + click.echo(f" {entry['version']}{marker} from {tag}, added {added}") + if not store["versions"]: + click.echo(" none; see the README in that directory") + + remote = store["remote"] + click.echo() + click.echo(f"remote ({remote['base_url']}):") + state = f"cached, fetched {remote['fetched']}" if remote["cached"] else "not cached" + click.echo(f" {state}") + click.echo(f" cache dir: {remote['cache_dir']}") + + +@meta_group.command("fetch") +@click.option("--force", is_flag=True, help="Refetch even when a cached copy exists.") +def meta_fetch(force: bool) -> None: + """Fetch the unreleased meta-schemas from the oold-schema repository into the cache. + + This never writes into the tracked version history, so a released version cannot change + meaning behind your back. + """ + try: + target = fetch_remote(force=force) + except MetaSchemaError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(f"fetched into {target}") + + +@click.group() +@click.version_option(package_name="oold") +def main() -> None: + """Validate OO-LD schemas and instance documents.""" + + +main.add_command(validate) +main.add_command(validate_instance_command) +main.add_command(compliance_command) +main.add_command(meta_group) + + +if __name__ == "__main__": + main() diff --git a/src/oold/validation/compliance.py b/src/oold/validation/compliance.py new file mode 100644 index 0000000..ed9b95d --- /dev/null +++ b/src/oold/validation/compliance.py @@ -0,0 +1,371 @@ +"""The deterministic per-feature compliance suite. + +Ports tier 2 of the reference harness (``validate.mjs`` lines 536-634). Where the general +workflow runs generic checks over any schema, this runs *fixtures with exact expected +outcomes*, so it catches behaviour a generic check cannot express. + +A suite is a directory of JSON files, each holding a list of groups. A group is one of three +shapes: + +``schemas`` + Candidate schemas checked against the OO-LD meta-schema, each asserting ``valid`` true or + false. This is how every ``x-oold-*`` keyword gets a well-formedness test. + +``lintSchemas`` + Candidate ``@context``\\ s checked against the round-trip pattern lint, same shape. + +``tests`` + Per-feature cases against a schema named either by ``schemaRef`` (an example file, so real + OO-LD composition is exercised through the loader) or inline via ``schema``. Each case may + assert ``valid`` (instance validation), ``expectRdf`` (RDF dataset isomorphism), + ``roundtrip`` (reconstruction equals the input), and ``expectErrorCode`` (processing must + fail). + +Finally, :func:`vocabulary_coverage` cross-checks that every keyword the meta-schemas define has +a well-formedness test, which is what keeps the suite honest as the vocabulary grows. +""" + +from __future__ import annotations + +import copy +import json +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator +from pyld import jsonld + +from .formats import OOLD_FORMAT_CHECKER +from .frame import embedded_properties, schema_to_frame +from .loader import DocumentLoader, describe_jsonld_error +from .meta_store import MetaBundle +from .pattern_lint import lint +from .roundtrip import canonical, json_equal +from .schema_checks import validate_against_meta + +#: Base the reference harness uses for inline compliance fixtures. +RDF_BASE = "https://oo-ld.test/" + +#: Keyword prefixes the vocabulary-coverage check tracks. +VOCAB_PREFIXES = ("x-oold-", "x-enum-") + + +@dataclass +class ComplianceCase: + """One assertion from a fixture, with what was expected and what happened.""" + + file: str + group: str + description: str + kind: str + passed: bool + detail: str = "" + + def to_dict(self) -> dict[str, Any]: + payload = { + "file": self.file, + "group": self.group, + "description": self.description, + "kind": self.kind, + "passed": self.passed, + } + if self.detail: + payload["detail"] = self.detail + return payload + + +@dataclass +class ComplianceResult: + """Everything one suite run produced.""" + + cases: list[ComplianceCase] = field(default_factory=list) + covered_keywords: set[str] = field(default_factory=set) + errors: list[str] = field(default_factory=list) + + @property + def passed(self) -> int: + return sum(1 for case in self.cases if case.passed) + + @property + def failed(self) -> list[ComplianceCase]: + return [case for case in self.cases if not case.passed] + + def to_dict(self, include_documents: bool = False) -> dict[str, Any]: + payload: dict[str, Any] = { + "total": len(self.cases), + "passed": self.passed, + "failed": [case.to_dict() for case in self.failed], + "errors": self.errors, + } + if include_documents: + payload["cases"] = [case.to_dict() for case in self.cases] + return payload + + +def collect_keywords(node: Any, found: set[str]) -> set[str]: + """Every ``x-oold-*`` / ``x-enum-*`` keyword used anywhere in a document.""" + if isinstance(node, list): + for item in node: + collect_keywords(item, found) + elif isinstance(node, dict): + for key, value in node.items(): + if key.startswith(VOCAB_PREFIXES): + found.add(key) + collect_keywords(value, found) + return found + + +def _instance_validator(schema: dict[str, Any]) -> Draft202012Validator: + return Draft202012Validator(schema, format_checker=OOLD_FORMAT_CHECKER) + + +def _canonize(document: Any, options: dict[str, Any]) -> str: + return jsonld.normalize(document, {"algorithm": "URDNA2015", **options}).strip() + + +def _error_code(exc: BaseException) -> str: + """The identifying string of a JSON-LD error, matching how the reference reads it.""" + code = getattr(exc, "code", None) + if code: + return str(code) + details = getattr(exc, "details", None) or {} + if isinstance(details, dict) and details.get("code"): + return str(details["code"]) + args = getattr(exc, "args", ()) + return args[0] if args and isinstance(args[0], str) else str(exc) + + +def run_suite( + directory: Path, + bundle: MetaBundle, + loader: DocumentLoader, + dereference: Callable[[str], dict[str, Any]] | None = None, +) -> ComplianceResult: + """Run every fixture file in ``directory`` against one meta-schema version. + + ``dereference`` resolves a ``schemaRef`` to a dereferenced, bounded schema. Passing it is + what lets a group exercise real OO-LD composition - base-class ``@context`` inheritance, + property-``$ref`` scoped contexts - instead of only inline schemas. Groups using + ``schemaRef`` are skipped when it is not supplied. + """ + result = ComplianceResult() + directory = Path(directory) + if not directory.is_dir(): + result.errors.append(f"no compliance suite at {directory}") + return result + + for path in sorted(directory.glob("*.json")): + try: + groups = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + result.errors.append(f"{path.name}: could not be read: {exc}") + continue + if not isinstance(groups, list): + result.errors.append(f"{path.name}: expected a list of groups") + continue + for group in groups: + if isinstance(group, dict): + _run_group(path.name, group, bundle, loader, dereference, result) + + return result + + +def _run_group( + filename: str, + group: dict[str, Any], + bundle: MetaBundle, + loader: DocumentLoader, + dereference: Callable[[str], dict[str, Any]] | None, + result: ComplianceResult, +) -> None: + label = group.get("feature") or group.get("description") or filename + + if isinstance(group.get("schemas"), list): + for case in group["schemas"]: + collect_keywords(case.get("schema"), result.covered_keywords) + got = validate_against_meta(case.get("schema"), bundle).valid + result.cases.append( + ComplianceCase( + file=filename, + group=label, + description=case.get("description", ""), + kind="vocab", + passed=got == case.get("valid"), + detail="" + if got == case.get("valid") + else f"expected schema {'valid' if case.get('valid') else 'invalid'}, got " + f"{'valid' if got else 'invalid'}", + ) + ) + return + + if isinstance(group.get("lintSchemas"), list): + for case in group["lintSchemas"]: + got = not lint(case.get("schema", {}), bundle).schema_errors + result.cases.append( + ComplianceCase( + file=filename, + group=label, + description=case.get("description", ""), + kind="lint", + passed=got == case.get("valid"), + detail="" + if got == case.get("valid") + else f"expected lint {'pass' if case.get('valid') else 'fail'}, got {'pass' if got else 'fail'}", + ) + ) + return + + if isinstance(group.get("tests"), list): + _run_tests(filename, label, group, bundle, loader, dereference, result) + + +def _run_tests( + filename: str, + label: str, + group: dict[str, Any], + bundle: MetaBundle, + loader: DocumentLoader, + dereference: Callable[[str], dict[str, Any]] | None, + result: ComplianceResult, +) -> None: + def record(description: str, kind: str, passed: bool, detail: str = "") -> None: + result.cases.append( + ComplianceCase( + file=filename, + group=label, + description=description, + kind=kind, + passed=passed, + detail=detail, + ) + ) + + context: Any = None + rdf_base = RDF_BASE + schema_ref = group.get("schemaRef") + + if schema_ref: + if dereference is None: + record(str(schema_ref), "setup", True, "skipped: no schema directory supplied") + return + try: + feature_schema = dereference(schema_ref) + except Exception as exc: + record(str(schema_ref), "setup", False, f"could not dereference: {exc}") + return + validator = _instance_validator(feature_schema) + rdf_base = loader.base_url + frame_context = loader.url_for(schema_ref) + else: + inline_schema = group.get("schema") or {} + meta_result = validate_against_meta(inline_schema, bundle) + if not meta_result.valid: + record(label, "setup", False, f"feature schema is invalid: {meta_result.errors[:2]}") + return + stripped = copy.deepcopy(inline_schema) + stripped.pop("$schema", None) + validator = _instance_validator(stripped) + context = inline_schema.get("@context") + feature_schema = inline_schema + frame_context = context + + for test in group["tests"]: + description = test.get("description", "") + data = test.get("data") + + if "valid" in test: + got = validator.is_valid(data) + record( + description, + "validate", + got == test["valid"], + "" + if got == test["valid"] + else f"expected {'pass' if test['valid'] else 'fail'}, got {'pass' if got else 'fail'}", + ) + + if "expectRdf" in test: + try: + document = _document_for(data, context) + got_rdf = _canonize(document, loader.options(base=rdf_base, format="application/n-quads")) + want_rdf = _canonize( + test["expectRdf"], + {"inputFormat": "application/n-quads", "format": "application/n-quads"}, + ) + record( + description, + "rdf", + got_rdf == want_rdf, + "" if got_rdf == want_rdf else f"not isomorphic\n got: {got_rdf}\n want: {want_rdf}", + ) + except Exception as exc: + record(description, "rdf", False, describe_jsonld_error(exc)) + + if test.get("roundtrip"): + try: + document = _document_for(data, context) + nquads = jsonld.to_rdf(document, loader.options(base=rdf_base, format="application/n-quads")) + back = jsonld.from_rdf(nquads, {"format": "application/n-quads", "useNativeTypes": True}) + if embedded_properties(feature_schema): + restored = jsonld.frame( + back, + schema_to_frame(feature_schema, frame_context), + loader.options(base=rdf_base, omitDefault=True), + ) + else: + restored = jsonld.compact(back, frame_context, loader.options(base=rdf_base)) + same = json_equal(canonical(document), canonical(restored)) + record( + description, + "roundtrip", + same, + "" + if same + else f"instance != reconstruction\n in: {json.dumps(canonical(document))}" + f"\n out: {json.dumps(canonical(restored))}", + ) + except Exception as exc: + record(description, "roundtrip", False, describe_jsonld_error(exc)) + + if "expectErrorCode" in test: + expected = test["expectErrorCode"] + raised: BaseException | None = None + try: + jsonld.to_rdf(data, loader.options(base=rdf_base, format="application/n-quads")) + except Exception as exc: + raised = exc + if raised is None: + record(description, "error", False, f"did not throw (expected {expected!r})") + else: + code = _error_code(raised) + passed = expected is True or str(expected) in str(code) + record( + description, + "error", + passed, + "" if passed else f"threw {code!r}, expected {expected!r}", + ) + + +def _document_for(data: Any, context: Any) -> dict[str, Any]: + """Attach the group's context unless the case carries its own, and drop ``$schema``. + + ``$schema`` is JSON Schema metadata, not JSON-LD data, so it must not reach the processor. + """ + document = dict(data) if isinstance(data, dict) else {"@value": data} + if "@context" not in document: + document = {"@context": context, **document} + document.pop("$schema", None) + return document + + +def vocabulary_coverage(bundle: MetaBundle, covered: set[str]) -> list[str]: + """Keywords the meta-schemas define but no fixture exercises. + + This is what keeps the suite in sync with the vocabulary: adding a keyword to a meta-schema + without a well-formedness fixture fails the check. + """ + return [keyword for keyword in bundle.declared_keywords() if keyword not in covered] diff --git a/src/oold/validation/context_graph.py b/src/oold/validation/context_graph.py new file mode 100644 index 0000000..f18bec8 --- /dev/null +++ b/src/oold/validation/context_graph.py @@ -0,0 +1,110 @@ +"""Detection of cyclic scoped ``@context`` references. + +Ports ``validate.mjs`` lines 195-227. This module *detects*; it does not resolve. Context +resolution lives in :mod:`~oold.validation.context_resolution` and the pyld document loader. + +A schema's ``@context`` can reference other schema files, both as a parent context and as a +term's scoped context. When those references form a cycle - a value type whose scoped context +embeds itself, or two types embedding each other - a JSON-LD processor is required to eagerly +validate the recursive scoped context. The specification bounds that recursion (validate scoped +context = false, plus context overflow), but neither mainstream processor honours the bound: +jsonld.js exhausts the heap and PyLD raises ``RecursionError``. Such a schema therefore cannot +be round-tripped in practice by either toolchain. + +So the affected schemas are identified up front and their round-trip and remote-context checks +are skipped with a warning, rather than crashing the run. Every other check still applies to +them, and ``$ref`` resolution is unaffected. + +A self-reference through the *top-level* context, with no scoped ``@context`` involved, is not a +cycle here and round-trips fine. +""" + +from __future__ import annotations + +from typing import Any + +#: Marks a string inside a ``@context`` as a reference to another OO-LD schema. +SCHEMA_SUFFIX = ".schema.json" + + +def context_file_refs(context: Any, out: set[str] | None = None) -> set[str]: + """Every schema file referenced anywhere inside a ``@context`` value. + + Both parent contexts and terms' scoped contexts are collected, since both are plain string + references to a schema file. ``x-oold-range`` references are correctly excluded: they live + in ``properties``, not in ``@context``, and load no context. + """ + if out is None: + out = set() + if isinstance(context, str): + if context.endswith(SCHEMA_SUFFIX): + out.add(context) + return out + if isinstance(context, list): + for entry in context: + context_file_refs(entry, out) + return out + if isinstance(context, dict): + for value in context.values(): + context_file_refs(value, out) + return out + + +def build_graph(schemas: dict[str, Any]) -> dict[str, list[str]]: + """Map each schema file name to the schema files its ``@context`` references. + + ``schemas`` is keyed by file name, as the checks operate on a directory of schemas. A + document that cannot be read contributes an empty edge list rather than aborting, matching + the reference harness. + """ + graph: dict[str, list[str]] = {} + for name, document in schemas.items(): + if isinstance(document, dict): + graph[name] = sorted(context_file_refs(document.get("@context"))) + else: + graph[name] = [] + return graph + + +def reaches_cycle(graph: dict[str, list[str]]) -> set[str]: + """Every node that lies on, or can reach, a cycle in the context-reference graph.""" + WHITE, GREY, BLACK = None, 1, 2 + color: dict[str, Any] = {} + on_cycle: set[str] = set() + + def visit(node: str, stack: list[str]) -> None: + color[node] = GREY + for neighbour in graph.get(node, []): + if neighbour not in graph: + # A reference outside the set under consideration; not a cycle we can see. + continue + if color.get(neighbour) == GREY: + # Back edge: everything from the neighbour onwards in the stack is on a cycle. + index = stack.index(neighbour) if neighbour in stack else 0 + for entry in stack[max(index, 0) :]: + on_cycle.add(entry) + on_cycle.add(neighbour) + elif color.get(neighbour, WHITE) is WHITE: + visit(neighbour, [*stack, neighbour]) + color[node] = BLACK + + for name in graph: + if color.get(name, WHITE) is WHITE: + visit(name, [name]) + + # Propagate backwards: a schema that references a cyclic one inherits the problem, because + # loading its context eventually loads the cycle. + reaches = set(on_cycle) + changed = True + while changed: + changed = False + for name, neighbours in graph.items(): + if name not in reaches and any(n in reaches for n in neighbours): + reaches.add(name) + changed = True + return reaches + + +def cyclic_scoped_contexts(schemas: dict[str, Any]) -> set[str]: + """Schema file names whose context chain reaches a cycle. The convenience entry point.""" + return reaches_cycle(build_graph(schemas)) diff --git a/src/oold/validation/context_resolution.py b/src/oold/validation/context_resolution.py new file mode 100644 index 0000000..3575b19 --- /dev/null +++ b/src/oold/validation/context_resolution.py @@ -0,0 +1,304 @@ +"""Flattening an OO-LD ``@context`` chain into a plain JSON-LD context. + +Most checks reference a context *by URL* and let the document loader fetch it, which is what +the reference harness does and what keeps relative IRIs resolving against the right base. This +module exists for the cases that need the context as a single in-memory value instead: reporting +which terms a schema defines, and the per-property attribution in +:mod:`~oold.validation.predicates`. + +It is needed because OO-LD ``@context`` entries are not JSON-LD context documents. They are +references to *other OO-LD schemas*, usually relative siblings:: + + "@context": ["StructuredValue.schema.json", {"latitude": "schema:latitude"}] + +Handing that straight to a JSON-LD processor fetches a JSON Schema where a context was expected, +so nothing resolves and every property looks dropped. In the schema.org-derived corpus the +prefixes that make those terms meaningful (``schema:``, ``xsd:``) are only defined several hops +up the chain, in ``Thing.schema.json``. + +The same pattern appears inside term definitions, where a scoped context is also a schema +reference:: + + "identifier": {"@id": "schema:identifier", "@context": "PropertyValue.schema.json"} + +:func:`resolve_context` walks both forms. Entry order is preserved and the result stays a *list* +of context objects rather than being merged by hand, so JSON-LD's own override semantics still +apply. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urljoin + +from .resolve import Resolver, SchemaResolutionError + +#: How deep to follow the inheritance chain (``Thing -> Intangible -> ...``). The chain is +#: linear, so this is only a backstop; cycles are caught by the stack. +DEFAULT_MAX_DEPTH = 40 + +#: How deep to follow *scoped* contexts nested inside term definitions. These branch rather than +#: chain, so their transitive closure grows explosively and has to be cut much sooner. +#: Truncating one is safe for this module's purpose: scoped contexts govern terms inside nested +#: objects, while the checks here concern a schema's own top-level properties, and the affected +#: term still expands through its ``@id``. +DEFAULT_MAX_SCOPED_DEPTH = 3 + + +@dataclass +class ResolvedContext: + """A JSON-LD-ready context plus a record of how it was assembled.""" + + context: list[Any] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + resolved_refs: list[str] = field(default_factory=list) + cut_cycles: list[str] = field(default_factory=list) + truncated_scoped_contexts: list[str] = field(default_factory=list) + + @property + def is_empty(self) -> bool: + return not self.context + + def as_jsonld(self) -> Any: + """The value to use as a document's ``@context``.""" + if not self.context: + return {} + if len(self.context) == 1: + return self.context[0] + return list(self.context) + + def terms(self) -> dict[str, Any]: + """Flatten term definitions across all context objects, later entries winning. + + Only used for reporting and for discovering which terms alias ``@id``/``@type``; + expansion itself always goes through the unflattened context. + """ + merged: dict[str, Any] = {} + for entry in self.context: + if isinstance(entry, dict): + for key, value in entry.items(): + if not key.startswith("@"): + merged[key] = value + return merged + + def to_dict(self) -> dict[str, Any]: + return { + "term_count": len(self.terms()), + "errors": self.errors, + "resolved_refs": self.resolved_refs, + "cut_cycles": self.cut_cycles, + "truncated_scoped_contexts": self.truncated_scoped_contexts, + } + + +def resolve_context( + schema: dict[str, Any], + base_uri: str, + resolver: Resolver, + max_depth: int = DEFAULT_MAX_DEPTH, + max_scoped_depth: int = DEFAULT_MAX_SCOPED_DEPTH, +) -> ResolvedContext: + """Resolve a schema's ``@context`` into a usable JSON-LD context.""" + result = ResolvedContext() + raw = schema.get("@context") + if raw is None: + return result + + _walk_entries( + raw, + base_uri, + resolver, + result, + stack=(), + depth=0, + max_depth=max_depth, + scoped_depth=0, + max_scoped_depth=max_scoped_depth, + ) + return result + + +def _walk_entries( + raw: Any, + base_uri: str, + resolver: Resolver, + result: ResolvedContext, + stack: tuple[str, ...], + depth: int, + max_depth: int, + scoped_depth: int, + max_scoped_depth: int, +) -> None: + """Append the resolved form of a context value onto ``result.context``.""" + entries = raw if isinstance(raw, list) else [raw] + + for entry in entries: + if isinstance(entry, str): + _walk_reference( + entry, + base_uri, + resolver, + result, + stack, + depth, + max_depth, + scoped_depth, + max_scoped_depth, + ) + elif isinstance(entry, dict): + result.context.append( + _resolve_inline( + entry, + base_uri, + resolver, + result, + stack, + depth, + max_depth, + scoped_depth, + max_scoped_depth, + ) + ) + elif entry is None: + # `null` resets the active context; that is meaningful, so keep it. + result.context.append(None) + else: + result.errors.append(f"unsupported @context entry of type {type(entry).__name__}") + + +def _walk_reference( + ref: str, + base_uri: str, + resolver: Resolver, + result: ResolvedContext, + stack: tuple[str, ...], + depth: int, + max_depth: int, + scoped_depth: int, + max_scoped_depth: int, +) -> None: + """Follow a string ``@context`` entry, which points at another OO-LD schema.""" + absolute = urljoin(base_uri, ref) if base_uri else ref + + if absolute in stack: + if absolute not in result.cut_cycles: + result.cut_cycles.append(absolute) + return + + if depth >= max_depth: + result.errors.append(f"@context chain exceeded depth {max_depth} at {absolute}") + return + + try: + document = resolver.fetch(absolute) + except SchemaResolutionError as exc: + result.errors.append(f"unresolvable @context reference {ref!r}: {exc}") + return + + if absolute not in result.resolved_refs: + result.resolved_refs.append(absolute) + + if isinstance(document, dict) and "@context" in document: + # An OO-LD schema, or a wrapped context document: recurse into its context, resolving + # that document's own relative references against its own URI. + _walk_entries( + document["@context"], + absolute, + resolver, + result, + stack=(*stack, absolute), + depth=depth + 1, + max_depth=max_depth, + scoped_depth=scoped_depth, + max_scoped_depth=max_scoped_depth, + ) + elif isinstance(document, dict): + result.context.append(document) # a bare context object with no wrapper + else: + result.errors.append(f"@context reference {ref!r} did not resolve to a JSON object") + + +def _resolve_inline( + obj: dict[str, Any], + base_uri: str, + resolver: Resolver, + result: ResolvedContext, + stack: tuple[str, ...], + depth: int, + max_depth: int, + scoped_depth: int, + max_scoped_depth: int, +) -> dict[str, Any]: + """Copy an inline context object, resolving any scoped ``@context`` schema references.""" + resolved: dict[str, Any] = {} + + for term, definition in obj.items(): + if not isinstance(definition, dict) or "@context" not in definition: + resolved[term] = definition + continue + + if scoped_depth >= max_scoped_depth: + # Stop descending, but do not call this an error: the term keeps its `@id` and still + # expands correctly. Only terms *inside* the nested object lose their scoped + # definitions, and those are not what this module is consulted for. + label = f"{term} (in {base_uri.rsplit('/', 1)[-1]})" + if label not in result.truncated_scoped_contexts: + result.truncated_scoped_contexts.append(label) + trimmed = dict(definition) + trimmed.pop("@context", None) + resolved[term] = trimmed + continue + + scoped = ResolvedContext() + _walk_entries( + definition["@context"], + base_uri, + resolver, + scoped, + stack=stack, + depth=depth + 1, + max_depth=max_depth, + scoped_depth=scoped_depth + 1, + max_scoped_depth=max_scoped_depth, + ) + + # Fold the nested findings into the parent report. + for ref in scoped.resolved_refs: + if ref not in result.resolved_refs: + result.resolved_refs.append(ref) + for cycle in scoped.cut_cycles: + if cycle not in result.cut_cycles: + result.cut_cycles.append(cycle) + for truncated in scoped.truncated_scoped_contexts: + if truncated not in result.truncated_scoped_contexts: + result.truncated_scoped_contexts.append(truncated) + result.errors.extend(scoped.errors) + + merged = dict(definition) + if scoped.is_empty: + # Dropping an unusable scoped context beats leaving a schema reference behind: the + # term still expands through its own `@id`. + merged.pop("@context", None) + else: + merged["@context"] = scoped.as_jsonld() + resolved[term] = merged + + return resolved + + +def find_alias_keys(terms: dict[str, Any]) -> tuple[str, str]: + """Find which context terms alias ``@id`` and ``@type``. + + OO-LD schemas conventionally expose these as plain ``id`` and ``type`` properties, so the + actual names have to be discovered rather than assumed. Generation must leave them alone: a + random string in ``type`` would produce a meaningless RDF type and a false failure. + """ + id_key, type_key = "@id", "@type" + for term, definition in terms.items(): + target = definition.get("@id") if isinstance(definition, dict) else definition + if target == "@id": + id_key = term + elif target == "@type": + type_key = term + return id_key, type_key diff --git a/src/oold/validation/formats.py b/src/oold/validation/formats.py new file mode 100644 index 0000000..396bf92 --- /dev/null +++ b/src/oold/validation/formats.py @@ -0,0 +1,236 @@ +"""``format`` assertion, matching the reference harness. + +``format`` is an annotation by default in JSON Schema 2020-12. The OO-LD harness turns it into +a real assertion (ajv's ``validateFormats: true`` plus ``ajv-formats`` and +``ajv-formats-draft2019``), so a generated instance that violates a declared ``format`` is a +failure rather than a silent pass. This module is the Python equivalent. + +Two reasons it implements the formats itself instead of relying on ``jsonschema[format]``: + +* the stock :data:`jsonschema.Draft202012Validator.FORMAT_CHECKER` asserts only eight formats + without optional packages installed, and the ones OO-LD leans on most (``iri``, ``uri``, + ``date-time``, ``duration``, ``time``) are not among them; +* ``iri`` and ``iri-reference`` need a deliberate *override* rather than a strict RFC 3987 + implementation. See :func:`is_iri_reference`. + +Formats not implemented here stay annotations, exactly as an unknown format does in ajv. +""" + +from __future__ import annotations + +import ipaddress +import re +from datetime import date as _date +from typing import Any + +from jsonschema import FormatChecker + +# ---------------------------------------------------------------------------- IRI + +# Corrected IRI formats (RFC 3987), ported from oold-schema scripts/validate.mjs. +# +# ajv-formats-draft2019 ships buggy iri / iri-reference regexes: they reject valid compact IRIs +# such as `ex:alice` (any URI/IRI grammar accepts those - an IRI is a superset of a URI) and are +# mutually inconsistent (`iri` accepts `urn:uuid:...` while `iri-reference` does not). The +# reference harness overrides both, and so does this module, or the two implementations would +# disagree on the majority of OO-LD schemas: an IRI reference excludes ASCII controls, space and +# the delimiters RFC 3987 disallows, while non-ASCII ucschar stays allowed; an absolute IRI +# additionally begins with a scheme. Upstream bug: luzlab/ajv-formats-draft2019#31. +_IRI_EXCLUDED = re.compile(r"[\s<>\"{}|\\^`]") +_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:") + +#: ``format`` values that constrain a string to IRI/URI shape. Used by the pattern lint. +IRI_FORMATS = frozenset({"iri-reference", "iri", "uri-reference", "uri"}) + + +def is_iri_reference(value: str) -> bool: + """True for an IRI reference: absolute, compact or relative.""" + return isinstance(value, str) and not _IRI_EXCLUDED.search(value) + + +def is_iri(value: str) -> bool: + """True for an absolute IRI, meaning an IRI reference that carries a scheme.""" + return is_iri_reference(value) and bool(_SCHEME.match(value)) + + +# ---------------------------------------------------------------------------- patterns + +# Ported from ajv-formats so the two toolchains agree on edge cases. ajv matches a loose regex +# and then range-checks the fields numerically, which is what stops `25:00:00` being accepted; +# the same split is used here. `date-time` accepts a space separator and requires an offset, +# `time` leaves the offset optional, and both allow the leap second 23:59:60. +_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_TIME = re.compile(r"^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|[+-]\d\d(?::?\d\d)?)?$", re.IGNORECASE) +_DATE_TIME_SPLIT = re.compile(r"^(.+?)[t ](.+)$", re.IGNORECASE) +_DURATION = re.compile(r"^P(?!$)((\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?|(\d+W)?)$") +# ajv-formats' *full* email, which is what `addFormats(ajv)` installs by default and therefore +# what the reference harness asserts. It differs from the fast variant by requiring a dotted +# domain, so `a@b` is rejected, and by only allowing dots between local-part atoms. +_EMAIL = re.compile( + r"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*" + r"@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$", + re.IGNORECASE, +) +_HOSTNAME = re.compile( + r"^(?=.{1,253}\.?$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" + r"(?:\.[a-z0-9](?:[-0-9a-z]{0,61}[0-9a-z])?)*\.?$", + re.IGNORECASE, +) +_UUID = re.compile( + r"^(?:urn:uuid:)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) +_JSON_POINTER = re.compile(r"^(?:/(?:[^~/]|~0|~1)*)*$") +_RELATIVE_JSON_POINTER = re.compile(r"^(?:0|[1-9][0-9]*)(?:#|(?:/(?:[^~/]|~0|~1)*)*)$") + +#: An ASCII-only counterpart of the IRI rule, which is what separates ``uri`` from ``iri``. +_NON_ASCII = re.compile(r"[^\x00-\x7f]") + + +def _is_uri_reference(value: str) -> bool: + return is_iri_reference(value) and not _NON_ASCII.search(value) + + +def _is_uri(value: str) -> bool: + return _is_uri_reference(value) and bool(_SCHEME.match(value)) + + +# ---------------------------------------------------------------------------- checker + +#: The format checker used everywhere in this package. Kept as a module-level singleton so a +#: validator built in one check behaves identically to one built in another. +OOLD_FORMAT_CHECKER = FormatChecker() + + +def _string_check(func): + """Wrap a predicate so non-strings pass: ``format`` only constrains strings.""" + + def check(value: Any) -> bool: + if not isinstance(value, str): + return True + return func(value) + + check.__name__ = func.__name__ + return check + + +def _register(name: str, predicate) -> None: + OOLD_FORMAT_CHECKER.checks(name)(_string_check(predicate)) + + +def _valid_date(value: str) -> bool: + if not _DATE.match(value): + return False + try: + _date.fromisoformat(value) + except ValueError: + return False + return True + + +def _valid_time(value: str, require_offset: bool = True) -> bool: + """RFC 3339 full-time, with the field ranges checked numerically as ajv does. + + The offset is *required*, matching ajv-formats in full mode - which is what + ``addFormats(ajv)`` installs by default and therefore what the reference harness asserts. + The fast-mode variant makes it optional, and following that would let ``03:04:05`` pass + here while failing upstream. + """ + match = _TIME.match(value) + if not match: + return False + hour, minute, second = int(match[1]), int(match[2]), float(match[3]) + offset = match[4] + if require_offset and not offset: + return False + if offset and offset.lower() != "z": + # The offset itself carries an hour and minute that must be in range. + digits = offset[1:].replace(":", "") + if int(digits[:2]) > 23 or (len(digits) > 2 and int(digits[2:4]) > 59): + return False + if hour <= 23 and minute <= 59 and second < 60: + return True + # The leap second is the one legal exception. + return hour == 23 and minute == 59 and second == 60 + + +def _valid_date_time(value: str) -> bool: + parts = _DATE_TIME_SPLIT.match(value) + if not parts: + return False + return _valid_date(parts[1]) and _valid_time(parts[2], require_offset=True) + + +def _valid_ipv4(value: str) -> bool: + try: + ipaddress.IPv4Address(value) + except ValueError: + return False + return True + + +def _valid_ipv6(value: str) -> bool: + # A scoped address such as `fe80::1%eth0` is accepted by `ipaddress` but not by ajv, and a + # zone identifier is not part of the JSON Schema `ipv6` format. + if "%" in value: + return False + try: + ipaddress.IPv6Address(value) + except ValueError: + return False + return True + + +def _valid_regex(value: str) -> bool: + try: + re.compile(value) + except re.error: + return False + return True + + +_register("date", _valid_date) +_register("date-time", _valid_date_time) +_register("time", _valid_time) +_register("duration", lambda v: bool(_DURATION.match(v))) +_register("email", lambda v: bool(_EMAIL.match(v))) +_register("hostname", lambda v: bool(_HOSTNAME.match(v))) +_register("ipv4", _valid_ipv4) +_register("ipv6", _valid_ipv6) +_register("uuid", lambda v: bool(_UUID.match(v))) +_register("regex", _valid_regex) +_register("json-pointer", lambda v: bool(_JSON_POINTER.match(v))) +_register("relative-json-pointer", lambda v: bool(_RELATIVE_JSON_POINTER.match(v))) +_register("uri", _is_uri) +_register("uri-reference", _is_uri_reference) +_register("iri", is_iri) +_register("iri-reference", is_iri_reference) + +# The internationalised variants differ from their ASCII counterparts only by permitting +# non-ASCII, which the IRI rules already do. Neither OO-LD corpus uses them; they are registered +# so a schema that declares one still gets a shape check rather than silently no check at all. +_register("idn-email", lambda v: bool(_EMAIL.match(v)) or ("@" in v and is_iri_reference(v))) +_register("idn-hostname", lambda v: is_iri_reference(v) and "/" not in v) + +#: Deterministic, format-valid sample values, used by the instance generator. Every entry must +#: satisfy the corresponding checker above; :mod:`tests` asserts exactly that. +FORMAT_SAMPLES: dict[str, str] = { + "date": "2026-01-02", + "date-time": "2026-01-02T03:04:05Z", + "time": "03:04:05Z", + "duration": "P1DT2H", + "email": "someone@example.org", + "idn-email": "someone@example.org", + "hostname": "example.org", + "idn-hostname": "example.org", + "ipv4": "192.0.2.1", + "ipv6": "2001:db8::1", + "uuid": "00000000-0000-4000-8000-000000000000", + "regex": "^example$", + "json-pointer": "/example", + "relative-json-pointer": "0/example", + "uri": "https://example.org/thing", + "uri-reference": "https://example.org/thing", + "iri": "https://example.org/thing", + "iri-reference": "https://example.org/thing", +} diff --git a/src/oold/validation/frame.py b/src/oold/validation/frame.py new file mode 100644 index 0000000..75a9e52 --- /dev/null +++ b/src/oold/validation/frame.py @@ -0,0 +1,124 @@ +"""Deriving a minimal JSON-LD frame from an OO-LD schema. + +Port of ``scripts/schema_to_frame.mjs`` from oold-schema. + +Compaction alone reconstructs literals and references from RDF, but an embedded object is +flattened into a separate (blank) node, and compaction never re-nests a flat graph. Framing +does. So a schema that embeds objects needs a frame to round-trip, and this module derives the +minimal one: + +* ``@type`` is the schema's instance ``rdf:type``(s), so the exported root - which materialises + that type - becomes the frame root and embedded objects nest beneath it rather than surfacing + as sibling graph nodes; +* ``@context`` is the schema's own context, or a reference to it, so terms compact back to their + property names; +* an empty subframe ``{}`` is added per property that embeds an object. + +Reference-valued and literal properties need no subframe: a referenced IRI with no local triples +stays ``{"id": ...}`` and literals compact directly. + +Use with ``jsonld.frame(rdf, frame, {"omitDefault": True})`` so a property absent from a given +instance is omitted rather than emitted as null. +""" + +from __future__ import annotations + +from typing import Any + +from .pattern_lint import context_terms + +#: Distinguishes "no context reference given" from an explicit ``None``, which is a meaningful +#: JSON-LD context value. +_UNSET = object() + + +def is_embed(node: Any) -> bool: + """True when a property's schema describes an embedded *object* value. + + A ``$ref`` alone is not enough: it may point at a scalar DataType leaf, which is a literal + rather than an embed. After dereferencing, a real embed is inlined as an object with its own + properties anyway, so the object shape is the reliable signal. + """ + if not isinstance(node, dict): + return False + if node.get("type") == "object" and node.get("properties"): + return True + if node.get("items") is not None: + return is_embed(node["items"]) + for keyword in ("anyOf", "oneOf", "allOf"): + branches = node.get(keyword) + if isinstance(branches, list) and any(is_embed(branch) for branch in branches): + return True + return False + + +def collect_composed_properties(node: Any, out: dict[str, Any] | None = None) -> dict[str, Any]: + """Every property a schema declares, including those inherited through ``allOf``. + + A dereferenced subclass chain inlines each superclass as an ``allOf`` entry, so a schema's + own ``properties`` map is only part of the picture. First declaration wins, matching the + reference implementation. + """ + if out is None: + out = {} + if not isinstance(node, dict): + return out + for name, value in (node.get("properties") or {}).items(): + if name not in out: + out[name] = value + for sub in node.get("allOf") or []: + collect_composed_properties(sub, out) + return out + + +def embedded_properties(schema: dict[str, Any]) -> list[str]: + """Properties that embed an object, by schema shape or by a scoped ``@context``. + + Shape is the primary signal; a scoped context is a strong hint but not mandatory, since an + embed can also be mapped by the ambient top-level context. + """ + properties = collect_composed_properties(schema) + structural = [name for name, prop in properties.items() if is_embed(prop)] + terms = context_terms(schema.get("@context")) + scoped = [term for term, definition in terms.items() if "@context" in definition] + # dict.fromkeys dedupes while preserving order, matching the JS Set spread. + return list(dict.fromkeys([*structural, *scoped])) + + +def instance_rdf_types(schema: Any) -> list[str] | None: + """The instance ``rdf:type``(s) a schema declares, most-derived-wins. + + Composition is override rather than merge, consistent with ``@context``: the nearest + declaration in the chain is authoritative, and superclass types stay recoverable by ontology + inference rather than being materialised. A subclass that wants supertypes in the data lists + them explicitly. After dereferencing the most-derived value sits at the top level; the + ``allOf`` walk is the fallback for a subclass that omits its own declaration. + """ + if not isinstance(schema, dict): + return None + own = schema.get("x-oold-instance-rdf-type") + if isinstance(own, list) and own: + return own + if isinstance(schema.get("allOf"), list): + for sub in schema["allOf"]: + types = instance_rdf_types(sub) + if types: + return types + return None + + +def schema_to_frame(schema: dict[str, Any], context_ref: Any = _UNSET) -> dict[str, Any]: + """Derive the minimal frame for reconstructing this schema's instances. + + ``context_ref``, when given, is used as the frame's ``@context`` in place of the schema's + inline one. Pass the schema's URL so the document loader resolves inherited and scoped + contexts rather than losing them. + """ + frame: dict[str, Any] = {"@embed": "@once"} + frame["@context"] = schema.get("@context") if context_ref is _UNSET else context_ref + types = instance_rdf_types(schema) + if types: + frame["@type"] = types[0] if len(types) == 1 else types + for name in embedded_properties(schema): + frame[name] = {} + return frame diff --git a/src/oold/validation/generate.py b/src/oold/validation/generate.py new file mode 100644 index 0000000..276cca5 --- /dev/null +++ b/src/oold/validation/generate.py @@ -0,0 +1,395 @@ +"""Deterministic instance generation. + +The reference harness generates instances with json-schema-faker configured as +``{alwaysFakeOptionals: true, useExamplesValue: true, useDefaultValue: true, maxItems: 1, +maxLength: 40}`` (``validate.mjs`` lines 112-120). ``alwaysFakeOptionals`` is the important part: +it means every property is populated, not a random subset. So the reference is already +generating a *maximal* instance, and this module reproduces that deterministically instead of +sampling. + +Determinism is a real gain rather than a compromise. The generated instance is what the +satisfiability and round-trip checks run on, and a randomised one turns a round-trip bug into a +flaky CI failure that reproduces only sometimes. + +Two things the generator must get right, both learned from the reference implementation: + +* a cut node (:data:`~oold.validation.resolve.CUT_SCHEMA`) must produce a *string*. At a typeless + node a random generator is free to emit a boolean or a number, and a non-string under an + ``@type: "@id"`` term becomes an RDF literal that cannot compact back, which reads as a false + round-trip loss. +* generated ``id`` values must be unique. The reference's faker draws URLs from a small pool, so + two ``id`` values in one document can collide, and in RDF the same IRI is the same node: a + colliding embed merges into its parent and the round-trip then faithfully reports the merged + graph, which reads as a false schema failure. See :func:`uniquify_ids`. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass, field +from typing import Any + +from .formats import FORMAT_SAMPLES +from .resolve import CUT_FORMAT + +#: Matches the reference faker's ``maxLength``/``maxItems`` settings. +MAX_LENGTH = 40 +MAX_ITEMS = 1 + +#: How many oneOf/anyOf branches to enumerate per schema. A large schema can have hundreds, and +#: each one means a full schema copy, so the reference caps this and notes when it does. +MAX_VARIANTS = 50 + +#: Guard against a schema that is deep but not cyclic. ``bound_schema`` has already cut cycles, +#: so this only ever bites on genuinely deep nesting. +MAX_DEPTH = 40 + +_DEFAULT_STRING = "example" + + +@dataclass +class GenerationResult: + """One generated instance, plus anything worth reporting about how it was built.""" + + instance: Any = None + notes: list[str] = field(default_factory=list) + error: str | None = None + + @property + def ok(self) -> bool: + return self.error is None + + def to_dict(self, include_documents: bool = False) -> dict[str, Any]: + payload: dict[str, Any] = {"ok": self.ok, "notes": self.notes} + if self.error: + payload["error"] = self.error + if include_documents: + payload["instance"] = self.instance + return payload + + +@dataclass +class Variant: + """One ``oneOf``/``anyOf`` branch, pinned so it is the only reachable alternative.""" + + label: str + schema: dict[str, Any] + + +class _Counter: + """Per-run counters, so distinct cut nodes and ids do not collapse onto one RDF node.""" + + def __init__(self) -> None: + self.cut = 0 + self.identifier = 0 + #: Object identities of generated dicts whose ``id`` came from the schema author + #: (``const``/``enum``/``default``/``examples``) rather than from this generator. + #: Those must not be rewritten. Held by identity, which is safe because every entry is + #: reachable from the instance being built and so stays alive. + self.pinned_ids: set[int] = set() + + def next_cut(self) -> str: + value = f"https://oo-ld.test/cut/{self.cut}" + self.cut += 1 + return value + + def next_id(self) -> str: + # A distinct authority from the document base, or compaction relativises the id against + # the base and breaks strict `format: iri` schemas. + value = f"https://instances.example.org/id/{self.identifier}" + self.identifier += 1 + return value + + +# ---------------------------------------------------------------------------- composition + + +def _merge_all_of(node: dict[str, Any]) -> dict[str, Any]: + """Flatten ``allOf`` into one effective schema. + + OO-LD models inheritance as ``allOf: [{"$ref": "Parent.schema.json"}]``, so after + dereferencing a subclass's inherited properties live inside ``allOf`` rather than at the top + level. Generating without flattening would miss most of the schema. + """ + if not isinstance(node.get("allOf"), list): + return node + + merged: dict[str, Any] = {k: v for k, v in node.items() if k != "allOf"} + properties: dict[str, Any] = dict(merged.get("properties") or {}) + required: list[str] = list(merged.get("required") or []) + + for branch in node["allOf"]: + if not isinstance(branch, dict): + continue + flattened = _merge_all_of(branch) + for key, value in flattened.items(): + if key == "properties" and isinstance(value, dict): + for name, sub in value.items(): + properties.setdefault(name, sub) + elif key == "required" and isinstance(value, list): + required.extend(r for r in value if r not in required) + else: + merged.setdefault(key, value) + + if properties: + merged["properties"] = properties + if required: + merged["required"] = required + return merged + + +def _effective(node: dict[str, Any]) -> dict[str, Any]: + """Resolve composition down to a single schema: ``allOf`` merged, first branch pinned.""" + merged = _merge_all_of(node) + for keyword in ("oneOf", "anyOf"): + branches = merged.get(keyword) + if isinstance(branches, list) and branches: + chosen = branches[0] if isinstance(branches[0], dict) else {} + rest = {k: v for k, v in merged.items() if k not in ("oneOf", "anyOf")} + combined = dict(rest) + for key, value in chosen.items(): + combined[key] = value + if "properties" in rest and "properties" in chosen: + combined["properties"] = {**rest["properties"], **chosen["properties"]} + return _effective(combined) + return merged + + +# ---------------------------------------------------------------------------- scalars + + +def _string_value(node: dict[str, Any], counter: _Counter) -> str: + fmt = node.get("format") + if fmt == CUT_FORMAT: + return counter.next_cut() + if isinstance(fmt, str) and fmt in FORMAT_SAMPLES: + return FORMAT_SAMPLES[fmt] + + value = _DEFAULT_STRING + minimum = node.get("minLength") + if isinstance(minimum, int) and minimum > len(value): + value += "x" * (minimum - len(value)) + maximum = node.get("maxLength") + limit = MAX_LENGTH if not isinstance(maximum, int) else min(maximum, MAX_LENGTH) + if len(value) > limit: + value = value[:limit] + return value + + +def _number_value(node: dict[str, Any], integer: bool) -> Any: + value: Any = 0 + if "minimum" in node and isinstance(node["minimum"], (int, float)): + value = node["minimum"] + elif "exclusiveMinimum" in node and isinstance(node["exclusiveMinimum"], (int, float)): + value = node["exclusiveMinimum"] + 1 + + maximum = node.get("maximum") + if isinstance(maximum, (int, float)) and value > maximum: + value = maximum + exclusive_max = node.get("exclusiveMaximum") + if isinstance(exclusive_max, (int, float)) and value >= exclusive_max: + value = exclusive_max - 1 + + multiple = node.get("multipleOf") + if isinstance(multiple, (int, float)) and multiple > 0: + steps = -(-value // multiple) if value > 0 else 0 + value = steps * multiple + + return int(value) if integer else float(value) + + +def _infer_type(node: dict[str, Any]) -> str: + declared = node.get("type") + if isinstance(declared, list): + declared = next((t for t in declared if isinstance(t, str)), None) + if isinstance(declared, str): + return declared + if "properties" in node or "required" in node: + return "object" + if "items" in node or "prefixItems" in node: + return "array" + # A node carrying only a `format` is a string, which is what makes the cut marker render as + # one. A node carrying nothing at all accepts anything, and a string is the safest choice: + # it round-trips under any term, as an IRI reference under @id or a literal under a plain + # term, whereas a boolean or number under an @id-coerced term cannot compact back. + return "string" + + +# ---------------------------------------------------------------------------- generation + + +def _generate(node: Any, counter: _Counter, depth: int) -> Any: + if node is True or node == {}: + return _DEFAULT_STRING + if node is False or not isinstance(node, dict): + return None + if depth > MAX_DEPTH: + return None + + node = _effective(node) + + # Author-provided values win, in the order json-schema-faker applies them. + if "const" in node: + return copy.deepcopy(node["const"]) + if "default" in node: + return copy.deepcopy(node["default"]) + examples = node.get("examples") + if isinstance(examples, list) and examples: + return copy.deepcopy(examples[0]) + enum = node.get("enum") + if isinstance(enum, list) and enum: + return copy.deepcopy(enum[0]) + + kind = _infer_type(node) + + if kind == "object": + out: dict[str, Any] = {} + for name, sub in (node.get("properties") or {}).items(): + value = _generate(sub, counter, depth + 1) + if value is not None or _allows_null(sub): + out[name] = value + if _is_authored(node.get("properties", {}).get("id")): + counter.pinned_ids.add(id(out)) + return out + + if kind == "array": + items = node.get("items") + prefix = node.get("prefixItems") + values: list[Any] = [] + if isinstance(prefix, list): + values.extend(_generate(entry, counter, depth + 1) for entry in prefix) + min_items = node.get("minItems") if isinstance(node.get("minItems"), int) else 0 + max_items = node.get("maxItems") if isinstance(node.get("maxItems"), int) else MAX_ITEMS + wanted = max(min_items, min(MAX_ITEMS, max_items)) + if items is not None: + while len(values) < wanted: + values.append(_generate(items, counter, depth + 1)) + return values[:max_items] if isinstance(node.get("maxItems"), int) else values + + if kind == "boolean": + return True + if kind == "null": + return None + if kind in ("number", "integer"): + return _number_value(node, integer=kind == "integer") + return _string_value(node, counter) + + +def _allows_null(sub: Any) -> bool: + if not isinstance(sub, dict): + return False + declared = sub.get("type") + return declared == "null" or (isinstance(declared, list) and "null" in declared) + + +def _is_authored(subschema: Any) -> bool: + """True when a subschema pins its value, so the generator did not choose it. + + Mirrors the precedence in :func:`_generate`: ``const`` and ``default`` count even when the + pinned value is falsy, while ``examples``/``enum`` need a non-empty list to apply. + """ + if not isinstance(subschema, dict): + return False + if "const" in subschema or "default" in subschema: + return True + return bool(subschema.get("examples")) or bool(subschema.get("enum")) + + +def uniquify_ids(value: Any, counter: _Counter) -> Any: + """Give every generated ``id`` a distinct value. + + Port of ``uniquifyIds`` (``validate.mjs`` lines 422-432). Colliding ``id`` values are not a + cosmetic problem: in RDF the same IRI is the same node, so a colliding embed merges into its + parent and the round-trip then faithfully reports the merged graph, which reads as a false + schema failure. + + One deliberate refinement over the reference, which rewrites unconditionally: an ``id`` the + schema pinned itself (``const``, ``enum``, ``default``, ``examples``) is left alone. + Overwriting it would make the generated instance violate its own schema and report a + satisfiability failure that says nothing about the schema. + """ + if isinstance(value, list): + for item in value: + uniquify_ids(item, counter) + elif isinstance(value, dict): + if isinstance(value.get("id"), str) and id(value) not in counter.pinned_ids: + value["id"] = counter.next_id() + for item in value.values(): + uniquify_ids(item, counter) + return value + + +def generate(schema: dict[str, Any], unique_ids: bool = True) -> GenerationResult: + """Generate one maximal instance from a dereferenced, bounded schema.""" + result = GenerationResult() + counter = _Counter() + try: + instance = _generate(schema, counter, 0) + except RecursionError: + result.error = "generation recursed too deeply; the schema may not be fully bounded" + return result + except Exception as exc: + result.error = f"generation failed: {type(exc).__name__}: {exc}" + return result + + if unique_ids: + uniquify_ids(instance, counter) + result.instance = instance + if counter.cut: + result.notes.append( + f"{counter.cut} cut node(s) were populated with a placeholder IRI; the schema has " + "cycles or exceeds the depth budget" + ) + return result + + +# ---------------------------------------------------------------------------- variants + +_SUB_DICT = ("properties", "$defs", "definitions", "patternProperties") +_SUB_VAL = ("items", "additionalProperties", "not", "if", "then", "else", "contains", "propertyNames") +_SUB_LIST = ("allOf", "oneOf", "anyOf", "prefixItems") + + +def collect_variants(schema: dict[str, Any], limit: int = MAX_VARIANTS) -> tuple[list[Variant], int]: + """Enumerate one schema variant per ``oneOf``/``anyOf`` branch. + + Port of ``collectVariants`` (``validate.mjs`` lines 329-356). Each variant pins one branch by + replacing the alternatives with a single-element list, which is how the reference gets + deterministic per-branch coverage out of a random generator. Here generation is already + deterministic, but pinning is still what makes the *other* branches reachable at all: without + it only branch 0 is ever exercised. + + Returns the variants (capped at ``limit``) and the total number found, so a caller can report + that it truncated. + """ + variants: list[Variant] = [] + + def walk(node: Any, path: list[Any]) -> None: + if not isinstance(node, dict): + return + for keyword in ("oneOf", "anyOf"): + branches = node.get(keyword) + if isinstance(branches, list) and len(branches) > 1: + for index in range(len(branches)): + clone = copy.deepcopy(schema) + target = clone + for key in path: + target = target[key] + target[keyword] = [copy.deepcopy(branches[index])] + label = "/".join(str(p) for p in path) or "" + variants.append(Variant(label=f"{label}/{keyword}[{index}]", schema=clone)) + + for key, value in node.items(): + if not isinstance(value, (dict, list)): + continue + if key in _SUB_DICT and isinstance(value, dict): + for name, sub in value.items(): + walk(sub, [*path, key, name]) + elif key in _SUB_VAL and isinstance(value, dict): + walk(value, [*path, key]) + elif key in _SUB_LIST and isinstance(value, list): + for index, sub in enumerate(value): + walk(sub, [*path, key, index]) + + walk(schema, []) + return variants[:limit], len(variants) diff --git a/src/oold/validation/instance_checks.py b/src/oold/validation/instance_checks.py new file mode 100644 index 0000000..88bb74a --- /dev/null +++ b/src/oold/validation/instance_checks.py @@ -0,0 +1,134 @@ +"""Checks on a committed instance document. + +Ports two sections of the reference harness: + +* ``validate.mjs`` lines 408-416 - the instance validates against its schema, with ``format`` + asserted rather than annotated; +* ``validate.mjs`` lines 476-503 - the instance survives ``instance -> RDF -> instance`` + unchanged. + +An instance names its schema with ``$schema``, resolved relative to the instance's own location, +so a directory of instances validates with no further configuration. The round-trip here uses +the full :func:`~oold.validation.roundtrip.canonical` comparison rather than the keys-only one: +for a document someone actually wrote, the values matter too, not just that the keys survived. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from jsonschema import Draft202012Validator +from pyld import jsonld + +from .formats import OOLD_FORMAT_CHECKER +from .frame import embedded_properties, schema_to_frame +from .loader import DocumentLoader, describe_jsonld_error +from .roundtrip import canonical, json_equal +from .schema_checks import format_error + + +@dataclass +class InstanceCheckResult: + """Structural validation of one instance against its schema.""" + + valid: bool = True + errors: list[str] = field(default_factory=list) + schema_ref: str | None = None + + def to_dict(self) -> dict[str, Any]: + return {"valid": self.valid, "errors": self.errors, "schema_ref": self.schema_ref} + + +@dataclass +class InstanceRoundtripResult: + """One committed instance through RDF and back, compared in full.""" + + ok: bool = False + lossless: bool = False + triples: int = 0 + method: str = "" + error: str | None = None + restored: Any = None + original_canonical: Any = None + restored_canonical: Any = None + + def to_dict(self, include_documents: bool = False) -> dict[str, Any]: + payload: dict[str, Any] = { + "ok": self.ok, + "lossless": self.lossless, + "triples": self.triples, + "method": self.method, + } + if self.error: + payload["error"] = self.error + if include_documents: + payload["restored"] = self.restored + payload["original_canonical"] = self.original_canonical + payload["restored_canonical"] = self.restored_canonical + return payload + + +def validate_instance(instance: Any, schema: dict[str, Any]) -> InstanceCheckResult: + """Validate an instance against its dereferenced, bounded schema. + + The whole document is validated, ``@context`` and ``$schema`` included. OO-LD schemas allow + additional properties, so those keys pass as unconstrained extras, exactly as they do under + the reference harness. + """ + result = InstanceCheckResult(schema_ref=instance.get("$schema") if isinstance(instance, dict) else None) + try: + validator = Draft202012Validator(schema, format_checker=OOLD_FORMAT_CHECKER) + errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.absolute_path)) + except Exception as exc: + result.valid = False + result.errors = [f"could not validate against the schema: {type(exc).__name__}: {exc}"] + return result + + result.errors = [format_error(error) for error in errors] + result.valid = not result.errors + return result + + +def roundtrip_instance( + instance: dict[str, Any], + schema: dict[str, Any], + loader: DocumentLoader, + instance_url: str, + schema_url: str, +) -> InstanceRoundtripResult: + """Project a committed instance to RDF and reconstruct it. + + The instance is used as written: it carries its own ``@context``, so nothing is injected. + Reconstruction uses the schema-derived frame when the schema embeds objects, and plain + compaction otherwise, because compaction alone never re-nests a flattened graph. + """ + result = InstanceRoundtripResult() + try: + nquads = jsonld.to_rdf(instance, loader.options(base=instance_url, format="application/n-quads")) + result.triples = sum(1 for line in nquads.split("\n") if line.strip()) + if not result.triples: + result.error = "produced no triples" + return result + + back = jsonld.from_rdf(nquads, {"format": "application/n-quads", "useNativeTypes": True}) + + if embedded_properties(schema): + result.method = "framed" + result.restored = jsonld.frame( + back, + schema_to_frame(schema, schema_url), + loader.options(base=instance_url, omitDefault=True), + ) + else: + result.method = "compacted" + result.restored = jsonld.compact(back, schema_url, loader.options(base=instance_url)) + except Exception as exc: + result.error = describe_jsonld_error(exc) + return result + + result.original_canonical = canonical(instance) + result.restored_canonical = canonical(result.restored) + result.lossless = json_equal(result.original_canonical, result.restored_canonical) + result.ok = result.lossless + return result diff --git a/src/oold/validation/loader.py b/src/oold/validation/loader.py new file mode 100644 index 0000000..0167f03 --- /dev/null +++ b/src/oold/validation/loader.py @@ -0,0 +1,165 @@ +"""A JSON-LD document loader backed by the schema resolver. + +The reference harness installs a loader that maps ``https://oo-ld.test/examples/`` onto +the directory under test and refuses every other fetch (``validate.mjs`` lines 236-243). That +makes the run deterministic, but it also means a schema whose ``@context`` chain leaves the +directory cannot be processed at all. + +This loader keeps the synthetic base mapping - it is what makes relative ``@context`` entries +resolve on disk - and additionally serves ``http(s)`` and ``file:`` references through +:class:`~oold.validation.resolve.Resolver`, so they are cached rather than refetched. Passing +``offline=True`` on the resolver restores the reference behaviour exactly: local files and warm +cache only, with the network refused. + +The loader is passed per call via pyld's ``documentLoader`` option rather than installed with +``jsonld.set_document_loader``, so validating two directories in one process (or two MCP tool +calls in one session) cannot leak one run's mapping into another. + +An OO-LD schema doubles as a remote context: JSON-LD 1.1 remote-context retrieval uses the +``@context`` member of the fetched document, which is exactly what makes ``"@context": +"Thing.schema.json"`` work. +""" + +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlsplit + +from pyld import jsonld + +from .resolve import Resolver, SchemaResolutionError + +#: Synthetic host standing in for the filesystem. The reference harness uses +#: ``https://oo-ld.test/examples/`` as its base; mounting the *parent* of the directory under +#: test at the host root reproduces that exactly whenever the directory is called ``examples``, +#: while also giving ``../`` references somewhere to land. +DEFAULT_HOST = "https://oo-ld.test/" + +#: Used when no directory is under test, so ``url_for`` still returns something well-formed. +DEFAULT_BASE = DEFAULT_HOST + "examples/" + + +class DocumentLoader: + """Resolves JSON-LD document URLs for one validation run. + + The synthetic host is mounted on the filesystem: its root is the parent of the directory + under test, and the directory itself sits one segment down. That mapping is what lets a + ``@context`` reference climb out of the directory - ``"../Thing.schema.json"`` resolves to + ``https://oo-ld.test/Thing.schema.json`` and lands on the sibling file - which the reference + harness cannot do, since it only maps names directly under its base. + + Resolved paths are confined to the mounted root, so a crafted ``../../`` reference cannot + read arbitrary files. + """ + + def __init__( + self, + resolver: Resolver, + directory: Path | None = None, + host: str = DEFAULT_HOST, + root: Path | None = None, + ) -> None: + self.resolver = resolver + self.directory = Path(directory).resolve() if directory else None + self.host = host if host.endswith("/") else host + "/" + if self.directory is not None: + self.root = Path(root).resolve() if root else self.directory.parent + self.base_url = f"{self.host}{self.directory.name}/" + else: + self.root = None + self.base_url = DEFAULT_BASE + #: URLs this loader was asked for, in order. Useful in tests and ``--verbose``. + self.requested: list[str] = [] + + # pyld calls the loader as loader(url, options). + def __call__(self, url: str, options: Any = None) -> dict[str, Any]: + self.requested.append(url) + document = self._load(url) + # Hand pyld a private copy. It rewrites a retrieved context's relative references to + # absolute *in place*, so returning the resolver's cached object would rewrite + # `"Thing.schema.json"` to `"https://oo-ld.test/examples/Thing.schema.json"` inside the + # cache. Every later consumer of that document - context resolution, the pattern lint - + # would then see a synthetic URL it cannot resolve, and the failure would surface far + # from its cause and only when checks run in a particular order. + return {"contextUrl": None, "documentUrl": url, "document": deepcopy(document)} + + def _load(self, url: str) -> Any: + if self.root is not None and url.startswith(self.host): + target = self._map_to_disk(url) + try: + return self.resolver.fetch(target.as_uri()) + except SchemaResolutionError as exc: + raise _loader_error(str(exc), url) from exc + + try: + return self.resolver.fetch(url) + except SchemaResolutionError as exc: + raise _loader_error(str(exc), url) from exc + + def _map_to_disk(self, url: str) -> Path: + relative = unquote(urlsplit(url).path).lstrip("/") + target = (self.root / relative).resolve() + try: + target.relative_to(self.root) + except ValueError as exc: + raise _loader_error(f"refusing to read {relative!r}: it escapes {self.root}", url) from exc + if not target.is_file(): + raise _loader_error(f"no such document under {self.root}: {relative}", url) + return target + + def url_for(self, filename: str) -> str: + """The synthetic URL a file in the directory under test is addressed by.""" + return f"{self.base_url}{filename}" + + def options(self, **extra: Any) -> dict[str, Any]: + """pyld options carrying this loader, plus whatever the caller adds.""" + return {"documentLoader": self, **extra} + + +def _loader_error(message: str, url: str) -> jsonld.JsonLdError: + """Raise in the shape pyld expects, so failures surface as JSON-LD errors.""" + return jsonld.JsonLdError( + message, + "jsonld.LoadDocumentError", + {"url": url}, + code="loading document failed", + ) + + +def _message_of(exc: BaseException) -> str: + """The first line of an exception's message. + + ``JsonLdError.__str__`` renders ``str(self.args)``, so a plain ``str()`` yields a tuple + repr with a stray parenthesis, plus several trailing metadata lines. Reading ``args[0]`` + directly avoids both. + """ + args = getattr(exc, "args", ()) + text = args[0] if args and isinstance(args[0], str) else str(exc) + return text.strip().splitlines()[0] + + +def describe_jsonld_error(exc: BaseException) -> str: + """Render a pyld error usefully, surfacing the root cause rather than the wrapper. + + pyld replaces a loader failure with a generic "Dereferencing a URL did not result in a + valid JSON-LD object" and lists four possible causes, none of which is the actual one. The + real reason - an offline refusal, a missing file - survives only on the exception chain, so + that is what gets reported. Without this, an offline run's most common failure reads as an + unexplained JSON-LD error. + """ + chain: list[BaseException] = [] + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen and len(chain) < 8: + seen.add(id(current)) + chain.append(current) + current = current.__cause__ or current.__context__ + + root = chain[-1] + message = _message_of(root) + if root is not exc: + code = getattr(exc, "code", None) or getattr(exc, "type", None) + return f"{message} [{code}]" if code else message + return message if isinstance(exc, jsonld.JsonLdError) else f"{type(exc).__name__}: {message}" diff --git a/src/oold/validation/mcp_server.py b/src/oold/validation/mcp_server.py new file mode 100644 index 0000000..c3dfdc2 --- /dev/null +++ b/src/oold/validation/mcp_server.py @@ -0,0 +1,270 @@ +"""MCP server exposing OO-LD validation as tools. + +A thin wrapper: every tool delegates to the same pipeline the CLI uses, so there is no second +copy of the validation logic to keep in sync. + +Two conventions hold throughout. + +* Errors come back as data, never as exceptions. A caller asking about a broken schema wants the + report explaining why it is broken, which is precisely the case where raising would destroy + the answer. +* ``verbosity`` is ``"summary"`` by default. ``"full"`` adds per-check detail, generated + instances and reconstructed documents, which is verbose enough to be worth opting into. + +Run with ``python -m oold.validation.mcp_server``; transport is stdio. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from typing import Any, Literal + +try: + # mcp 2.x + from mcp.server.mcpserver import MCPServer as _Server +except ImportError: # pragma: no cover - depends on the installed mcp major version + # mcp 1.x, where the same class is called FastMCP. The parts used here - the `tool` + # decorator and `run(transport=...)` - are identical across both. + from mcp.server.fastmcp import FastMCP as _Server # ty: ignore[unresolved-import] + +from .generate import generate +from .meta_store import MetaSchemaError, describe_store +from .pipeline import Options, run_compliance, validate_directory, validate_instance, validate_schema +from .predicates import check_predicates +from .report import Report, failure_reasons +from .resolve import Resolver, SchemaResolutionError, bound_schema + +mcp = _Server("oold-validation") + +Verbosity = Literal["summary", "full"] + + +def _options(meta: list[str] | None, offline: bool) -> Options: + return Options(meta=tuple(meta) if meta else ("latest",), offline=offline) + + +def _payload(report: Report, verbosity: Verbosity) -> dict[str, Any]: + payload = report.to_dict(verbosity) + payload["problems"] = failure_reasons(report) + return payload + + +def _materialise(source: str, suffix: str) -> tuple[Path, tempfile.TemporaryDirectory | None]: + """Turn a path or a raw JSON string into a file on disk. + + The checks are directory-relative by nature: a schema's ``@context`` and ``$ref`` entries are + usually relative siblings. A schema passed as raw JSON therefore has to be written somewhere + before it can be validated, and it will only resolve if it has no relative references. + """ + text = source.strip() + if not text.startswith("{"): + return Path(source), None + + holder = tempfile.TemporaryDirectory(prefix="oold-validation-") + name = "Inline" + suffix + target = Path(holder.name) / name + target.write_text(text, encoding="utf-8") + return target, holder + + +@mcp.tool() +def validate_oold_schema( + schema: str, + meta: list[str] | None = None, + offline: bool = False, + verbosity: Verbosity = "summary", +) -> dict[str, Any]: + """Run the full OO-LD pipeline over one schema. + + Checks the schema against the OO-LD meta-schema, resolves its $ref composition, lints its + @context for round-trip-safe patterns, generates an instance and confirms it validates, then + round-trips that instance through RDF to prove no property is silently lost. + + Args: + schema: Path to a *.schema.json file, or the schema itself as a JSON string. A path is + strongly preferred: relative @context and $ref entries only resolve on disk. + meta: Meta-schema versions, e.g. ["latest"], ["0.7.0"], ["remote"], ["all"]. Several may + be given to validate against all of them at once. + offline: Never fetch over the network; use local files and the cache only. + verbosity: "full" adds per-check detail and generated documents. + + The fields that matter most are `summary` (counts and the verdict) and `problems`, which + lists each failure in readable form. + """ + path, holder = _materialise(schema, ".schema.json") + try: + return _payload(validate_schema(path, _options(meta, offline)), verbosity) + except MetaSchemaError as exc: + return {"passed": False, "fatal_error": str(exc)} + finally: + if holder: + holder.cleanup() + + +@mcp.tool() +def validate_oold_instance( + instance: str, + schema: str | None = None, + meta: list[str] | None = None, + offline: bool = False, + verbosity: Verbosity = "summary", +) -> dict[str, Any]: + """Check whether a specific document conforms to an OO-LD schema. + + Validates the instance structurally against its schema, with formats asserted, then projects + it to RDF and back to confirm the reconstruction is identical. This answers "does this + document conform", as opposed to "is this schema sound". + + Args: + instance: Path to the instance document. It names its schema with $schema. + schema: Optional path to a schema, overriding $schema. Must sit in the same directory as + the instance so relative @context references resolve. + meta: Meta-schema versions, as in validate_oold_schema. + offline: Never fetch over the network. + verbosity: "full" adds the canonical forms of both sides of the round-trip. + """ + try: + report = validate_instance(Path(instance), Path(schema) if schema else None, _options(meta, offline)) + except MetaSchemaError as exc: + return {"passed": False, "fatal_error": str(exc)} + return _payload(report, verbosity) + + +@mcp.tool() +def validate_oold_directory( + directory: str, + meta: list[str] | None = None, + offline: bool = False, + verbosity: Verbosity = "summary", +) -> dict[str, Any]: + """Validate every *.schema.json and *.instance.json in a directory. + + The same general-workflow tier the reference harness exposes as `oold-validate `, so a + downstream repository can conformance-check its generated schemas. + + Args: + directory: The directory to validate. + meta: Meta-schema versions, as in validate_oold_schema. + offline: Never fetch over the network. + verbosity: "full" adds per-check detail. + """ + try: + report = validate_directory(Path(directory), _options(meta, offline)) + except MetaSchemaError as exc: + return {"passed": False, "fatal_error": str(exc)} + return _payload(report, verbosity) + + +@mcp.tool() +def run_oold_compliance( + directory: str, + meta: list[str] | None = None, + offline: bool = False, + verbosity: Verbosity = "summary", +) -> dict[str, Any]: + """Run a deterministic compliance suite and the vocabulary-coverage cross-check. + + Args: + directory: Directory of fixture files, for example oold-schema's examples/compliance. + meta: Meta-schema versions, as in validate_oold_schema. + offline: Never fetch over the network. + verbosity: "full" lists every case rather than only the failures. + """ + try: + report = run_compliance(Path(directory), _options(meta, offline)) + except MetaSchemaError as exc: + return {"passed": False, "fatal_error": str(exc)} + return _payload(report, verbosity) + + +@mcp.tool() +def generate_oold_instance(schema: str, offline: bool = False) -> dict[str, Any]: + """Generate a deterministic example instance from a schema. + + Every declared property is populated, including those inherited through allOf, and declared + formats are respected. Useful on its own for seeing what a schema actually covers. + + Args: + schema: Path to a *.schema.json file, or the schema itself as a JSON string. + offline: Never fetch over the network while resolving $refs. + """ + path, holder = _materialise(schema, ".schema.json") + try: + resolver = Resolver(offline=offline) + loaded = resolver.load(path) + deref = resolver.dereference(loaded) + bounded = bound_schema(deref.schema) + bounded.pop("$schema", None) + result = generate(bounded) + except SchemaResolutionError as exc: + return {"ok": False, "instance": None, "error": str(exc)} + finally: + if holder: + holder.cleanup() + + return { + "ok": result.ok, + "instance": result.instance, + "notes": result.notes, + "error": result.error, + "unresolved_refs": deref.unresolved, + } + + +@mcp.tool() +def check_context_mapping(document: str, context: str | None = None) -> dict[str, Any]: + """Report which properties of a JSON-LD document carry meaning, and which do not. + + No schema and no generation involved. Two failure modes are distinguished: a property that + expands to nothing (`dropped`, it has no @context term) and one that expands to a + non-absolute IRI (`suspicious`, its prefix is probably undefined). The second is the + dangerous one, because the document looks fine and round-trips cleanly while pointing at a + meaningless predicate. + + Args: + document: The JSON-LD document, as a JSON string or a path to one. + context: Optional context as a JSON string, overriding the document's own @context. + """ + text = document.strip() + if text.startswith("{"): + parsed = json.loads(text) + else: + try: + parsed = json.loads(Path(document).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return {"ok": False, "errors": [f"could not read the document: {exc}"]} + + active = parsed.get("@context") + if context is not None: + try: + active = json.loads(context) if context.strip().startswith(("{", "[")) else context + except json.JSONDecodeError as exc: + return {"ok": False, "errors": [f"context is not valid JSON: {exc}"]} + if active is None: + return {"ok": False, "errors": ["document has no @context and none was given"]} + + payload = {k: v for k, v in parsed.items() if k not in ("@context", "$schema")} + return check_predicates(payload, active).to_dict(include_documents=True) + + +@mcp.tool() +def list_meta_versions() -> dict[str, Any]: + """List the tracked meta-schema versions, which one is `latest`, and the remote cache state. + + Use this before choosing a `meta` argument for the validation tools. + """ + try: + return describe_store() + except MetaSchemaError as exc: + return {"error": str(exc)} + + +def main() -> None: + """Run the server over stdio.""" + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/src/oold/validation/meta/0.7.0/oold-meta-schema.json b/src/oold/validation/meta/0.7.0/oold-meta-schema.json new file mode 100644 index 0000000..0bf2530 --- /dev/null +++ b/src/oold/validation/meta/0.7.0/oold-meta-schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$dynamicAnchor": "meta", + "title": "OO-LD dialect meta-schema", + "$comment": "The $id uses the versioned hosting at oo-ld.github.io/oold-schema/ (the source keeps the /latest/ placeholder; each released copy is stamped per release). The OO-LD vocabulary is declared optional (false) so that generic JSON-Schema 2020-12 validators still process OO-LD schemas. The UI keyword definitions are included via the oold-ui-meta-schema #keywords anchor so a schema carrying x-oold-ui-* annotations validates in one pass.", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://oo-ld.github.io/oold-schema/latest/vocab/oold": false + }, + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + { "$ref": "https://oo-ld.github.io/oold-schema/latest/meta/oold-ui-meta-schema.json#keywords" } + ], + "properties": { + "@context": { + "description": "JSON-LD context for instances of this schema. The schema is consumed as a remote JSON-LD context; this entry is ignored by JSON-Schema validators." + }, + "x-oold-context": { + "description": "Extended term mappings (synonyms): an object keyed by term, each holding a dict keyed by synonym IRI whose value is a JSON-LD term-definition fragment plus an optional strippable x-sssom block. Supports more than two mappings per term, override under composition (most-derived-wins; null removes), prefix-driven ontology-family prioritization, and SSSOM round-trip. Promoted into @context by OO-LD-aware tooling; see the 'Term mappings and synonyms' section.", + "type": "object", + "examples": [ + { "name": { "skos:prefLabel": { "x-sssom": { "predicate_id": "skos:exactMatch", "confidence": 0.95 } } } } + ] + }, + "x-oold-uuid": { + "description": "Stable UUID identifying this schema across versions and locations.", + "type": "string", + "format": "uuid" + }, + "x-oold-version": { + "description": "Semantic version of this schema.", + "type": "string" + }, + "x-oold-prior-version": { + "description": "Identifier or version of the immediately preceding schema version.", + "type": "string" + }, + "x-oold-backward-compatible-with": { + "description": "URI of a prior schema version this schema is backward-compatible with.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-incompatible-with": { + "description": "URI of a prior schema version this schema is NOT compatible with.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-iri": { + "description": "Ontology IRI (or compact IRI) denoting the class described by this schema.", + "type": "string" + }, + "x-oold-instance-rdf-type": { + "description": "The rdf:type(s) carried by instances of this schema, as a list of IRIs (e.g. [\"schema:Person\"]). OO-LD tooling materializes these as @type when exporting an instance to JSON-LD / RDF.", + "type": "array", + "items": { "type": "string" } + }, + "x-oold-ref": { + "description": "Reference to another OO-LD schema. Use x-oold-ref (not the standard $ref) for references that appear inside OO-LD custom keywords such as x-oold-range: there a plain $ref would be eagerly - and, for cyclic schema graphs, dangerously - dereferenced by generic JSON-Schema bundlers (the behaviour is undefined per Core section 9.4.2). Keep using the standard $ref for ordinary schema composition (allOf, properties, $defs), which bundlers are expected to resolve. x-oold-ref is resolved only by OO-LD-aware tools, lazily and with cycle handling.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-range": { + "description": "Type constraint on the target of an IRI-valued property: an IRI string, an array of IRIs, or an OO-LD subschema (using x-oold-ref for references). See the 'Range of properties' section.", + "anyOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "$comment": "OO-LD subschema form; references inside it use x-oold-ref. The reverse-property keywords (x-oold-reverse-*) are intentionally not validated within a range subschema for now." + } + ] + }, + "x-oold-multilang-title": { + "description": "Language map of translated `title` values keyed by BCP-47 language code.", + "type": "object", + "additionalProperties": { "type": "string" } + }, + "x-oold-multilang-description": { + "description": "Language map of translated `description` values keyed by BCP-47 language code.", + "type": "object", + "additionalProperties": { "type": "string" } + }, + "x-oold-reverse-properties": { + "description": "Properties stored on the related object but editable from this side, mapped via JSON-LD @reverse.", + "type": "object" + }, + "x-oold-reverse-required": { + "description": "Names of reverse properties that are required.", + "type": "array", + "items": { "type": "string" } + }, + "x-oold-reverse-default-properties": { + "description": "Deprecated. Names of reverse properties shown by default in generated user interfaces. Like the object-level defaultProperties array this is extend-only under composition; prefer a per-reverse-property x-oold-ui-default-property boolean, which is overridable.", + "deprecated": true, + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/src/oold/validation/meta/0.7.0/oold-pattern-lint.schema.json b/src/oold/validation/meta/0.7.0/oold-pattern-lint.schema.json new file mode 100644 index 0000000..f6576a4 --- /dev/null +++ b/src/oold/validation/meta/0.7.0/oold-pattern-lint.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.github.io/oold-schema/latest/meta/oold-pattern-lint.schema.json", + "title": "OO-LD round-trip pattern lint", + "description": "SHOULD-level constraints on a schema's @context that keep instances round-trip-safe, checkable by JSON Schema alone. This is distinct from oold-meta-schema.json, which asserts MUST-level well-formedness. It currently enforces that no term coerces a literal to xsd:string: xsd:string is RDF's default datatype and is elided from plain literals, so a term declaring @type: xsd:string is never selected when the value is compacted back from RDF and the round-trip is lossy (see the specification, Property value forms). CURIEs are matched in their conventional xsd: form and as the full XSD IRI; a term that coerces to xsd:string through a non-standard prefix mapping is beyond what a single JSON Schema can resolve and is left to tooling.", + "type": "object", + "properties": { + "@context": { "$ref": "#/$defs/context" } + }, + "$defs": { + "context": { + "oneOf": [ + { "type": "null" }, + { "type": "string" }, + { "type": "array", "items": { "$ref": "#/$defs/context" } }, + { "$ref": "#/$defs/contextObject" } + ] + }, + "contextObject": { + "type": "object", + "patternProperties": { + "^@": true, + "^[^@]": { "$ref": "#/$defs/termValue" } + }, + "additionalProperties": { "$ref": "#/$defs/termValue" } + }, + "termValue": { + "oneOf": [ + { "type": "null" }, + { "type": "string" }, + { "$ref": "#/$defs/termDefinition" } + ] + }, + "termDefinition": { + "type": "object", + "properties": { + "@type": { "$ref": "#/$defs/notXsdString" }, + "@context": { "$ref": "#/$defs/context" } + } + }, + "notXsdString": { + "not": { + "enum": ["xsd:string", "http://www.w3.org/2001/XMLSchema#string"] + } + } + } +} diff --git a/src/oold/validation/meta/0.7.0/oold-ui-meta-schema.json b/src/oold/validation/meta/0.7.0/oold-ui-meta-schema.json new file mode 100644 index 0000000..6274045 --- /dev/null +++ b/src/oold/validation/meta/0.7.0/oold-ui-meta-schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.github.io/oold-schema/latest/meta/oold-ui-meta-schema.json", + "$dynamicAnchor": "meta", + "title": "OO-LD UI dialect meta-schema", + "$comment": "The $id uses the versioned hosting at oo-ld.github.io/oold-schema/ (the source keeps the /latest/ placeholder; each released copy is stamped per release). The oold-ui vocabulary is declared optional (false) so that generic JSON-Schema 2020-12 validators still process the schema. The x-oold-ui-* keyword definitions live in $defs.keywords (plain anchor #keywords) so the main OO-LD meta-schema can include just them, without re-introducing the 2020-12 reference or a second dynamic anchor. Each keyword carries a description and an example so the vocabulary can be rendered into documentation. As with the core dialect, this meta-schema only validates that the keywords are well-formed; the behaviour is supplied by OO-LD-aware form generators (for example jedison).", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://oo-ld.github.io/oold-schema/latest/vocab/oold-ui": false + }, + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + { "$ref": "#keywords" } + ], + "$defs": { + "keywords": { + "$anchor": "keywords", + "properties": { + "x-oold-ui-widget": { + "description": "Widget hint for a value whose intended widget is not a registered JSON Schema format (for example table, tabs, grid, autocomplete, textarea, checkbox, markdown, color). Registered formats (date, uri, uuid, ...) stay in `format`. Maps to jedison `x-format`.", + "type": "string", + "examples": ["table", "autocomplete", "markdown"] + }, + "x-oold-ui-property-order": { + "description": "Display order of this property within its object or group; lower sorts first. Maps to jedison `x-categoryOrder`.", + "type": "integer", + "examples": [1] + }, + "x-oold-ui-property-group": { + "description": "Name of the group, tab or category this property belongs to. Maps to jedison `x-category` / `x-propGroup`.", + "type": "string", + "examples": ["General", "Contact"] + }, + "x-oold-ui-form-hidden": { + "description": "Hide this property in the editing form. Maps to jedison `x-hidden`.", + "type": "boolean", + "examples": [true] + }, + "x-oold-ui-render-hidden": { + "description": "Hide this property in the rendered (read) view.", + "type": "boolean", + "examples": [true] + }, + "x-oold-ui-enum-titles": { + "description": "Human display labels for the default language, aligned positionally with `enum`: the Nth label is the title of the Nth enum value. For `enum: [\"pi\", \"postdoc\", \"phd\"]` the value `[\"Principal investigator\", \"Postdoc\", \"PhD student\"]` labels each option. Localize with `x-oold-multilang-ui-enum-titles`. Distinct from the identifier-safe code names in `x-enum-varnames`. Maps to jedison `x-enumTitles`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["Principal investigator", "Postdoc", "PhD student"]] + }, + "x-oold-multilang-ui-enum-titles": { + "description": "BCP-47 language map of `x-oold-ui-enum-titles` arrays (mirrors `x-oold-multilang-title`); each array aligns positionally with `enum`. For `enum: [\"pi\", \"postdoc\", \"phd\"]`: {\"en\": [\"Principal investigator\", \"Postdoc\", \"PhD student\"], \"de\": [\"Projektleitung\", \"Postdoc\", \"Doktorand\"]}.", + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "string" } }, + "examples": [{ "en": ["Principal investigator", "Postdoc", "PhD student"], "de": ["Projektleitung", "Postdoc", "Doktorand"] }] + }, + "x-oold-ui-hint": { + "description": "Short help text shown with the field, in the default language. Localize with `x-oold-multilang-ui-hint`. Maps to jedison `x-info`.", + "type": "string", + "examples": ["Full name"] + }, + "x-oold-multilang-ui-hint": { + "description": "BCP-47 language map of the `x-oold-ui-hint` text (mirrors `x-oold-multilang-title`).", + "type": "object", + "additionalProperties": { "type": "string" }, + "examples": [{ "en": "Full name", "de": "Vollständiger Name" }] + }, + "x-oold-ui-default-property": { + "description": "Whether this optional property is shown by default in generated user interfaces. Replaces the object-level `defaultProperties` array: a per-property boolean is overridable under composition (most-derived-wins), so a derived schema can set it false, whereas the merged array form was extend-only.", + "type": "boolean", + "examples": [true] + }, + "x-enum-varnames": { + "description": "Identifier-safe code names aligned positionally with `enum`, for code generation. For `enum: [\"m\", \"s\"]` the value `[\"metre\", \"second\"]` names each option (so a generator can emit `Unit.metre` instead of `Unit.m`). An established vendor extension (OpenAPI Generator; NSwag uses the camelCase `x-enumNames`). Kept as-is; distinct from the human labels in `x-oold-ui-enum-titles`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["metre", "second"]] + }, + "x-enum-descriptions": { + "description": "Per-value descriptions aligned positionally with `enum`, the established companion of `x-enum-varnames`. For `enum: [\"m\", \"s\"]`: `[\"SI base unit of length\", \"SI base unit of time\"]`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["SI base unit of length", "SI base unit of time"]] + } + } + } + } +} diff --git a/src/oold/validation/meta/README.md b/src/oold/validation/meta/README.md new file mode 100644 index 0000000..d8aa75e --- /dev/null +++ b/src/oold/validation/meta/README.md @@ -0,0 +1,46 @@ +# Meta-schema version history + +The OO-LD meta-schemas are owned by [oold-schema](https://github.com/OO-LD/oold-schema). This +folder holds a hand-curated copy of each **released** version so `oold` can validate offline and so +one schema can be checked against several meta-schema versions in a single run. + +``` +meta/ +├── index.json provenance: upstream tag, commit, checksums +├── 0.7.0/ oold-meta-schema.json, oold-pattern-lint.schema.json, oold-ui-meta-schema.json +└── / +``` + +Nothing here is written at runtime. `--meta remote` fetches the unreleased `main` state into the +user cache (`~/.cache/oold/meta/`, or `OOLD_CACHE_DIR`) and never touches this folder, so a released +version cannot change meaning behind your back. + +## Adding a version + +When oold-schema cuts a release, from a checkout of it: + +```bash +V=0.8.0 +mkdir -p src/oold/validation/meta/$V +for f in oold-meta-schema oold-pattern-lint.schema oold-ui-meta-schema; do + git -C ../oold-schema show v$V:meta/$f.json > src/oold/validation/meta/$V/$f.json +done +sha256sum src/oold/validation/meta/$V/*.json +git -C ../oold-schema rev-parse v$V +git -C ../oold-schema log -1 --format=%cI v$V +``` + +Extract from the **tag**, not from the working tree. The two diverge: at the time 0.7.0 was added, +`main` had already changed all three files, including the canonical `$id` domain. + +Then add an entry to `index.json` with the tag, commit, commit date, the `$id` base in use for that +release, and the checksums. Add `--meta $V` to a test run and confirm the suite still passes: +`uv run pytest tests/test_validation -q`. + +## Why `id_base` is recorded and not assumed + +The `$id` domain has already moved once, from +`https://oo-ld.github.io/oold-schema/latest/meta/` (0.7.0) to `https://oo-ld.org/latest/meta/` +(post-0.7.0). Released copies also stamp the version in place of `latest`. The registry therefore +resolves cross-document `$ref`s by file name rather than by any fixed URL, and `id_base` is +documentation rather than something the code depends on. diff --git a/src/oold/validation/meta/index.json b/src/oold/validation/meta/index.json new file mode 100644 index 0000000..00b70b7 --- /dev/null +++ b/src/oold/validation/meta/index.json @@ -0,0 +1,27 @@ +{ + "$comment": "Version history of the OO-LD meta-schemas, curated by hand. See README.md for the procedure. Each entry records where the files came from so provenance is visible without a network call. Unreleased upstream state is not tracked here; reach it with --meta remote.", + "source_repository": "https://github.com/OO-LD/oold-schema", + "remote": { + "ref": "refs/heads/main", + "base_url": "https://raw.githubusercontent.com/OO-LD/oold-schema/refs/heads/main/meta/" + }, + "files": [ + "oold-meta-schema.json", + "oold-pattern-lint.schema.json", + "oold-ui-meta-schema.json" + ], + "versions": { + "0.7.0": { + "tag": "v0.7.0", + "commit": "3fff1b98a8709292f53a4a00f85b4d4802cbc6d5", + "committed": "2026-07-20T05:58:53+02:00", + "added": "2026-07-30", + "id_base": "https://oo-ld.github.io/oold-schema/latest/meta/", + "sha256": { + "oold-meta-schema.json": "a93b002fe4648f82d3bb59ca0159baaa339360cfd5275ef550b7f97ae85ec8a6", + "oold-pattern-lint.schema.json": "1e10d3efee06e590c81757fdf273695ecef6f5128ade5d8dd391c6f28fed7c1b", + "oold-ui-meta-schema.json": "f584a8f535529369914a196af80adac7b937ffbb3fbb7c03d5ec0ef6d9d6cb7d" + } + } + } +} diff --git a/src/oold/validation/meta_store.py b/src/oold/validation/meta_store.py new file mode 100644 index 0000000..7d3e60c --- /dev/null +++ b/src/oold/validation/meta_store.py @@ -0,0 +1,355 @@ +"""The OO-LD meta-schema store: version history, remote fetch, and validator construction. + +The meta-schemas are owned by `oold-schema `_. This +package keeps a hand-curated copy of each released version under ``meta//`` (see +``meta/README.md``) so validation works offline, and so one schema can be checked against +several meta-schema versions in a single run. + +Selection is by name: + +========== =============================================================== +``latest`` the highest version in the tracked folder (the default) +``0.7.0`` that tracked version +``remote`` the unreleased ``main`` state, fetched into the user cache +``all`` every tracked version +========== =============================================================== + +Remote fetches land in the user cache and never touch the tracked folder, so ``--meta remote`` +cannot silently change what a released version means. +""" + +from __future__ import annotations + +import contextlib +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from functools import lru_cache +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator +from jsonschema.exceptions import SchemaError +from referencing import Registry, Resource +from referencing.jsonschema import DRAFT202012 + +from .formats import OOLD_FORMAT_CHECKER +from .resolve import SchemaResolutionError, default_cache_dir, http_get_json + +META_SCHEMA_FILE = "oold-meta-schema.json" +UI_META_SCHEMA_FILE = "oold-ui-meta-schema.json" +PATTERN_LINT_FILE = "oold-pattern-lint.schema.json" + +#: Selector for the unreleased upstream state. +REMOTE = "remote" +LATEST = "latest" +ALL = "all" + + +class MetaSchemaError(Exception): + """A meta-schema version could not be loaded.""" + + +def meta_dir() -> Path: + """The tracked version-history folder that ships inside the package.""" + return Path(__file__).parent / "meta" + + +def remote_cache_dir() -> Path: + """Where fetched (unreleased) meta-schemas are cached. Never the tracked folder.""" + return default_cache_dir() / "meta" / "remote-main" + + +@lru_cache(maxsize=1) +def load_index() -> dict[str, Any]: + """Read ``meta/index.json``, which records provenance for each tracked version.""" + path = meta_dir() / "index.json" + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise MetaSchemaError(f"meta-schema index missing: {path}") from exc + except json.JSONDecodeError as exc: + raise MetaSchemaError(f"meta-schema index is not valid JSON: {exc}") from exc + + +def meta_files() -> list[str]: + index = load_index() + files = index.get("files") + if not isinstance(files, list) or not files: + return [META_SCHEMA_FILE, PATTERN_LINT_FILE, UI_META_SCHEMA_FILE] + return list(files) + + +def remote_base_url() -> str: + remote = load_index().get("remote") or {} + base = remote.get("base_url") + if not base: + raise MetaSchemaError("meta/index.json declares no remote.base_url") + return str(base) + + +def _version_key(version: str) -> tuple: + """Sort key that orders 0.10.0 after 0.9.0 rather than before it.""" + parts: list[Any] = [] + for chunk in version.split("."): + parts.append((0, int(chunk)) if chunk.isdigit() else (1, chunk)) + return tuple(parts) + + +def tracked_versions() -> list[str]: + """Every version present in the tracked folder, oldest first.""" + root = meta_dir() + if not root.is_dir(): + return [] + found = [entry.name for entry in root.iterdir() if entry.is_dir() and (entry / META_SCHEMA_FILE).is_file()] + return sorted(found, key=_version_key) + + +def latest_version() -> str: + versions = tracked_versions() + if not versions: + raise MetaSchemaError(f"no meta-schema versions are tracked in {meta_dir()}; see its README.md") + return versions[-1] + + +# ---------------------------------------------------------------------------- bundle + + +@dataclass +class MetaBundle: + """The meta-schemas for one version, plus the registry that resolves between them.""" + + version: str + origin: str + documents: dict[str, Any] + registry: Registry = field(repr=False) + + @property + def meta(self) -> dict[str, Any]: + return self.documents[META_SCHEMA_FILE] + + @property + def ui_meta(self) -> dict[str, Any]: + return self.documents[UI_META_SCHEMA_FILE] + + @property + def pattern_lint(self) -> dict[str, Any]: + return self.documents[PATTERN_LINT_FILE] + + def validator(self, document: dict[str, Any]) -> Draft202012Validator: + """A validator for one of this bundle's schemas, with formats asserted.""" + return Draft202012Validator(document, registry=self.registry, format_checker=OOLD_FORMAT_CHECKER) + + def meta_validator(self) -> Draft202012Validator: + return self.validator(self.meta) + + def pattern_lint_validator(self) -> Draft202012Validator: + return self.validator(self.pattern_lint) + + def self_check(self) -> list[str]: + """Problems with the meta-schemas themselves, as data rather than exceptions. + + The reference harness gets this for free when ajv compiles the meta-schema + (``validate.mjs`` line 68). Here it is explicit, so a badly curated version folder is + reported as a failing check rather than crashing mid-run. + """ + problems: list[str] = [] + for name, document in sorted(self.documents.items()): + try: + Draft202012Validator.check_schema(document) + except SchemaError as exc: + problems.append(f"{name} is not a valid JSON Schema 2020-12 document: {exc.message}") + return problems + + def declared_keywords(self) -> list[str]: + """Every ``x-oold-*`` / ``x-oold-ui-*`` keyword the meta-schemas define. + + Used by the vocabulary-coverage cross-check, which fails when a keyword exists in the + meta-schemas but no compliance fixture exercises it. + """ + keywords = [key for key in (self.meta.get("properties") or {}) if key.startswith("x-oold-")] + ui_keywords = (self.ui_meta.get("$defs") or {}).get("keywords", {}).get("properties") or {} + keywords.extend(ui_keywords) + return sorted(set(keywords)) + + +def _build_registry(documents: dict[str, Any]) -> Registry: + """Register each document under its ``$id``, and resolve anything else by file name. + + The file-name fallback is load-bearing rather than defensive. The canonical ``$id`` domain + has already moved once (``oo-ld.github.io/oold-schema`` before 0.7.0, ``oo-ld.org`` after), + and a released copy stamps its version in place of ``latest`` while its internal ``$ref`` + may still say ``latest``. Matching on the file name makes the bundle self-consistent + whatever URL scheme a given release happens to use. JSON Schema 2020-12 itself is not + handled here; it comes from the specifications bundled with ``referencing``. + """ + resources = { + name: Resource.from_contents(document, default_specification=DRAFT202012) + for name, document in documents.items() + } + + def retrieve(uri: str) -> Resource: + basename = uri.split("#", 1)[0].rsplit("/", 1)[-1] + if basename in resources: + return resources[basename] + raise MetaSchemaError(f"the meta-schema bundle references {uri!r}, which is not one of its files") + + pairs = [] + for name, document in documents.items(): + declared = document.get("$id") + pairs.append((str(declared) if declared else name, resources[name])) + return Registry(retrieve=retrieve).with_resources(pairs) + + +def _read_documents(directory: Path, label: str) -> dict[str, Any]: + documents: dict[str, Any] = {} + for name in meta_files(): + path = directory / name + try: + documents[name] = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise MetaSchemaError(f"{label} is missing {name} (looked in {directory})") from exc + except json.JSONDecodeError as exc: + raise MetaSchemaError(f"{label}: {name} is not valid JSON: {exc}") from exc + return documents + + +def load_tracked(version: str) -> MetaBundle: + """Load one tracked version from the package.""" + directory = meta_dir() / version + if not directory.is_dir(): + available = ", ".join(tracked_versions()) or "none" + raise MetaSchemaError(f"meta-schema version {version!r} is not tracked (available: {available})") + documents = _read_documents(directory, f"meta-schema version {version}") + return MetaBundle( + version=version, + origin=str(directory), + documents=documents, + registry=_build_registry(documents), + ) + + +# ---------------------------------------------------------------------------- remote + + +def fetch_remote(force: bool = False, timeout: float = 10.0) -> Path: + """Fetch the unreleased ``main`` meta-schemas into the user cache and return its path.""" + target = remote_cache_dir() + stamp = target / "fetched.json" + if not force and all((target / name).is_file() for name in meta_files()): + return target + + base = remote_base_url() + target.mkdir(parents=True, exist_ok=True) + for name in meta_files(): + document = http_get_json(base + name, timeout=timeout) + (target / name).write_text(json.dumps(document, indent=2), encoding="utf-8") + stamp.write_text( + json.dumps( + { + "base_url": base, + "fetched": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "files": meta_files(), + }, + indent=2, + ), + encoding="utf-8", + ) + return target + + +def load_remote(offline: bool = False, timeout: float = 10.0) -> MetaBundle: + """Load the ``main`` meta-schemas, from the cache when offline.""" + target = remote_cache_dir() + cached = all((target / name).is_file() for name in meta_files()) + + if not cached: + if offline: + raise MetaSchemaError( + "refusing network fetch (offline): the remote meta-schemas are not cached. " + "Run `oold meta fetch` once while online, or select a tracked version." + ) + try: + target = fetch_remote(timeout=timeout) + except SchemaResolutionError as exc: + raise MetaSchemaError(f"could not fetch the remote meta-schemas: {exc}") from exc + + documents = _read_documents(target, "the remote meta-schemas") + origin = str(target) + stamp = target / "fetched.json" + if stamp.is_file(): + # The stamp is provenance for the report, so a corrupt one must not fail the run. + with contextlib.suppress(OSError, json.JSONDecodeError, KeyError): + origin = f"{target} (fetched {json.loads(stamp.read_text(encoding='utf-8'))['fetched']})" + return MetaBundle( + version=REMOTE, + origin=origin, + documents=documents, + registry=_build_registry(documents), + ) + + +# ---------------------------------------------------------------------------- selection + + +def resolve_selection( + selectors: str | list[str] | tuple[str, ...] = (LATEST,), + offline: bool = False, + timeout: float = 10.0, +) -> list[MetaBundle]: + """Turn ``--meta`` selectors into bundles, in the order given and without duplicates.""" + if isinstance(selectors, str): + selectors = [selectors] + requested = list(selectors) or [LATEST] + + wanted: list[str] = [] + for selector in requested: + name = selector.strip() + if name == ALL: + resolved = tracked_versions() + if not resolved: + raise MetaSchemaError(f"no meta-schema versions are tracked in {meta_dir()}") + elif name == LATEST: + resolved = [latest_version()] + else: + resolved = [name] + for version in resolved: + if version not in wanted: + wanted.append(version) + + bundles: list[MetaBundle] = [] + for version in wanted: + if version == REMOTE: + bundles.append(load_remote(offline=offline, timeout=timeout)) + else: + bundles.append(load_tracked(version)) + return bundles + + +def describe_store() -> dict[str, Any]: + """What ``oold meta list`` prints: tracked versions, provenance and cache state.""" + index = load_index() + versions = tracked_versions() + cache = remote_cache_dir() + cached = all((cache / name).is_file() for name in meta_files()) + + fetched = None + stamp = cache / "fetched.json" + if stamp.is_file(): + try: + fetched = json.loads(stamp.read_text(encoding="utf-8")).get("fetched") + except (OSError, json.JSONDecodeError): + fetched = None + + return { + "tracked_dir": str(meta_dir()), + "versions": [{"version": v, **(index.get("versions", {}).get(v) or {})} for v in versions], + "latest": versions[-1] if versions else None, + "files": meta_files(), + "remote": { + "base_url": (index.get("remote") or {}).get("base_url"), + "cache_dir": str(cache), + "cached": cached, + "fetched": fetched, + }, + } diff --git a/src/oold/validation/pattern_lint.py b/src/oold/validation/pattern_lint.py new file mode 100644 index 0000000..fc8512d --- /dev/null +++ b/src/oold/validation/pattern_lint.py @@ -0,0 +1,182 @@ +"""Round-trip-safe ``@context`` pattern lint. + +Ports ``meta/oold-pattern-lint.schema.json`` plus ``scripts/pattern_lint.mjs`` from oold-schema. +The lint has three parts, in two categories: + +**MUST (a failure).** + +* No term may coerce a literal to a datatype JSON encodes natively (``xsd:string``, + ``xsd:boolean``, ``xsd:integer``, ``xsd:double``, ``xsd:float``). None of them survive a + round-trip: ``xsd:string`` is RDF's default and is elided from plain literals, while + boolean and numeric literals reconstruct as untyped native JSON values. Either way the value + carries no ``@type`` coming back, the coercing term is never selected, and the property + returns under its full IRI instead. This part *is* expressible in JSON Schema, so it lives in + the versioned lint schema and is checked with a validator. +* A strictly ``type: array`` property must declare ``"@container": "@set"`` (or ``"@list"``), + or a single-element array comes back as a scalar and the reconstruction fails re-validation. + This correlates ``properties`` with ``@context``, so no single JSON Schema can express it and + :func:`array_properties_missing_container` implements it directly. + +**SHOULD (a warning).** A bare-IRI-string reference should constrain its lexical form with an +IRI/URI-family ``format``. The reference still round-trips without one, so this is a +recommendation rather than loss: :func:`iri_references_missing_format`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .formats import IRI_FORMATS +from .meta_store import MetaBundle +from .schema_checks import format_error + + +@dataclass +class PatternLintResult: + """Findings for one schema against one meta-schema version.""" + + meta_version: str + schema_errors: list[str] = field(default_factory=list) + missing_container: list[str] = field(default_factory=list) + missing_iri_format: list[str] = field(default_factory=list) + + @property + def failed(self) -> bool: + """MUST-level findings only. The IRI-format finding is a warning.""" + return bool(self.schema_errors or self.missing_container) + + @property + def has_warning(self) -> bool: + return bool(self.missing_iri_format) + + def to_dict(self) -> dict[str, Any]: + return { + "meta_version": self.meta_version, + "schema_errors": self.schema_errors, + "missing_container": self.missing_container, + "missing_iri_format": self.missing_iri_format, + } + + +def context_terms(context: Any, out: dict[str, dict[str, Any]] | None = None) -> dict[str, dict]: + """Collect the object-valued term definitions of a ``@context``. + + The context may be a string, an array or an object. Keyword entries (``@vocab``, + ``@version``, ...) are skipped, as are terms defined as a plain string, which carry no + ``@container`` or ``@type`` to inspect. + """ + if out is None: + out = {} + if isinstance(context, list): + for entry in context: + context_terms(entry, out) + return out + if isinstance(context, dict): + for term, definition in context.items(): + if term.startswith("@"): + continue + if isinstance(definition, dict): + out[term] = definition + return out + + +def is_strict_array(prop: Any) -> bool: + """True when a property accepts arrays and nothing else. + + A cardinality-flexible shape - ``type: ["array", "string"]``, or a ``oneOf``/``anyOf`` that + also permits a scalar - is not strict: its scalar form still validates after a round-trip, + so ``@container: @set`` is optional there (a MAY) rather than required. + """ + if not isinstance(prop, dict): + return False + if prop.get("type") == "array": + return True + return ("items" in prop or "prefixItems" in prop) and prop.get("type") is None + + +def _has_container(definition: Any) -> bool: + if not isinstance(definition, dict): + return False + container = definition.get("@container") + # `@container` is legitimately either a string or an array of strings, so the string case + # must be narrowed before any membership test: an unhashable list would otherwise raise. + if isinstance(container, str): + return container in {"@set", "@list"} + if isinstance(container, list): + return "@set" in container or "@list" in container + return False + + +def array_properties_missing_container(schema: dict[str, Any]) -> list[str]: + """Strict-array properties whose local ``@context`` term declares no ``@container``. + + Only locally declared properties mapped by a local term are in scope. A property mapped + solely through an inherited (remote) context is checked when that context's own schema is + linted. + """ + terms = context_terms(schema.get("@context")) + properties = schema.get("properties") or {} + missing: list[str] = [] + for name, prop in properties.items(): + if not isinstance(prop, dict) or not is_strict_array(prop): + continue + if name in terms and not _has_container(terms[name]): + missing.append(name) + return missing + + +def iri_references_missing_format(schema: dict[str, Any]) -> list[str]: + """IRI-valued reference properties that declare no IRI/URI-family ``format``. + + A bare IRI string (a value coerced to an IRI by ``@type: @id`` and typed by + ``x-oold-range``) should constrain its form with ``iri-reference`` - which accepts absolute, + compact and relative IRIs - or a stricter ``iri``/``uri-reference``/``uri``. Only + string-valued ``@type: @id`` terms are in scope; value-form and object-valued (embedded) + ranges are not. + """ + terms = context_terms(schema.get("@context")) + properties = schema.get("properties") or {} + out: list[str] = [] + + def has_range(node: Any) -> bool: + return isinstance(node, dict) and "x-oold-range" in node + + for name, prop in properties.items(): + definition = terms.get(name) + if not isinstance(prop, dict) or not isinstance(definition, dict): + continue + if definition.get("@type") != "@id": + continue + if prop.get("type") == "string" and has_range(prop) and prop.get("format") not in IRI_FORMATS: + out.append(name) + continue + items = prop.get("items") + if ( + isinstance(items, dict) + and items.get("type") == "string" + and has_range(items) + and items.get("format") not in IRI_FORMATS + ): + out.append(f"{name}[]") + return out + + +def lint(schema: dict[str, Any], bundle: MetaBundle) -> PatternLintResult: + """Run all three lint parts against one schema.""" + result = PatternLintResult(meta_version=bundle.version) + + try: + errors = sorted( + bundle.pattern_lint_validator().iter_errors(schema), + key=lambda e: list(e.absolute_path), + ) + result.schema_errors = [format_error(error) for error in errors] + except Exception as exc: + result.schema_errors = [f"pattern lint could not run: {type(exc).__name__}: {exc}"] + + # These two correlate `properties` with `@context`, so they are not expressible in the lint + # schema and are meta-version independent. + result.missing_container = array_properties_missing_container(schema) + result.missing_iri_format = iri_references_missing_format(schema) + return result diff --git a/src/oold/validation/pipeline.py b/src/oold/validation/pipeline.py new file mode 100644 index 0000000..84e7cde --- /dev/null +++ b/src/oold/validation/pipeline.py @@ -0,0 +1,613 @@ +"""Orchestration: run the checks over targets, across meta-schema versions. + +The one structural decision worth knowing: only two checks depend on which meta-schema version +is in use (``schema.meta`` and ``lint.pattern``). Everything else - ``$ref`` resolution, +generation, RDF round-trip, predicate attribution - is version independent. So the version +independent work runs *once* and only the two dependent checks fan out across the selected +versions. With ``--meta all`` that is the difference between linear and near-constant cost. + +Check ids are stable and dotted, so results can be filtered, compared across runs, and lined up +against the reference harness's sections. See ``docs/how-to/validation.md``. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .compliance import run_suite, vocabulary_coverage +from .context_graph import cyclic_scoped_contexts +from .context_resolution import find_alias_keys, resolve_context +from .formats import OOLD_FORMAT_CHECKER +from .frame import collect_composed_properties +from .generate import MAX_VARIANTS, collect_variants, generate +from .instance_checks import roundtrip_instance +from .instance_checks import validate_instance as _validate_instance_doc +from .loader import DocumentLoader +from .meta_store import MetaBundle, MetaSchemaError, resolve_selection +from .pattern_lint import lint +from .predicates import check_predicates +from .report import FAIL, OK, SKIP, WARN, Report +from .resolve import Resolver, SchemaResolutionError, bound_schema +from .roundtrip import roundtrip +from .schema_checks import check_usable_as_validator, validate_against_meta + +SCHEMA_SUFFIX = ".schema.json" +INSTANCE_SUFFIX = ".instance.json" + +#: Why a schema's JSON-LD checks were skipped. Shared so the message is identical everywhere. +CYCLIC_NOTE = ( + "reaches a cyclic scoped @context, which neither PyLD nor jsonld.js can process " + "(flatten it to the top-level context)" +) + + +@dataclass +class Options: + """Everything that varies between runs.""" + + meta: tuple[str, ...] = ("latest",) + offline: bool = False + max_variants: int = MAX_VARIANTS + only: tuple[str, ...] = () + skip: tuple[str, ...] = () + cache_dir: Path | None = None + + def wants(self, check_id: str) -> bool: + if self.only and not any(check_id.startswith(prefix) for prefix in self.only): + return False + return not any(check_id.startswith(prefix) for prefix in self.skip) + + +@dataclass +class _Run: + """Mutable state shared by the checks of one run.""" + + options: Options + report: Report + resolver: Resolver + loader: DocumentLoader + bundles: list[MetaBundle] + directory: Path + schemas: dict[str, Any] = field(default_factory=dict) + cyclic: set[str] = field(default_factory=set) + _bounded: dict[str, Any] = field(default_factory=dict) + + def bounded(self, name: str) -> dict[str, Any]: + """Dereference and bound a schema by file name, memoised for the run.""" + if name not in self._bounded: + resolved = self.resolver.load(self.directory / name) + schema = bound_schema(self.resolver.dereference(resolved).schema) + schema.pop("$schema", None) + self._bounded[name] = schema + return self._bounded[name] + + def add(self, check_id: str, *args: Any, **kwargs: Any) -> None: + if self.options.wants(check_id): + self.report.add(check_id, *args, **kwargs) + + +# ---------------------------------------------------------------------------- setup + + +def _start(source: Path, options: Options, label: str) -> _Run: + resolver = Resolver(offline=options.offline, cache_dir=options.cache_dir) + directory = source if source.is_dir() else source.parent + report = Report(source=str(source)) + bundles = resolve_selection(options.meta, offline=options.offline) + report.meta_versions = [b.version for b in bundles] + + run = _Run( + options=options, + report=report, + resolver=resolver, + loader=DocumentLoader(resolver, directory=directory), + bundles=bundles, + directory=directory, + ) + + for bundle in bundles: + problems = bundle.self_check() + if problems: + run.add( + "meta.self-check", + f"meta-schema {bundle.version}", + FAIL, + "; ".join(problems), + {"origin": bundle.origin}, + bundle.version, + ) + report.notes.append(f"target: {label}") + return run + + +def _read(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +# ---------------------------------------------------------------------------- schema checks + + +def _check_schema(run: _Run, name: str) -> None: + """Every check that applies to one schema file.""" + try: + raw = run.schemas[name] + except KeyError: # pragma: no cover - callers populate schemas first + return + + # -- meta-schema well-formedness (per version) --------------------------------- + for bundle in run.bundles: + result = validate_against_meta(raw, bundle) + problems = list(result.errors) + check_usable_as_validator(raw) + if problems: + extra = f" (+{result.truncated} more)" if result.truncated else "" + run.add( + "schema.meta", + name, + FAIL, + problems[0] + extra, + {"errors": problems}, + bundle.version, + ) + else: + run.add("schema.meta", name, OK, meta_version=bundle.version) + + # -- $ref composition (version independent) ------------------------------------ + try: + resolved = run.resolver.load(run.directory / name) + deref = run.resolver.dereference(resolved) + except SchemaResolutionError as exc: + run.add("schema.refs", name, FAIL, str(exc)) + return + + if deref.unresolved: + run.add("schema.refs", name, FAIL, deref.unresolved[0], {"unresolved": deref.unresolved}) + return + run.add("schema.refs", name, OK, "", {"resolved": len(deref.resolved_refs)}) + + # -- pattern lint -------------------------------------------------------------- + first = None + for bundle in run.bundles: + result = lint(raw, bundle) + first = first or result + if result.schema_errors: + run.add( + "lint.pattern", + name, + FAIL, + result.schema_errors[0], + {"errors": result.schema_errors}, + bundle.version, + ) + else: + run.add("lint.pattern", name, OK, meta_version=bundle.version) + + if first is not None: + # These two correlate `properties` with `@context`, so no meta-schema version can + # express them and they are reported once rather than per version. + if first.missing_container: + joined = ", ".join(first.missing_container) + plural = "ies" if len(first.missing_container) > 1 else "y" + run.add( + "lint.container", + name, + FAIL, + f"strict array propert{plural} without @container @set/@list: {joined}", + {"properties": first.missing_container}, + ) + else: + run.add("lint.container", name, OK) + + if first.missing_iri_format: + joined = ", ".join(first.missing_iri_format) + plural = "ies" if len(first.missing_iri_format) > 1 else "y" + run.add( + "lint.iri-format", + name, + WARN, + f"IRI reference propert{plural} without an iri-reference/uri* format: {joined}", + {"properties": first.missing_iri_format}, + ) + + _check_schema_jsonld(run, name, raw) + + +def _check_schema_jsonld(run: _Run, name: str, raw: dict[str, Any]) -> None: + """Generation, round-trip, remote-context and attribution for one schema.""" + from pyld import jsonld + + schema = run.bounded(name) + + # -- satisfiability ------------------------------------------------------------ + generated = generate(schema) + if not generated.ok: + run.add("generate.satisfiable", name, FAIL, generated.error or "generation failed") + return + + from jsonschema import Draft202012Validator + + validator = Draft202012Validator(schema, format_checker=OOLD_FORMAT_CHECKER) + errors = sorted(validator.iter_errors(generated.instance), key=lambda e: list(e.absolute_path)) + if errors: + run.add( + "generate.satisfiable", + name, + FAIL, + f"generated instance is rejected by its own schema: {errors[0].message}", + {"sample": generated.instance}, + ) + return + run.add("generate.satisfiable", name, OK, "", {"notes": generated.notes}) + + cyclic = name in run.cyclic + context_url = run.loader.url_for(name) + + # -- generated-instance round-trip --------------------------------------------- + if cyclic: + run.add("roundtrip.generated", name, SKIP, CYCLIC_NOTE) + else: + result = roundtrip(schema, generated.instance, context_url, run.loader) + if result.error: + run.add("roundtrip.generated", name, FAIL, result.error) + elif result.lost: + joined = ", ".join(result.lost) + plural = "ies" if len(result.lost) > 1 else "y" + run.add( + "roundtrip.generated", + name, + FAIL, + f"propert{plural} lost through RDF (unmapped in @context?): {joined}", + {"lost": result.lost}, + ) + else: + re_errors = sorted(validator.iter_errors(result.restored), key=lambda e: list(e.absolute_path)) + if re_errors: + run.add( + "roundtrip.generated", + name, + FAIL, + "reconstruction fails its schema (shape not preserved by @context?): " + re_errors[0].message, + {"restored": result.restored}, + ) + else: + run.add( + "roundtrip.generated", + name, + OK, + "", + {"triples": result.triples, "method": result.method}, + ) + + # -- schema usable as a remote context ----------------------------------------- + if cyclic: + run.add("context.remote", name, SKIP, CYCLIC_NOTE) + else: + try: + jsonld.expand( + {"@context": context_url, "@id": "https://example.org/dummy"}, + run.loader.options(base=run.loader.base_url), + ) + run.add("context.remote", name, OK) + except Exception as exc: + from .loader import describe_jsonld_error + + run.add("context.remote", name, FAIL, describe_jsonld_error(exc)) + + _check_predicates(run, name, raw, schema, generated.instance) + + # -- per-branch variant coverage ----------------------------------------------- + if cyclic: + return + variants, total = collect_variants(schema, limit=run.options.max_variants) + if total > len(variants): + run.report.notes.append(f"{name}: {total} oneOf/anyOf branches, checking the first {len(variants)}") + for variant in variants: + _check_variant(run, name, schema, variant, validator, context_url) + + +def _check_variant(run: _Run, name: str, schema, variant, validator, context_url: str) -> None: + label = f"{name} {variant.label}" + produced = generate(variant.schema) + if not produced.ok: + run.add("variants", label, FAIL, produced.error or "generation failed") + return + errors = sorted(validator.iter_errors(produced.instance), key=lambda e: list(e.absolute_path)) + if errors: + run.add("variants", label, FAIL, f"generated instance rejected: {errors[0].message}") + return + result = roundtrip(schema, produced.instance, context_url, run.loader) + if result.error: + run.add("variants", label, FAIL, result.error) + elif result.lost: + run.add( + "variants", + label, + FAIL, + f"properties lost through RDF: {', '.join(result.lost)}", + {"lost": result.lost}, + ) + else: + re_errors = sorted(validator.iter_errors(result.restored), key=lambda e: list(e.absolute_path)) + if re_errors: + run.add("variants", label, FAIL, f"reconstruction fails its schema: {re_errors[0].message}") + else: + run.add("variants", label, OK) + + +def _check_predicates(run: _Run, name: str, raw, schema, sample) -> None: + """Attribute each declared property to the predicate it produces.""" + if not run.options.wants("context.predicates") or not isinstance(sample, dict): + return + try: + resolved = run.resolver.load(run.directory / name) + context = resolve_context(raw, resolved.base_uri, run.resolver) + except SchemaResolutionError as exc: + run.add("context.predicates", name, FAIL, str(exc)) + return + + if context.errors: + run.add("context.predicates", name, FAIL, context.errors[0], {"errors": context.errors}) + return + if context.is_empty: + run.add("context.predicates", name, SKIP, "schema declares no @context") + return + + id_key, type_key = find_alias_keys(context.terms()) + declared = set(collect_composed_properties(schema)) | {id_key, type_key} + result = check_predicates(sample, context.as_jsonld(), declared_properties=declared) + + if result.suspicious: + first = next(iter(result.suspicious.items())) + run.add( + "context.predicates", + name, + FAIL, + f"property {first[0]!r} expands to {first[1]!r}, which is not an absolute IRI: " + "the prefix is probably undefined", + result.to_dict(include_documents=True), + ) + elif result.dropped: + run.add( + "context.predicates", + name, + FAIL, + f"propert{'ies' if len(result.dropped) > 1 else 'y'} with no @context term: " + ", ".join(result.dropped), + result.to_dict(include_documents=True), + ) + else: + run.add( + "context.predicates", + name, + OK, + "", + {"mapped": len(result.mapped), "aliased": len(result.aliased)}, + ) + + +# ---------------------------------------------------------------------------- instances + + +def _check_instance_file(run: _Run, name: str, instance: Any = None) -> None: + """Validate and round-trip one instance. + + ``instance`` may be supplied already parsed, which is how an explicit ``--schema`` override + is applied: the override rewrites ``$schema`` in memory so this single path still handles it. + """ + if instance is None: + try: + instance = _read(run.directory / name) + except (OSError, json.JSONDecodeError) as exc: + run.add("instance.schema", name, FAIL, f"could not be read: {exc}") + return + + schema_ref = instance.get("$schema") if isinstance(instance, dict) else None + if not schema_ref: + run.add("instance.schema", name, FAIL, "instance names no schema ($schema is missing)") + return + + try: + schema = run.bounded(schema_ref) + except SchemaResolutionError as exc: + run.add("instance.schema", name, FAIL, f"schema {schema_ref!r} could not be loaded: {exc}") + return + + result = _validate_instance_doc(instance, schema) + if result.valid: + run.add("instance.schema", name, OK, f"instance of {schema_ref}") + else: + run.add("instance.schema", name, FAIL, result.errors[0], {"errors": result.errors}) + + if schema_ref in run.cyclic: + run.add("roundtrip.instance", name, SKIP, f"its schema {CYCLIC_NOTE}") + return + + rt = roundtrip_instance(instance, schema, run.loader, run.loader.url_for(name), run.loader.url_for(schema_ref)) + if rt.error: + run.add("roundtrip.instance", name, FAIL, rt.error) + elif not rt.lossless: + run.add( + "roundtrip.instance", + name, + FAIL, + "instance != roundtrip (incomplete @context?)", + {"in": rt.original_canonical, "out": rt.restored_canonical}, + ) + else: + run.add( + "roundtrip.instance", + name, + OK, + f"{rt.triples} triples, lossless ({rt.method})", + {"triples": rt.triples, "method": rt.method}, + ) + + +# ---------------------------------------------------------------------------- entry points + + +def _collect(run: _Run, schema_names: list[str]) -> None: + for name in schema_names: + try: + run.schemas[name] = _read(run.directory / name) + except (OSError, json.JSONDecodeError) as exc: + run.add("schema.meta", name, FAIL, f"could not be read: {exc}") + run.cyclic = cyclic_scoped_contexts(run.schemas) + + +def validate_directory(path: str | Path, options: Options | None = None) -> Report: + """Validate every schema and instance in a directory, the general-workflow tier.""" + options = options or Options() + directory = Path(path) + if not directory.is_dir(): + report = Report(source=str(directory)) + report.fatal_error = f"not a directory: {directory}" + return report + + try: + run = _start(directory, options, f"directory {directory}") + except MetaSchemaError as exc: + report = Report(source=str(directory)) + report.fatal_error = str(exc) + return report + + schema_names = sorted(p.name for p in directory.glob(f"*{SCHEMA_SUFFIX}")) + instance_names = sorted(p.name for p in directory.glob(f"*{INSTANCE_SUFFIX}")) + if not schema_names and not instance_names: + run.report.fatal_error = f"no *{SCHEMA_SUFFIX} or *{INSTANCE_SUFFIX} files in {directory}" + return run.report + + _collect(run, schema_names) + for name in schema_names: + _check_schema(run, name) + for name in instance_names: + _check_instance_file(run, name) + return run.report + + +def validate_schema(source: str | Path, options: Options | None = None) -> Report: + """Validate a single schema file. + + Sibling schemas in the same directory are still read, because the cyclic-context detection + is a property of the reference *graph* rather than of one document. + """ + options = options or Options() + path = Path(source) + if path.is_dir(): + return validate_directory(path, options) + if not path.is_file(): + report = Report(source=str(path)) + report.fatal_error = f"schema file not found: {path}" + return report + + try: + run = _start(path, options, f"schema {path.name}") + except MetaSchemaError as exc: + report = Report(source=str(path)) + report.fatal_error = str(exc) + return report + + _collect(run, sorted(p.name for p in run.directory.glob(f"*{SCHEMA_SUFFIX}"))) + if path.name not in run.schemas: + try: + run.schemas[path.name] = _read(path) + except (OSError, json.JSONDecodeError) as exc: + run.report.fatal_error = f"{path.name} could not be read: {exc}" + return run.report + _check_schema(run, path.name) + return run.report + + +def validate_instance(source: str | Path, schema: str | Path | None = None, options: Options | None = None) -> Report: + """Validate one instance document against the schema it names, or an explicit one.""" + options = options or Options() + path = Path(source) + if not path.is_file(): + report = Report(source=str(path)) + report.fatal_error = f"instance file not found: {path}" + return report + + try: + run = _start(path, options, f"instance {path.name}") + except MetaSchemaError as exc: + report = Report(source=str(path)) + report.fatal_error = str(exc) + return report + + if schema is not None: + # An explicit schema overrides $schema; rewrite it so one code path handles both. + try: + instance = _read(path) + except (OSError, json.JSONDecodeError) as exc: + run.report.fatal_error = f"{path.name} could not be read: {exc}" + return run.report + schema_path = Path(schema) + if schema_path.parent.resolve() != run.directory.resolve(): + run.report.fatal_error = ( + "an explicit --schema must sit in the same directory as the instance, so " + f"relative @context references resolve ({schema_path.parent} != {run.directory})" + ) + return run.report + instance["$schema"] = schema_path.name + run.report.notes.append(f"schema overridden with {schema_path.name}") + _collect(run, sorted(p.name for p in run.directory.glob(f"*{SCHEMA_SUFFIX}"))) + _check_instance_file(run, path.name, instance) + return run.report + + _collect(run, sorted(p.name for p in run.directory.glob(f"*{SCHEMA_SUFFIX}"))) + _check_instance_file(run, path.name) + return run.report + + +def run_compliance(path: str | Path, options: Options | None = None) -> Report: + """Run a compliance suite directory, plus the vocabulary-coverage cross-check.""" + options = options or Options() + directory = Path(path) + report = Report(source=str(directory)) + if not directory.is_dir(): + report.fatal_error = f"not a directory: {directory}" + return report + + # Fixtures reference example schemas by name, and those live one level up. + schema_dir = directory.parent + try: + run = _start(schema_dir, options, f"compliance suite {directory}") + except MetaSchemaError as exc: + report.fatal_error = str(exc) + return report + run.report.source = str(directory) + + for bundle in run.bundles: + result = run_suite(directory, bundle, run.loader, dereference=run.bounded) + for error in result.errors: + run.add("compliance.suite", directory.name, FAIL, error, meta_version=bundle.version) + for case in result.cases: + target = f"{case.file} :: {case.description}" + run.add( + f"compliance.{case.kind}", + target, + OK if case.passed else FAIL, + case.detail, + {"group": case.group}, + bundle.version, + ) + uncovered = vocabulary_coverage(bundle, result.covered_keywords) + if uncovered: + run.add( + "coverage.vocab", + directory.name, + FAIL, + f"{len(uncovered)} keyword(s) defined in the meta-schemas but not tested: " + ", ".join(uncovered), + {"uncovered": uncovered}, + bundle.version, + ) + else: + run.add( + "coverage.vocab", + directory.name, + OK, + f"all {len(bundle.declared_keywords())} keywords covered", + meta_version=bundle.version, + ) + return run.report diff --git a/src/oold/validation/predicates.py b/src/oold/validation/predicates.py new file mode 100644 index 0000000..e68a852 --- /dev/null +++ b/src/oold/validation/predicates.py @@ -0,0 +1,180 @@ +"""Per-property attribution: which declared property produced which RDF predicate. + +This check has no counterpart in the reference harness. It exists because there are two ways a +``@context`` can fail a property, and looking only for the first misses the worse half. + +**Dropped** - the term has no context definition at all, so the key vanishes on expansion. The +round-trip check catches this too, via a lost key. + +**Suspicious** - the term maps through a prefix that was never defined. JSON-LD then reads +``schema:latitude`` as an absolute IRI whose scheme is literally ``schema``, so the key survives +expansion and the round-trip is lossless, while the predicate means nothing. Nothing about the +output looks wrong, which is what makes it dangerous. + +Attribution is done by expanding one property at a time. Expanding the whole document tells you +which predicates came out but not which input key produced which, and that mapping is exactly +what is in question. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +from pyld import jsonld + +from .loader import describe_jsonld_error + +#: Keys in expanded output that mean "this term is a JSON-LD alias", not a predicate. +ALIAS_TARGETS = frozenset({"@id", "@type", "@graph", "@index", "@language", "@value", "@list", "@set"}) + +_ABSOLUTE_IRI = re.compile(r"^[a-z][a-z0-9+.\-]*://", re.IGNORECASE) +_OTHER_SAFE_SCHEMES = ("urn:", "did:", "mailto:", "tag:", "_:") + +#: A predicate no context term can produce, used to keep probe nodes alive during +#: single-property expansion. See :func:`classify_property`. +ANCHOR = "urn:oold:validation:anchor" + +MAPPED = "mapped" +ALIAS = "alias" +DROPPED = "dropped" +SUSPICIOUS = "suspicious" + + +@dataclass +class PropertyOutcome: + """What happened to one instance property when the document was expanded.""" + + name: str + status: str + predicate: str | None = None + detail: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "status": self.status, + "predicate": self.predicate, + "detail": self.detail, + } + + +@dataclass +class PredicateResult: + """Attribution outcome for one instance.""" + + ok: bool = True + outcomes: list[PropertyOutcome] = field(default_factory=list) + dropped: list[str] = field(default_factory=list) + suspicious: dict[str, str] = field(default_factory=dict) + aliased: dict[str, str] = field(default_factory=dict) + mapped: dict[str, str] = field(default_factory=dict) + undeclared: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + def to_dict(self, include_documents: bool = False) -> dict[str, Any]: + payload: dict[str, Any] = { + "ok": self.ok, + "dropped": self.dropped, + "suspicious": self.suspicious, + "mapped_count": len(self.mapped), + "aliased": self.aliased, + "undeclared": self.undeclared, + "errors": self.errors, + } + if include_documents: + payload["outcomes"] = [o.to_dict() for o in self.outcomes] + payload["mapped"] = self.mapped + return payload + + +def is_grounded_predicate(iri: Any) -> bool: + """True when a predicate IRI is absolute, and therefore actually means something.""" + if not isinstance(iri, str) or not iri: + return False + if iri.startswith("@"): + return True + if _ABSOLUTE_IRI.match(iri): + return True + return iri.startswith(_OTHER_SAFE_SCHEMES) + + +def classify_property(name: str, value: Any, context: Any, options: dict[str, Any]) -> PropertyOutcome: + """Expand a single property in isolation to see which predicate it produces. + + The anchor is load-bearing. A node object carrying nothing but ``@id`` is free floating, and + JSON-LD drops it on expansion, so a term aliased to ``@id`` would look dropped when it is in + fact working correctly. Adding one predicate that cannot collide with a context term keeps + the node alive, and it is filtered back out below. + """ + try: + expanded = jsonld.expand({"@context": context, name: value, ANCHOR: "anchor"}, options) + except Exception as exc: + return PropertyOutcome(name=name, status=DROPPED, detail=f"expansion failed: {describe_jsonld_error(exc)}") + + if not expanded: + return PropertyOutcome(name=name, status=DROPPED, detail="no context term, so expansion produced no predicate") + + keys = set(expanded[0]) - {ANCHOR} + if not keys: + return PropertyOutcome(name=name, status=DROPPED, detail="no context term, so expansion produced no predicate") + + predicates = sorted(keys - ALIAS_TARGETS) + if not predicates: + alias = sorted(keys)[0] + return PropertyOutcome(name=name, status=ALIAS, predicate=alias, detail=f"term is a JSON-LD alias for {alias}") + + predicate = predicates[0] + if not is_grounded_predicate(predicate): + prefix = predicate.split(":", 1)[0] + return PropertyOutcome( + name=name, + status=SUSPICIOUS, + predicate=predicate, + detail=( + f"expanded to {predicate!r}, which is not an absolute IRI; the {prefix!r} " + "prefix is probably undefined in the context" + ), + ) + + return PropertyOutcome(name=name, status=MAPPED, predicate=predicate) + + +def check_predicates( + instance: dict[str, Any], + context: Any, + declared_properties: set[str] | None = None, + options: dict[str, Any] | None = None, +) -> PredicateResult: + """Classify every declared property of an instance. + + ``declared_properties`` limits the check to properties the schema actually declares. + Generated instances routinely carry extra keys, because these schemas allow additional + properties, and those keys have no context term by design. Counting them as dropped would + bury the real findings in noise, so they are reported separately instead. + """ + result = PredicateResult() + options = options or {} + + payload_keys = [k for k in instance if not k.startswith("@") and k != "$schema"] + if declared_properties is None: + checked = payload_keys + else: + checked = [k for k in payload_keys if k in declared_properties] + result.undeclared = sorted(k for k in payload_keys if k not in declared_properties) + + for name in checked: + outcome = classify_property(name, instance[name], context, options) + result.outcomes.append(outcome) + if outcome.status == DROPPED: + result.dropped.append(name) + elif outcome.status == SUSPICIOUS: + result.suspicious[name] = outcome.predicate or "" + elif outcome.status == ALIAS: + result.aliased[name] = outcome.predicate or "" + else: + result.mapped[name] = outcome.predicate or "" + + result.ok = not (result.dropped or result.suspicious or result.errors) + return result diff --git a/src/oold/validation/report.py b/src/oold/validation/report.py new file mode 100644 index 0000000..11326c7 --- /dev/null +++ b/src/oold/validation/report.py @@ -0,0 +1,164 @@ +"""Result structures shared by the library API, the CLI and the MCP server. + +One serialisable shape carries a whole run, so there is no second representation to keep in +sync. A run is a flat list of :class:`Check` records; grouping (by target, by check id, by +meta-schema version) is done at render time rather than baked into the structure. + +Statuses follow the reference harness (``scripts/validate.mjs`` in oold-schema): only ``fail`` +is fatal to the verdict. ``warn`` marks a SHOULD-level finding, ``skip`` marks a check that +could not run for a documented reason (a cyclic scoped ``@context``, for instance). +""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field +from typing import Any, Literal + +Status = Literal["ok", "fail", "warn", "skip"] +Verbosity = Literal["summary", "full"] + +OK: Status = "ok" +FAIL: Status = "fail" +WARN: Status = "warn" +SKIP: Status = "skip" + + +@dataclass +class Check: + """One check applied to one target. + + ``id`` is a stable dotted identifier (``schema.meta``, ``roundtrip.instance``, ...) so + results can be filtered and compared across runs; the golden parity test keys on it. + ``meta_version`` is set only for the checks whose outcome depends on which meta-schema + version was used, which keeps a multi-version run readable. + """ + + id: str + target: str + status: Status + message: str = "" + detail: dict[str, Any] = field(default_factory=dict) + meta_version: str | None = None + + @property + def failed(self) -> bool: + return self.status == FAIL + + def to_dict(self, verbosity: Verbosity = "summary") -> dict[str, Any]: + payload: dict[str, Any] = { + "id": self.id, + "target": self.target, + "status": self.status, + } + if self.message: + payload["message"] = self.message + if self.meta_version is not None: + payload["meta_version"] = self.meta_version + if self.detail and verbosity == "full": + payload["detail"] = self.detail + return payload + + def line(self) -> str: + """A single-line rendering, in the reference harness's column style.""" + label = self.status.upper().ljust(4) + version = f" [{self.meta_version}]" if self.meta_version else "" + message = f": {self.message}" if self.message else "" + return f"{label} {self.id:<24} {self.target}{version}{message}" + + +@dataclass +class Report: + """Everything one run produced.""" + + source: str + meta_versions: list[str] = field(default_factory=list) + checks: list[Check] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + fatal_error: str | None = None + + # ------------------------------------------------------------------ building + + def add( + self, + id: str, + target: str, + status: Status, + message: str = "", + detail: dict[str, Any] | None = None, + meta_version: str | None = None, + ) -> Check: + check = Check( + id=id, + target=target, + status=status, + message=message, + detail=detail or {}, + meta_version=meta_version, + ) + self.checks.append(check) + return check + + def extend(self, checks: list[Check]) -> None: + self.checks.extend(checks) + + # ------------------------------------------------------------------ querying + + @property + def passed(self) -> bool: + return self.fatal_error is None and not any(c.failed for c in self.checks) + + @property + def counts(self) -> dict[str, int]: + tally = Counter(c.status for c in self.checks) + return {status: tally.get(status, 0) for status in (OK, FAIL, WARN, SKIP)} + + def by_status(self, status: Status) -> list[Check]: + return [c for c in self.checks if c.status == status] + + def failures(self) -> list[Check]: + return self.by_status(FAIL) + + def warnings(self) -> list[Check]: + return self.by_status(WARN) + + def targets(self) -> list[str]: + seen: list[str] = [] + for check in self.checks: + if check.target not in seen: + seen.append(check.target) + return seen + + # ------------------------------------------------------------------ rendering + + def summary(self) -> dict[str, Any]: + counts = self.counts + return { + "source": self.source, + "passed": self.passed, + "meta_versions": list(self.meta_versions), + "targets": len(self.targets()), + "checks": len(self.checks), + **counts, + "fatal_error": self.fatal_error, + } + + def to_dict(self, verbosity: Verbosity = "summary") -> dict[str, Any]: + payload: dict[str, Any] = { + "source": self.source, + "passed": self.passed, + "summary": self.summary(), + "checks": [c.to_dict(verbosity) for c in self.checks], + } + if self.notes: + payload["notes"] = list(self.notes) + if self.fatal_error: + payload["fatal_error"] = self.fatal_error + return payload + + +def failure_reasons(report: Report) -> list[str]: + """Human-readable reasons the run did not pass, most important first.""" + if report.fatal_error: + return [report.fatal_error] + return [f"{c.id} {c.target}: {c.message}" for c in report.failures()] diff --git a/src/oold/validation/resolve.py b/src/oold/validation/resolve.py new file mode 100644 index 0000000..15826b1 --- /dev/null +++ b/src/oold/validation/resolve.py @@ -0,0 +1,503 @@ +"""Document loading, ``$ref`` dereferencing and schema bounding. + +A schema can be given as a dict, a :class:`~pathlib.Path`, a file path string, a raw JSON +string, or a URL. Everything normalises to a :class:`ResolvedSchema`, whose ``base_uri`` is what +relative ``$ref`` and relative ``@context`` entries resolve against. Local files get a +``file://`` base URI so one code path covers local and remote alike. + +Remote documents are cached on disk. The reference harness +(``json-schema-ref-parser`` inside ``scripts/validate.mjs``) also follows remote ``$ref``s, but +refetches them on every run; caching is the one behavioural difference here, and +``offline=True`` restricts resolution to local files and the warm cache. + +:func:`dereference` deliberately produces a *graph*, with shared and circular references, the +way ``json-schema-ref-parser`` does. :func:`bound_schema` then turns that graph back into a +finite tree. Keeping the two separate is what lets the bounding pass see the real sharing +structure and cut it consistently. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from collections import deque +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urljoin, urlsplit + +#: Matches the `/C:` a Windows drive letter gets in a file URI path. +_DRIVE_PREFIX = re.compile(r"^/[A-Za-z]:") + +#: Cut marker substituted for a cycle or an over-deep node. Ported verbatim from validate.mjs. +#: +#: It must stay permissive for validation: a *typed* cut would reject legitimate values at a node +#: that is shared with an intact path. Carrying only a custom ``format`` gives two properties at +#: once. Validation has no assertion for an unknown format and the node declares no ``type``, so +#: it accepts anything; and the generator treats a ``format`` node as a string, emitting a +#: deterministic marker. The second half matters because at a *typeless* node the generator would +#: otherwise be free to emit a boolean or a number, and a non-string under an ``@type: "@id"`` +#: term becomes an RDF literal that cannot compact back, which reads as a false round-trip loss. +CUT_FORMAT = "x-oold-cut" +CUT_SCHEMA: dict[str, Any] = {"format": CUT_FORMAT} + +#: Instance-nesting depth budget for :func:`bound_schema`. +DEFAULT_MAX_DEPTH = 6 + +#: Keywords whose value describes a nested instance level; descending costs depth budget. +INSTANCE_KEYWORDS = frozenset({ + "items", + "additionalItems", + "additionalProperties", + "contains", + "propertyNames", + "unevaluatedItems", + "unevaluatedProperties", +}) +#: Keywords whose *members'* values describe a nested instance level. +INSTANCE_MAP_KEYWORDS = frozenset({"properties", "patternProperties"}) + +#: Dropped from every node while bounding. Dereferencing inlines a ``$ref``'d leaf under many +#: properties, each keeping its ``$id``, which would make one ``$id`` resolve to several schemas. +IDENTITY_KEYWORDS = frozenset({"$id", "$schema"}) + + +def default_cache_dir() -> Path: + override = os.environ.get("OOLD_CACHE_DIR") + if override: + return Path(override) + return Path.home() / ".cache" / "oold" + + +class SchemaResolutionError(Exception): + """A schema or one of its references could not be loaded.""" + + +@dataclass +class ResolvedSchema: + """A loaded schema plus the base URI its relative references resolve against.""" + + schema: dict[str, Any] + base_uri: str + source: str + + def __post_init__(self) -> None: + if not isinstance(self.schema, dict): + raise SchemaResolutionError(f"expected a JSON object at the schema root, got {type(self.schema).__name__}") + + +@dataclass +class DereferenceResult: + """A dereferenced schema plus what could not be resolved along the way.""" + + schema: Any + unresolved: list[str] = field(default_factory=list) + resolved_refs: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.unresolved + + +def is_url(text: str) -> bool: + return urlsplit(text).scheme in {"http", "https", "file"} + + +def uri_to_path(uri: str) -> Path | None: + """Convert a ``file://`` URI back to a local path, or None for other schemes. + + ``Path.from_uri`` arrived in Python 3.13 and handles Windows drive letters and UNC paths + properly. Older versions fall back to ``url2pathname``, whose Windows implementation lives + in ``nturl2path`` and is deprecated from 3.14 - which is exactly the range where the + fallback no longer runs. + """ + parts = urlsplit(uri) + if parts.scheme != "file": + return None + + from_uri = getattr(Path, "from_uri", None) + if from_uri is not None: # Python 3.13+ + try: + return from_uri(uri) + except ValueError: + return None + + # Fallback for 3.10-3.12. Every file URI this package handles was produced by + # `Path.as_uri()`, so the shapes are known: an optional UNC authority, and on Windows a + # leading slash before the drive letter. + path = unquote(parts.path) + if parts.netloc: + return Path(f"//{parts.netloc}{path}") + if os.name == "nt" and _DRIVE_PREFIX.match(path): + path = path[1:] + return Path(path) + + +class Resolver: + """Loads documents and resolves references, with a memory and on-disk cache. + + One instance is meant to be reused for a whole run, and across MCP tool calls in one + session, so a document referenced many times is fetched once. + """ + + def __init__( + self, + cache_dir: Path | None = None, + timeout: float = 10.0, + offline: bool = False, + ) -> None: + self.cache_dir = Path(cache_dir) if cache_dir else default_cache_dir() / "documents" + self.timeout = timeout + self.offline = offline + self._memory: dict[str, Any] = {} + #: URIs fetched over the network in this session, for reporting and tests. + self.fetched: list[str] = [] + + # ------------------------------------------------------------------ loading + + def load(self, source: str | Path | dict[str, Any]) -> ResolvedSchema: + """Load from a dict, a path, a raw JSON string, or a URL.""" + if isinstance(source, dict): + return ResolvedSchema(schema=source, base_uri=str(source.get("$id") or ""), source="") + + if isinstance(source, Path): + return self._load_path(source) + + text = str(source).strip() + + if text.startswith("{"): + try: + schema = json.loads(text) + except json.JSONDecodeError as exc: + raise SchemaResolutionError(f"source is not valid JSON: {exc}") from exc + base = str(schema.get("$id") or "") if isinstance(schema, dict) else "" + return ResolvedSchema(schema=schema, base_uri=base, source="") + + if is_url(text): + return ResolvedSchema(schema=self.fetch(text), base_uri=text, source=text) + + return self._load_path(Path(text)) + + def _load_path(self, path: Path) -> ResolvedSchema: + path = path.expanduser() + if not path.exists(): + raise SchemaResolutionError(f"schema file not found: {path}") + try: + schema = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise SchemaResolutionError(f"{path} is not valid JSON: {exc}") from exc + uri = path.resolve().as_uri() + self._memory[uri] = schema + return ResolvedSchema(schema=schema, base_uri=uri, source=str(path)) + + # ------------------------------------------------------------------ fetching + + def fetch(self, uri: str) -> Any: + """Fetch a document by absolute URI, through the memory then the disk cache.""" + if uri in self._memory: + return self._memory[uri] + + local = uri_to_path(uri) + if local is not None: + if not local.exists(): + raise SchemaResolutionError(f"referenced file not found: {local}") + try: + document = json.loads(local.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise SchemaResolutionError(f"{local} is not valid JSON: {exc}") from exc + self._memory[uri] = document + return document + + cached = self._read_disk_cache(uri) + if cached is not None: + self._memory[uri] = cached + return cached + + if self.offline: + raise SchemaResolutionError(f"refusing network fetch (offline): {uri} is not in the cache") + + document = http_get_json(uri, timeout=self.timeout) + self.fetched.append(uri) + self._write_disk_cache(uri, document) + self._memory[uri] = document + return document + + def _cache_file(self, uri: str) -> Path: + digest = hashlib.sha256(uri.encode("utf-8")).hexdigest()[:32] + return self.cache_dir / f"{digest}.json" + + def _read_disk_cache(self, uri: str) -> Any | None: + target = self._cache_file(uri) + if not target.exists(): + return None + try: + return json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + def _write_disk_cache(self, uri: str, document: Any) -> None: + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + self._cache_file(uri).write_text(json.dumps(document), encoding="utf-8") + except OSError: + # A failing cache must never fail the run. + pass + + # ------------------------------------------------------------------ resolving + + def resolve_ref(self, ref: str, base_uri: str) -> tuple[Any, str]: + """Resolve a ``$ref`` against a base URI, returning the target and its own base URI. + + Handles absolute URLs, relative sibling references (the OO-LD norm) and JSON pointer + fragments, including a fragment applied to an external document. + """ + target_uri, _, fragment = ref.partition("#") + + if target_uri: + absolute = urljoin(base_uri, target_uri) if base_uri else target_uri + if not is_url(absolute): + raise SchemaResolutionError(f"cannot resolve relative reference {ref!r} without a base URI") + document = self.fetch(absolute) + new_base = absolute + else: + document = self.fetch(base_uri) if base_uri else {} + new_base = base_uri + + if fragment: + document = apply_json_pointer(document, fragment, ref) + + return document, new_base + + # ------------------------------------------------------------------ dereferencing + + def dereference(self, resolved: ResolvedSchema) -> DereferenceResult: + """Inline every ``$ref``, producing a graph that may share nodes and contain cycles. + + Targets are memoised by absolute URI and registered *before* their contents are walked, + so a self-referential schema yields a genuinely circular structure rather than silently + truncating. :func:`bound_schema` is what makes the result finite again. + """ + result = DereferenceResult(schema=None) + memo: dict[str, Any] = {} + + def expand(ref: str, base_uri: str) -> Any: + absolute = urljoin(base_uri, ref) if base_uri else ref + if absolute in memo: + return memo[absolute] + + try: + target, target_base = self.resolve_ref(ref, base_uri) + except SchemaResolutionError as exc: + message = f"{ref}: {exc}" + if message not in result.unresolved: + result.unresolved.append(message) + return {} + + if absolute not in result.resolved_refs: + result.resolved_refs.append(absolute) + + if not isinstance(target, dict): + return inline(target, target_base) + + out: dict[str, Any] = {} + memo[absolute] = out + fill(out, target, target_base) + return out + + def fill(out: dict[str, Any], node: dict[str, Any], base_uri: str) -> None: + """Populate an already-memoised container with the inlined form of ``node``. + + Split out from :func:`inline` so the memo entry exists before recursion, which is + what lets a cyclic reference resolve to the (still incomplete) container rather + than recursing forever. + + The ``$ref`` branch matters more than it looks: a referenced *document* can itself + be a ``$ref`` node with siblings, which is how the schema.org-derived corpus models + a refined datatype (``Email.schema.json`` is ``{"$ref": "Text.schema.json", + "format": "email"}``). Copying such a document's keys verbatim would leave a live + ``$ref`` in supposedly dereferenced output, and every downstream consumer - the + validator, the generator - would then fail on an unresolvable reference. + """ + ref = node.get("$ref") + if isinstance(ref, str): + expansion = expand(ref, base_uri) + if isinstance(expansion, dict): + out.update(expansion) + for key, value in node.items(): + if key != "$ref": + out[key] = inline(value, base_uri) + return + for key, value in node.items(): + out[key] = inline(value, base_uri) + + def inline(node: Any, base_uri: str) -> Any: + if isinstance(node, list): + return [inline(item, base_uri) for item in node] + if not isinstance(node, dict): + return node + + ref = node.get("$ref") + if isinstance(ref, str): + expansion = expand(ref, base_uri) + siblings = {k: v for k, v in node.items() if k != "$ref"} + if not siblings: + return expansion + # 2020-12 allows $ref to carry siblings; keep them alongside the target. + inlined = {k: inline(v, base_uri) for k, v in siblings.items()} + if isinstance(expansion, dict): + merged = dict(expansion) + merged.update(inlined) + return merged + return inlined + + return {key: inline(value, base_uri) for key, value in node.items()} + + result.schema = inline(resolved.schema, resolved.base_uri) + return result + + +def http_get_json(uri: str, timeout: float = 10.0) -> Any: + """Fetch JSON over HTTP using the standard library, so no HTTP client is a dependency.""" + from urllib.error import URLError + from urllib.request import Request, urlopen + + # The scheme is checked *before* opening, or `urlopen` would happily read a `file:` (or + # custom-handler) URL. Local paths have their own code path in `Resolver.fetch`; anything + # reaching here must be a network fetch. + if urlsplit(uri).scheme not in {"http", "https"}: + raise SchemaResolutionError(f"refusing to fetch a non-http(s) URL: {uri}") + + # S310 on both lines: the scheme is restricted to http(s) immediately above. + request = Request( # noqa: S310 + uri, headers={"Accept": "application/json", "User-Agent": "oold-validation"} + ) + try: + with urlopen(request, timeout=timeout) as response: # noqa: S310 + payload = response.read().decode("utf-8") + except (URLError, OSError) as exc: + raise SchemaResolutionError(f"could not fetch {uri}: {exc}") from exc + + try: + return json.loads(payload) + except json.JSONDecodeError as exc: + raise SchemaResolutionError(f"{uri} did not return valid JSON: {exc}") from exc + + +def apply_json_pointer(document: Any, fragment: str, ref: str) -> Any: + """Walk a JSON pointer fragment such as ``/$defs/Address``.""" + pointer = fragment.lstrip("/") + if not pointer: + return document + current = document + for raw_token in pointer.split("/"): + token = raw_token.replace("~1", "/").replace("~0", "~") + if isinstance(current, dict) and token in current: + current = current[token] + elif isinstance(current, list) and token.isdigit() and int(token) < len(current): + current = current[int(token)] + else: + raise SchemaResolutionError(f"could not resolve pointer in {ref!r}") + return current + + +# ---------------------------------------------------------------------------- bounding + + +def bound_schema(root: Any, max_depth: int = DEFAULT_MAX_DEPTH) -> Any: + """Return a finite, acyclic copy of a dereferenced schema. + + Ported from ``boundSchema`` in oold-schema ``scripts/validate.mjs``. + + Dereferencing inlines ``$ref``s, so a schema with cyclic embeds (a value type that embeds + itself, for example schema.org ``QuantitativeValue.valueReference``) becomes a graph with + circular references, and one where many properties share the same referenced leaf nodes. + Generation, validation and the variant walker would all recurse without bound. Here a node + on the current path, or beyond ``max_depth`` instance levels, is cut, and shared nodes are + memoised so a DAG is not unrolled into an exponentially larger tree. Non-cyclic, shallow + schemas are copied unchanged. + + Depth counts *instance* nesting rather than raw JSON nesting: an ``allOf``/``anyOf`` hop or a + subclass chain adds JSON depth without nesting the instance, and counting it would cut + inherited property constraints - turning them permissive - on any schema a few subclass + levels deep. + + One observed subtlety, verified against the reference implementation rather than inferred: + nesting through ``properties`` does **not** in practice consume the budget. The map is + enqueued at the current depth alongside its members at ``depth + 1``, and when the map is + later dequeued it is walked as an ordinary object, re-enqueueing those same members at the + current depth; the relaxation then keeps the smaller value. So only the keywords in + :data:`INSTANCE_KEYWORDS` and ``prefixItems`` actually cut on depth. Termination does not + depend on it either way, since cycles are cut path-locally. This port reproduces the + behaviour deliberately, because the goal is verdict parity with the reference harness; + changing it here would make the two disagree on deeply nested schemas. + """ + # Pass 1: each node's minimum instance depth over all paths reaching it. A shared node is + # then cut, or kept, identically everywhere rather than depending on which path happened to + # reach it first; otherwise one allOf member can carry an intact copy of a property while + # another carries an over-cut permissive copy of the same one, and generation satisfies only + # the cut. Node identity is by object, matching the reference implementation, so `root` must + # stay alive for the whole call - it does, being the argument. + min_depth: dict[int, int] = {} + queue: deque[tuple[Any, int]] = deque([(root, 0)]) + while queue: + node, depth = queue.popleft() + if not isinstance(node, (dict, list)): + continue + key = id(node) + if key in min_depth and min_depth[key] <= depth: + continue + min_depth[key] = depth + + if isinstance(node, list): + for item in node: + queue.append((item, depth)) + continue + + for name, value in node.items(): + step = 1 if (name in INSTANCE_KEYWORDS or name == "prefixItems") else 0 + if name in INSTANCE_MAP_KEYWORDS and isinstance(value, dict): + queue.append((value, depth)) + for member in value.values(): + queue.append((member, depth + 1)) + else: + queue.append((value, depth + step)) + + # Pass 2: copy, cutting cycles (path-local) and nodes whose best depth exceeds the budget. + memo: dict[int, Any] = {} + + def walk(node: Any, path: set[int]) -> Any: + if not isinstance(node, (dict, list)): + return node + key = id(node) + if key in path: + return dict(CUT_SCHEMA) # cycle: the node references itself or an ancestor + if key in memo: + return memo[key] # shared node already bounded: reuse, keeping the DAG + if min_depth.get(key, 0) > max_depth: + return dict(CUT_SCHEMA) + + path.add(key) + out: Any = [] if isinstance(node, list) else {} + memo[key] = out + if isinstance(node, list): + for item in node: + out.append(walk(item, path)) + else: + for name, value in node.items(): + if name in IDENTITY_KEYWORDS: + continue + out[name] = walk(value, path) + path.discard(key) + return out + + return walk(root, set()) + + +def dereference_and_bound( + resolver: Resolver, resolved: ResolvedSchema, max_depth: int = DEFAULT_MAX_DEPTH +) -> tuple[Any, DereferenceResult]: + """Dereference then bound, the combination every instance-level check needs.""" + result = resolver.dereference(resolved) + return bound_schema(result.schema, max_depth), result diff --git a/src/oold/validation/roundtrip.py b/src/oold/validation/roundtrip.py new file mode 100644 index 0000000..dbbc1a4 --- /dev/null +++ b/src/oold/validation/roundtrip.py @@ -0,0 +1,221 @@ +"""Projecting an instance to RDF and reconstructing it. + +Ports ``canonical``, ``lostKeys`` and ``roundtrip`` from ``validate.mjs`` (lines 253-324). + +Two different comparisons are needed, because two different questions are being asked. + +:func:`lost_keys` answers "did any property fall out?" It compares keys only and ignores leaf +values, which is right for a *generated* instance: a property with no (or a broken) ``@context`` +term produces no triples and its key disappears, while value coercion - a reference string +resolving to an absolute IRI - keeps the key and so must not be reported. + +:func:`canonical` answers "is the reconstruction equal to the original?" It is used for +*committed* instances, where the exact values matter too. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +from pyld import jsonld + +from .frame import embedded_properties, instance_rdf_types, schema_to_frame +from .loader import DocumentLoader, describe_jsonld_error + +#: Keys that are metadata rather than data, and are excluded from both comparisons. +_METADATA_KEYS = frozenset({"@context", "$schema"}) + + +def _sort_key(value: Any) -> str: + # Compact separators so ordering matches JSON.stringify in the reference implementation. + return json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str) + + +def canonical(value: Any) -> Any: + """A comparable normal form: metadata dropped, arrays sorted, cardinality flattened. + + Sorting array members is correct because RDF sets are unordered. Treating a single value and + a one-element array alike is JSON-LD semantics rather than laxness: ``"x"`` and ``["x"]`` + expand identically, and compaction picks the scalar or array form depending on whether the + term declares ``@container: @set``, so cardinality may legitimately differ between an + instance and its round-trip without any loss. The ``@container`` requirement is enforced + separately and statically by the pattern lint. + """ + if isinstance(value, list): + return sorted((canonical(item) for item in value), key=_sort_key) + if isinstance(value, dict): + out: dict[str, Any] = {} + for key in sorted(value): + if key in _METADATA_KEYS: + continue + if key == "@id": + # Blank-node identifiers are arbitrary labels, not stable identity: a blank node + # acquires a `_:bN` label on the way back from RDF that it did not carry before. + # Drop @id when every value is such a label, so the node compares equal. + values = value[key] if isinstance(value[key], list) else [value[key]] + if all(isinstance(v, str) and v.startswith("_:") for v in values): + continue + member = value[key] + out[key] = canonical(member if isinstance(member, list) else [member]) + return out + return value + + +def json_equal(left: Any, right: Any) -> bool: + """Structural equality with JSON semantics, notably keeping booleans distinct from 1/0. + + Python would otherwise treat ``True == 1`` as equal, which JSON and JSON-LD do not. Numbers + are compared by value so ``1`` and ``1.0`` match, which is what the reference implementation + does implicitly by having a single number type. + """ + if isinstance(left, bool) or isinstance(right, bool): + return isinstance(left, bool) and isinstance(right, bool) and left == right + if isinstance(left, dict) and isinstance(right, dict): + return left.keys() == right.keys() and all(json_equal(left[k], right[k]) for k in left) + if isinstance(left, list) and isinstance(right, list): + if len(left) != len(right): + return False + return all(json_equal(a, b) for a, b in zip(left, right, strict=True)) + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return left == right + return type(left) is type(right) and left == right + + +def is_noop(value: Any) -> bool: + """True for a JSON-LD no-op value: ``null``, ``[]``, or nested arrays of those. + + Such a value produces no triples, so its key legitimately disappears on the way back and + must not be reported as lost. + """ + if value is None: + return True + return isinstance(value, list) and all(is_noop(item) for item in value) + + +def lost_keys(before: Any, after: Any, path: str = "", lost: list[str] | None = None) -> list[str]: + """Property keys present in ``before`` but missing from ``after``, compared recursively.""" + if lost is None: + lost = [] + if is_noop(before): + return lost + + if isinstance(before, list): + if isinstance(after, list): + candidates = after + elif after is None: + candidates = [] + else: + candidates = [after] + for element in before: + if isinstance(element, (dict, list)) and not any( + not lost_keys(element, candidate, path, []) for candidate in candidates + ): + lost.append(f"{path}[]") + return lost + + if isinstance(before, dict): + if isinstance(after, dict): + target = after + elif isinstance(after, list): + target = next((x for x in after if isinstance(x, dict)), {}) + else: + target = {} + for key in before: + if key in _METADATA_KEYS or is_noop(before[key]): + continue + here = f"{path}.{key}" if path else key + if key not in target: + lost.append(here) + else: + lost_keys(before[key], target[key], here, lost) + return lost + + +@dataclass +class RoundtripResult: + """One instance through RDF and back.""" + + ok: bool = True + lost: list[str] = field(default_factory=list) + restored: Any = None + triples: int = 0 + method: str = "" + error: str | None = None + nquads: str = "" + + def to_dict(self, include_documents: bool = False) -> dict[str, Any]: + payload: dict[str, Any] = { + "ok": self.ok, + "lost": self.lost, + "triples": self.triples, + "method": self.method, + } + if self.error: + payload["error"] = self.error + if include_documents: + payload["restored"] = self.restored + payload["nquads"] = self.nquads + return payload + + +def roundtrip( + schema: dict[str, Any], + sample: Any, + context_ref: Any, + loader: DocumentLoader, + base: str | None = None, +) -> RoundtripResult: + """Round-trip an instance as a compliant export and report what was dropped. + + The declared ``rdf:type``(s) are materialised as ``@type`` unless the instance already + carries one, the document is projected to RDF and back, and it is reconstructed by framing + when the schema embeds objects or by plain compaction when it does not. + """ + result = RoundtripResult() + + # A scalar instance (a DataType leaf schema whose body is a bare string or boolean) has no + # properties to lose and cannot carry a @context; there is nothing to round-trip. + if not isinstance(sample, dict): + result.restored = sample + return result + + rdf_base = base if base is not None else context_ref + document: dict[str, Any] = {"@context": context_ref} + document.update({k: v for k, v in sample.items() if k != "@context"}) + + types = instance_rdf_types(schema) + if types and "type" not in sample and "@type" not in sample: + document["@type"] = list(types) + + try: + nquads = jsonld.to_rdf(document, loader.options(base=rdf_base, format="application/n-quads")) + result.nquads = nquads + result.triples = sum(1 for line in nquads.split("\n") if line.strip()) + + back = jsonld.from_rdf(nquads, {"format": "application/n-quads", "useNativeTypes": True}) + + if embedded_properties(schema): + result.method = "framed" + result.restored = jsonld.frame( + back, + schema_to_frame(schema, context_ref), + loader.options(base=rdf_base, omitDefault=True), + ) + else: + result.method = "compacted" + result.restored = jsonld.compact(back, context_ref, loader.options(base=rdf_base)) + except Exception as exc: + result.ok = False + result.error = describe_jsonld_error(exc) + return result + + result.lost = lost_keys(sample, result.restored) + result.ok = not result.lost + return result + + +def canonical_equal(before: Any, after: Any) -> bool: + """Whether an instance and its reconstruction are equal in canonical form.""" + return json_equal(canonical(before), canonical(after)) diff --git a/src/oold/validation/schema_checks.py b/src/oold/validation/schema_checks.py new file mode 100644 index 0000000..af5769c --- /dev/null +++ b/src/oold/validation/schema_checks.py @@ -0,0 +1,129 @@ +"""Schema-level checks: meta-schema well-formedness and ``$ref`` composition. + +Ports the first section of the reference harness (``validate.mjs`` lines 377-383): a schema is +a well-formed OO-LD schema when it validates against the OO-LD meta-schema, and its standard +``$ref`` composition resolves. + +The OO-LD meta-schema is 2020-12 plus the OO-LD and UI vocabularies, and it declares those +vocabularies optional so a generic 2020-12 validator still processes OO-LD schemas. Validating +against it therefore subsumes plain 2020-12 validation; there is no separate dialect check. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from jsonschema import Draft202012Validator +from jsonschema.exceptions import SchemaError + +from .meta_store import MetaBundle +from .resolve import DereferenceResult, ResolvedSchema, Resolver + +#: JSON-LD keywords that may legitimately appear at the root of an OO-LD schema. They are +#: unknown keywords as far as JSON Schema is concerned, and 2020-12 tolerates unknown keywords +#: as annotations, which is what lets an OO-LD schema be a valid JSON Schema at all. That +#: behaviour is load-bearing for this whole package, so the test suite asserts it directly +#: rather than assuming it. +JSONLD_KEYWORDS = frozenset({"@context", "@id", "@type", "@graph", "@vocab", "@base", "@version"}) + +#: Cap on how many meta-schema errors are reported for one schema. A single structural mistake +#: high in a document can produce hundreds of downstream errors, which buries the useful one. +MAX_REPORTED_ERRORS = 20 + + +@dataclass +class MetaValidationResult: + """Outcome of validating one schema against one meta-schema version.""" + + valid: bool + meta_version: str + errors: list[str] = field(default_factory=list) + truncated: int = 0 + declared_dialect: str | None = None + jsonld_keywords_found: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "valid": self.valid, + "meta_version": self.meta_version, + "errors": self.errors, + "declared_dialect": self.declared_dialect, + "jsonld_keywords_found": self.jsonld_keywords_found, + } + if self.truncated: + payload["errors_omitted"] = self.truncated + return payload + + +def format_error(error: Any) -> str: + """Render a validation error with the instance location that produced it.""" + location = "/".join(str(part) for part in error.absolute_path) + prefix = f"at /{location}: " if location else "" + return f"{prefix}{error.message}" + + +def validate_against_meta(schema: Any, bundle: MetaBundle) -> MetaValidationResult: + """Validate a schema document against the OO-LD meta-schema of one version. + + Errors come back as data, never as exceptions: a caller asking about a broken schema wants + the explanation, which is exactly the case where raising would destroy the answer. + """ + if not isinstance(schema, dict): + return MetaValidationResult( + valid=False, + meta_version=bundle.version, + errors=[f"schema root must be a JSON object, got {type(schema).__name__}"], + ) + + declared = schema.get("$schema") + found = sorted(key for key in schema if key in JSONLD_KEYWORDS) + + try: + raw = sorted( + bundle.meta_validator().iter_errors(schema), + key=lambda e: list(e.absolute_path), + ) + except Exception as exc: + return MetaValidationResult( + valid=False, + meta_version=bundle.version, + errors=[f"meta-schema validation could not run: {type(exc).__name__}: {exc}"], + declared_dialect=declared, + jsonld_keywords_found=found, + ) + + messages = [format_error(error) for error in raw] + truncated = max(0, len(messages) - MAX_REPORTED_ERRORS) + return MetaValidationResult( + valid=not messages, + meta_version=bundle.version, + errors=messages[:MAX_REPORTED_ERRORS], + truncated=truncated, + declared_dialect=declared, + jsonld_keywords_found=found, + ) + + +def check_usable_as_validator(schema: Any) -> list[str]: + """Check the schema can actually be compiled into a validator. + + Meta-schema validity and usability are not the same thing. A schema can satisfy the + meta-schema and still fail to compile, for instance through a malformed regex in + ``pattern``. The reference harness gets this implicitly from ``ajv.compile``. + """ + try: + Draft202012Validator.check_schema(schema) + except SchemaError as exc: + return [f"schema does not compile: {exc.message}"] + return [] + + +def check_refs_resolve(resolver: Resolver, resolved: ResolvedSchema) -> tuple[DereferenceResult, list[str]]: + """Dereference a schema's ``$ref`` composition, reporting anything that did not resolve. + + The equivalent of ``$RefParser.dereference`` in the reference harness. Like it, this + follows remote references as well as local ones; unlike it, results are cached. + """ + result = resolver.dereference(resolved) + return result, list(result.unresolved) diff --git a/tests/data/format_parity.json b/tests/data/format_parity.json new file mode 100644 index 0000000..b9537df --- /dev/null +++ b/tests/data/format_parity.json @@ -0,0 +1,136 @@ +{ + "$comment": "Expected format-assertion outcomes, captured from the reference toolchain: ajv + ajv-formats (full mode) + ajv-formats-draft2019, with validate.mjs iri/iri-reference override applied. Regenerate with scripts described in docs/how-to/validation.md.", + "source": "oold-schema scripts/validate.mjs ajv setup", + "formats": { + "date": { + "2026-01-02": true, + "2026-13-01": false, + "2026-02-30": false, + "2026-02-28": true, + "not-a-date": false, + "2026-1-2": false, + "2024-02-29": true, + "2026-02-29": false + }, + "date-time": { + "2026-01-02T03:04:05Z": true, + "2026-01-02 03:04:05Z": true, + "2026-01-02T03:04:05+02:00": true, + "2026-01-02T25:00:00Z": false, + "2026-01-02T03:04:05": false, + "2026-12-31T23:59:60Z": true, + "2026-02-30T00:00:00Z": false, + "2026-01-02t03:04:05z": true, + "2026-01-02T03:04:05.123Z": true + }, + "time": { + "03:04:05Z": true, + "03:04:05": false, + "23:59:60Z": true, + "25:00:00": false, + "23:60:00": false, + "12:00:00+02:00": true, + "12:00:00+0200": true, + "12:00:00.5Z": true + }, + "duration": { + "P1DT2H": true, + "P1Y": true, + "PT1S": true, + "P1W": true, + "1D": false, + "P": false, + "PT": false, + "P1Y2M3DT4H5M6S": true, + "P1YT": false + }, + "email": { + "someone@example.org": true, + "a.b+c@sub.example.co.uk": true, + "not-an-email": false, + "a@@b.com": false, + "a@b": false, + "a@b.c": true, + ".a@b.co": false, + "a.@b.co": false + }, + "uuid": { + "00000000-0000-4000-8000-000000000000": true, + "not-a-uuid": false, + "00000000000040008000000000000000": false, + "urn:uuid:00000000-0000-4000-8000-000000000000": true + }, + "ipv4": { + "192.0.2.1": true, + "999.0.0.1": false, + "1.2.3": false, + "0.0.0.0": true + }, + "ipv6": { + "2001:db8::1": true, + "::1": true, + "not::a::v6": false, + "fe80::1%eth0": false + }, + "hostname": { + "example.org": true, + "a-b.example.org": true, + "-bad.example.org": false, + "example..org": false, + "localhost": true, + "example.org.": true + }, + "json-pointer": { + "/example": true, + "": true, + "/a~0b": true, + "no-slash": false, + "/a/b/c": true, + "/a~2b": false + }, + "relative-json-pointer": { + "1": true, + "0/example": true, + "0#": true, + "x/y": false + }, + "regex": { + "^example$": true, + "([unclosed": false, + "a{2,3}": true + }, + "uri": { + "https://example.org/thing": true, + "urn:uuid:abc": true, + "ex:alice": true, + "relative/path": false, + "https://exa mple.org": false, + "mailto:a@b.co": true, + "//example.org/x": false + }, + "uri-reference": { + "https://example.org/thing": true, + "relative/path": true, + "ex:alice": true, + "has space": false, + "#frag": true, + "": true + }, + "iri": { + "https://example.org/thing": true, + "ex:alice": true, + "urn:uuid:abc": true, + "relative/path": false, + "not an iri": false, + "https://example.org/ünïcode": true + }, + "iri-reference": { + "https://example.org/thing": true, + "ex:alice": true, + "Thing.schema.json": true, + "has\"quote": false, + "https://example.org/ünïcode": true, + "": true + } + } +} diff --git a/tests/data/oold/Address.schema.json b/tests/data/oold/Address.schema.json new file mode 100644 index 0000000..eb1c102 --- /dev/null +++ b/tests/data/oold/Address.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "Address.schema.json", + "x-oold-uuid": "e8536464-a654-49ee-bd44-df60424b73b1", + "x-oold-version": "1.0.0", + "x-oold-instance-rdf-type": ["schema:PostalAddress"], + "@context": { + "@version": 1.1, + "schema": "http://schema.org/", + "country": "schema:addressCountry", + "streetAddress": "schema:streetAddress" + }, + "title": "Address", + "x-oold-multilang-title": { "en": "Address", "de": "Adresse" }, + "type": "object", + "properties": { + "country": { "type": "string", "description": "ISO 3166-1 alpha-2 country code" }, + "streetAddress": { "type": "string", "description": "Street address" } + } +} diff --git a/tests/data/oold/Contact.schema.json b/tests/data/oold/Contact.schema.json new file mode 100644 index 0000000..3ae80c3 --- /dev/null +++ b/tests/data/oold/Contact.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "Contact.schema.json", + "x-oold-uuid": "f3a1c2d4-5b6e-47a8-9c0d-1e2f3a4b5c6d", + "x-oold-version": "1.0.0", + "x-oold-instance-rdf-type": ["schema:Organization"], + "@context": [ + "Thing.schema.json", + { + "schema": "http://schema.org/", + "address": { + "@id": "schema:address", + "@context": { + "PostalAddress": "schema:PostalAddress", + "streetAddress": "schema:streetAddress", + "postalCode": "schema:postalCode" + } + } + } + ], + "title": "Contact", + "x-oold-multilang-title": { "en": "Contact", "de": "Kontakt" }, + "allOf": [{ "$ref": "Thing.schema.json" }], + "type": "object", + "properties": { + "address": { + "description": "An address given as free text, as a reference to a Place by IRI, or as an embedded PostalAddress. This is the value-form pattern: a single plain term whose value shape disambiguates (see the specification, Property value forms).", + "x-oold-range": ["schema:Text", "schema:PostalAddress", "schema:Place"], + "anyOf": [ + { "type": "string", "description": "Literal address text" }, + { + "type": "object", + "description": "Reference to a Place by IRI", + "required": ["id"], + "properties": { "id": { "type": "string", "format": "iri-reference" } } + }, + { + "type": "object", + "description": "Embedded PostalAddress", + "properties": { + "type": { "type": "string", "default": "PostalAddress" }, + "streetAddress": { "type": "string" }, + "postalCode": { "type": "string" } + } + } + ] + } + } +} diff --git a/tests/data/oold/ContactSeparateKeys.schema.json b/tests/data/oold/ContactSeparateKeys.schema.json new file mode 100644 index 0000000..b854814 --- /dev/null +++ b/tests/data/oold/ContactSeparateKeys.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "ContactSeparateKeys.schema.json", + "x-oold-uuid": "a7b8c9d0-1e2f-4a3b-8c5d-6e7f8a9b0c1d", + "x-oold-version": "1.0.0", + "x-oold-instance-rdf-type": ["schema:Organization"], + "@context": [ + "Thing.schema.json", + { + "schema": "http://schema.org/", + "address": { + "@id": "schema:address", + "@type": "@id", + "@context": { + "PostalAddress": "schema:PostalAddress", + "streetAddress": "schema:streetAddress", + "postalCode": "schema:postalCode" + } + }, + "address_text": { "@id": "schema:address" } + } + ], + "title": "Contact (separate keys)", + "x-oold-multilang-title": { "en": "Contact (separate keys)", "de": "Kontakt (getrennte Schluessel)" }, + "allOf": [{ "$ref": "Thing.schema.json" }], + "type": "object", + "properties": { + "address": { + "description": "A reference to a Place by IRI, or an embedded PostalAddress. This is the separate-keys pattern (see the specification, Property value forms): the canonical @type:@id term carries references and embedded objects, while literal address text goes in the companion `address_text`. Both map to schema:address; on the way back from RDF a literal routes to `address_text` and a node/IRI to `address`.", + "x-oold-range": ["schema:PostalAddress", "schema:Place"], + "anyOf": [ + { "type": "string", "format": "iri-reference", "description": "Reference to a Place by IRI (bare IRI string)" }, + { + "type": "object", + "description": "Embedded PostalAddress", + "properties": { + "type": { "type": "string", "default": "PostalAddress" }, + "streetAddress": { "type": "string" }, + "postalCode": { "type": "string" } + } + } + ] + }, + "address_text": { "type": "string", "description": "Literal address text (companion of the canonical `address` term)" } + } +} diff --git a/tests/data/oold/Minimal.schema.json b/tests/data/oold/Minimal.schema.json new file mode 100644 index 0000000..4d3efc2 --- /dev/null +++ b/tests/data/oold/Minimal.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "Minimal.schema.json", + "@context": { + "schema": "http://schema.org/", + "name": "schema:name" + }, + "title": "Minimal", + "type": "object", + "properties": { + "name": { "type": "string", "description": "Name of the thing" } + } +} diff --git a/tests/data/oold/Organization.schema.json b/tests/data/oold/Organization.schema.json new file mode 100644 index 0000000..f9f3b83 --- /dev/null +++ b/tests/data/oold/Organization.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "Organization.schema.json", + "x-oold-uuid": "c6314242-8432-57cc-9b22-bd4e2026951f", + "x-oold-version": "1.0.0", + "x-oold-instance-rdf-type": ["schema:Organization"], + "@context": [ + "Thing.schema.json", + { + "schema": "http://schema.org/", + "address": { "@id": "schema:address", "@context": "Address.schema.json" } + } + ], + "title": "Organization", + "x-oold-multilang-title": { "en": "Organization", "de": "Organisation" }, + "allOf": [ { "$ref": "Thing.schema.json" } ], + "type": "object", + "properties": { + "address": { + "type": "object", + "$ref": "Address.schema.json", + "description": "Postal address of the organization" + } + } +} diff --git a/tests/data/oold/OwlOrganization.instance.json b/tests/data/oold/OwlOrganization.instance.json new file mode 100644 index 0000000..4de357b --- /dev/null +++ b/tests/data/oold/OwlOrganization.instance.json @@ -0,0 +1,7 @@ +{ + "$schema": "OwlOrganization.schema.json", + "@context": "OwlOrganization.schema.json", + "id": "ex:acme", + "type": "schema:Organization", + "employee": ["ex:alice"] +} diff --git a/tests/data/oold/OwlOrganization.schema.json b/tests/data/oold/OwlOrganization.schema.json new file mode 100644 index 0000000..7fcaa53 --- /dev/null +++ b/tests/data/oold/OwlOrganization.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "OwlOrganization.schema.json", + "x-oold-iri": "schema:Organization", + "@context": { + "id": "@id", + "type": "@type", + "ex": "https://example.org/", + "schema": "http://schema.org/", + "employee": { "@id": "schema:employee", "@type": "@id", "@container": "@set" } + }, + "title": "Organization", + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { "type": "string", "const": "schema:Organization" }, + "employee": { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference", + "x-oold-range": "RdfPerson.schema.json" + } + } + } +} diff --git a/tests/data/oold/Person.schema.json b/tests/data/oold/Person.schema.json new file mode 100644 index 0000000..fc05a9d --- /dev/null +++ b/tests/data/oold/Person.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "Person.schema.json", + "x-oold-uuid": "b5203131-7321-46bb-8a11-acb3d1015840", + "x-oold-version": "1.0.0", + "x-oold-instance-rdf-type": ["schema:Person"], + "@context": [ + "Thing.schema.json", + { + "schema": "http://schema.org/", + "works_for": { "@id": "schema:worksFor", "@type": "@id" } + } + ], + "title": "Person", + "x-oold-multilang-title": { "en": "Person", "de": "Person" }, + "allOf": [ { "$ref": "Thing.schema.json" } ], + "type": "object", + "properties": { + "works_for": { + "type": "string", + "format": "iri-reference", + "description": "Organization the person works for (IRI reference)", + "x-oold-range": "Organization.schema.json" + } + } +} diff --git a/tests/data/oold/PersonWithPet.instance.json b/tests/data/oold/PersonWithPet.instance.json new file mode 100644 index 0000000..e6f8db7 --- /dev/null +++ b/tests/data/oold/PersonWithPet.instance.json @@ -0,0 +1,6 @@ +{ + "@context": "PersonWithPet.schema.json", + "$schema": "PersonWithPet.schema.json", + "name": "Max", + "pets": [ { "name": "Bruno" } ] +} diff --git a/tests/data/oold/PersonWithPet.schema.json b/tests/data/oold/PersonWithPet.schema.json new file mode 100644 index 0000000..5b48a8f --- /dev/null +++ b/tests/data/oold/PersonWithPet.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "PersonWithPet.schema.json", + "@context": { + "ex": "https://example.org/", + "schema": "http://schema.org/", + "name": "schema:name", + "pets": { "@id": "ex:hasPet", "@container": "@set", "@context": "Pet.schema.json" } + }, + "title": "Person with pet", + "type": "object", + "properties": { + "name": { "type": "string", "description": "Name of the person" }, + "pets": { + "type": "array", + "description": "Pets owned by the person (embedded via a property-scoped context)", + "items": { "$ref": "Pet.schema.json" } + } + } +} diff --git a/tests/data/oold/Pet.schema.json b/tests/data/oold/Pet.schema.json new file mode 100644 index 0000000..c0f0c6d --- /dev/null +++ b/tests/data/oold/Pet.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "Pet.schema.json", + "@context": { + "ex": "https://example.org/", + "name": "ex:petName" + }, + "title": "Pet", + "type": "object", + "properties": { + "name": { "type": "string", "description": "Name of the pet" } + } +} diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md new file mode 100644 index 0000000..f09cc54 --- /dev/null +++ b/tests/data/oold/README.md @@ -0,0 +1,47 @@ +# OO-LD test fixtures + +A snapshot of [oold-schema](https://github.com/OO-LD/oold-schema) `examples/`, taken at tag +**v0.7.0** - the same release the tracked meta-schemas in `src/oold/validation/meta/0.7.0/` come +from. + +That pairing matters. A compliance fixture asserts the lint rules of the version that introduced +them, so combining a newer fixture set with an older meta-schema produces failures that say +nothing about this code. Upstream's current `main` is covered instead by the opt-in parity tests +(`tests/test_validation/test_parity_live.py`), which validate against `--meta remote`. + +``` +. examples/ from v0.7.0, plus compliance/ +broken/ deliberately broken schemas: the checks must fail on these +remote_context/ a schema whose @context chain leaves its directory +``` + +## Refreshing the snapshot + +When a new oold-schema version is tracked in `src/oold/validation/meta/`, refresh this slice from +the *same tag* so the two stay in step: + +```bash +V=0.8.0 +DEST=tests/data/oold +for f in $(git -C ../oold-schema ls-tree --name-only v$V examples/ | grep '\.json$'); do + git -C ../oold-schema show "v$V:$f" > "$DEST/$(basename $f)" +done +for f in $(git -C ../oold-schema ls-tree --name-only v$V examples/compliance/); do + git -C ../oold-schema show "v$V:$f" > "$DEST/compliance/$(basename $f)" +done +make validate +``` + +## Broken fixtures + +Each one exists to prove a specific check fires, rather than only that valid input passes. +`tests/test_validation/test_pipeline.py` maps each file to the check it must trip. + +| Fixture | Trips | +|---|---| +| `invalid_meta` | `schema.meta` - `x-oold-uuid` is not a UUID, so `format` has to be asserted | +| `missing_context_term` | `roundtrip.generated`, `context.predicates` - a property with no `@context` term | +| `undefined_prefix` | `context.predicates` - expands to a syntactically absolute IRI that means nothing | +| `unresolvable_context_ref` | `context.predicates` - the `@context` chain points at a missing schema | +| `xsd_string_coercion` | `lint.pattern` - a term coercing a literal to `xsd:string` never round-trips | +| `array_without_container` | `lint.container` - a strict array without `@container: @set` | diff --git a/tests/data/oold/RdfPerson.instance.json b/tests/data/oold/RdfPerson.instance.json new file mode 100644 index 0000000..25fcb29 --- /dev/null +++ b/tests/data/oold/RdfPerson.instance.json @@ -0,0 +1,7 @@ +{ + "$schema": "RdfPerson.schema.json", + "@context": "RdfPerson.schema.json", + "id": "ex:alice", + "type": "schema:Person", + "name": "Alice" +} diff --git a/tests/data/oold/RdfPerson.schema.json b/tests/data/oold/RdfPerson.schema.json new file mode 100644 index 0000000..e025778 --- /dev/null +++ b/tests/data/oold/RdfPerson.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "RdfPerson.schema.json", + "x-oold-iri": "schema:Person", + "@context": { + "id": "@id", + "type": "@type", + "ex": "https://example.org/", + "schema": "http://schema.org/", + "name": "schema:name" + }, + "title": "Person", + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { "type": "string", "const": "schema:Person" }, + "name": { "type": "string" } + } +} diff --git a/tests/data/oold/Researcher.schema.json b/tests/data/oold/Researcher.schema.json new file mode 100644 index 0000000..6ad9693 --- /dev/null +++ b/tests/data/oold/Researcher.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "Researcher.schema.json", + "x-oold-uuid": "d7425353-9543-48dd-ac33-ce5f3137a62a", + "x-oold-version": "1.0.0", + "x-oold-instance-rdf-type": ["schema:Person"], + "@context": [ + "Person.schema.json", + { + "schema": "http://schema.org/", + "affiliation": { "@id": "schema:affiliation", "@type": "@id" } + } + ], + "title": "Researcher", + "x-oold-multilang-title": { "en": "Researcher", "de": "Forscher" }, + "allOf": [ { "$ref": "Person.schema.json" } ], + "type": "object", + "properties": { + "affiliation": { + "type": "string", + "format": "iri-reference", + "description": "A German organization the researcher is affiliated with (IRI reference)", + "x-oold-range": { + "allOf": [ + { "x-oold-ref": "Organization.schema.json" }, + { "properties": { "address": { "properties": { "country": { "const": "DE" } } } } } + ] + } + } + } +} diff --git a/tests/data/oold/Thing.schema.json b/tests/data/oold/Thing.schema.json new file mode 100644 index 0000000..bf8d67e --- /dev/null +++ b/tests/data/oold/Thing.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "Thing.schema.json", + "x-oold-uuid": "1b4de2a0-9d3f-4c2e-9a1b-0e7c5f8a2d10", + "x-oold-version": "1.0.0", + "x-oold-instance-rdf-type": ["schema:Thing"], + "@context": { + "@version": 1.1, + "id": "@id", + "type": "@type", + "schema": "http://schema.org/", + "name": "schema:name" + }, + "title": "Thing", + "x-oold-multilang-title": { "en": "Thing", "de": "Ding" }, + "type": "object", + "properties": { + "id": { "type": "string", "format": "iri", "description": "IRI of the entity" }, + "name": { "type": "string", "description": "Name of the thing" } + } +} diff --git a/tests/data/oold/UiAnnotations.schema.json b/tests/data/oold/UiAnnotations.schema.json new file mode 100644 index 0000000..a3a287c --- /dev/null +++ b/tests/data/oold/UiAnnotations.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$id": "UiAnnotations.schema.json", + "@context": [ + { + "@version": 1.1, + "schema": "https://schema.org/", + "name": "schema:name", + "role": "schema:jobTitle", + "homepage": { "@id": "schema:url", "@type": "@id" }, + "notes": "schema:comment", + "internal_id": "schema:identifier" + } + ], + "x-oold-uuid": "b1e7a0c2-2d4f-4a1e-9c3a-7f0e5d2b6a11", + "title": "Researcher", + "x-oold-multilang-title": { "en": "Researcher", "de": "Forschende Person" }, + "x-oold-iri": "schema:Person", + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name", + "x-oold-ui-default-property": true, + "x-oold-ui-property-order": 1, + "x-oold-ui-property-group": "General", + "x-oold-ui-hint": "Full name", + "x-oold-multilang-ui-hint": { "en": "Full name", "de": "Vollständiger Name" } + }, + "role": { + "type": "string", + "title": "Role", + "enum": ["pi", "postdoc", "phd"], + "x-enum-varnames": ["PrincipalInvestigator", "PostDoc", "PhDStudent"], + "x-enum-descriptions": ["Leads the project", "Holds a doctorate", "Doctoral candidate"], + "x-oold-ui-enum-titles": ["Principal investigator", "Postdoc", "PhD student"], + "x-oold-multilang-ui-enum-titles": { "en": ["Principal investigator", "Postdoc", "PhD student"], "de": ["Projektleitung", "Postdoc", "Doktorand"] }, + "x-oold-ui-property-order": 2, + "x-oold-ui-property-group": "General" + }, + "homepage": { + "type": "string", + "title": "Homepage", + "format": "uri", + "x-oold-ui-property-group": "Contact" + }, + "notes": { + "type": "string", + "title": "Notes", + "x-oold-ui-widget": "markdown", + "x-oold-ui-property-group": "Contact" + }, + "internal_id": { + "type": "string", + "title": "Internal id", + "x-oold-ui-form-hidden": true + } + } +} diff --git a/tests/data/oold/UiOverlay.json b/tests/data/oold/UiOverlay.json new file mode 100644 index 0000000..3508663 --- /dev/null +++ b/tests/data/oold/UiOverlay.json @@ -0,0 +1,27 @@ +{ + "overlay": "1.0.0", + "info": { + "title": "Researcher admin overlay", + "version": "1.0.0", + "description": "Applies presentation-only x-oold-ui-* keywords to Researcher without editing the schema. An overlay is a general schema-patching mechanism (OpenAPI Overlay 1.1.0); UI annotation is one use case." + }, + "extends": "./UiAnnotations.schema.json", + "actions": [ + { + "target": "$.properties.internal_id", + "description": "Show the internal id for admins.", + "update": { "x-oold-ui-form-hidden": false } + }, + { + "target": "$.properties.notes", + "description": "Render notes as a plain textarea instead of markdown.", + "update": { "x-oold-ui-widget": "textarea" } + }, + { + "target": "$.properties.role", + "description": "Drop the render-time grouping for a flat admin form.", + "remove": false, + "update": { "x-oold-ui-property-group": "Identity" } + } + ] +} diff --git a/tests/data/oold/broken/array_without_container.schema.json b/tests/data/oold/broken/array_without_container.schema.json new file mode 100644 index 0000000..3900866 --- /dev/null +++ b/tests/data/oold/broken/array_without_container.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "array_without_container.schema.json", + "title": "ArrayWithoutContainer", + "@context": { + "ex": "https://example.org/", + "tags": { "@id": "ex:tags" } + }, + "type": "object", + "properties": { + "tags": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/tests/data/oold/broken/invalid_meta.schema.json b/tests/data/oold/broken/invalid_meta.schema.json new file mode 100644 index 0000000..b2052c1 --- /dev/null +++ b/tests/data/oold/broken/invalid_meta.schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "invalid_meta.schema.json", + "title": "InvalidMeta", + "x-oold-uuid": "not-a-uuid", + "@context": { "ex": "https://example.org/", "name": "ex:name" }, + "type": "object", + "properties": { "name": { "type": "string" } } +} diff --git a/tests/data/oold/broken/missing_context_term.schema.json b/tests/data/oold/broken/missing_context_term.schema.json new file mode 100644 index 0000000..f8ded75 --- /dev/null +++ b/tests/data/oold/broken/missing_context_term.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "missing_context_term.schema.json", + "title": "MissingContextTerm", + "@context": { + "ex": "https://example.org/", + "name": "ex:name" + }, + "type": "object", + "properties": { + "name": { "type": "string" }, + "orphan": { "type": "string" } + } +} diff --git a/tests/data/oold/broken/undefined_prefix.schema.json b/tests/data/oold/broken/undefined_prefix.schema.json new file mode 100644 index 0000000..4e8bc9b --- /dev/null +++ b/tests/data/oold/broken/undefined_prefix.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "undefined_prefix.schema.json", + "title": "UndefinedPrefix", + "@context": { + "latitude": "schema:latitude" + }, + "type": "object", + "properties": { + "latitude": { "type": "number" } + } +} diff --git a/tests/data/oold/broken/unresolvable_context_ref.schema.json b/tests/data/oold/broken/unresolvable_context_ref.schema.json new file mode 100644 index 0000000..8c9451c --- /dev/null +++ b/tests/data/oold/broken/unresolvable_context_ref.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "unresolvable_context_ref.schema.json", + "title": "UnresolvableContextRef", + "@context": [ + "NoSuchSchema.schema.json", + { "ex": "https://example.org/", "name": "ex:name" } + ], + "type": "object", + "properties": { "name": { "type": "string" } } +} diff --git a/tests/data/oold/broken/xsd_string_coercion.schema.json b/tests/data/oold/broken/xsd_string_coercion.schema.json new file mode 100644 index 0000000..6a9bda6 --- /dev/null +++ b/tests/data/oold/broken/xsd_string_coercion.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "xsd_string_coercion.schema.json", + "title": "XsdStringCoercion", + "@context": { + "ex": "https://example.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "name": { "@id": "ex:name", "@type": "xsd:string" } + }, + "type": "object", + "properties": { "name": { "type": "string" } } +} diff --git a/tests/data/oold/compliance/jsonld-features.json b/tests/data/oold/compliance/jsonld-features.json new file mode 100644 index 0000000..a4ded92 --- /dev/null +++ b/tests/data/oold/compliance/jsonld-features.json @@ -0,0 +1,27 @@ +[ + { + "$comment": "OO-LD-specific JSON-LD constructs only (not vanilla JSON-LD). Each group names an example schema via schemaRef; the instance references it as a remote @context, so the OO-LD composition (base-class context inheritance, property-$ref scoped context) is exercised end to end. `valid` checks JSON Schema validation; `expectRdf` checks the resulting RDF by dataset isomorphism (jsonld.canonize).", + "feature": "inheritance: a subclass inherits its base classes' @context and JSON Schema (Researcher -> Person -> Thing)", + "schemaRef": "Researcher.schema.json", + "tests": [ + { + "description": "name (from Thing), works_for (from Person) and affiliation (from Researcher) all resolve via the inherited contexts", + "data": { "@context": "Researcher.schema.json", "$schema": "Researcher.schema.json", "id": "https://example.org/alice", "name": "Alice", "works_for": "https://example.org/acme", "affiliation": "https://example.org/uni" }, + "valid": true, + "expectRdf": " .\n \"Alice\" .\n .\n" + } + ] + }, + { + "feature": "composition: a property $ref pulls in the referenced schema's @context as a property-scoped context (Organization.address -> Address)", + "schemaRef": "Organization.schema.json", + "tests": [ + { + "description": "the nested address expands with Address's scoped context (country -> schema:addressCountry, streetAddress -> schema:streetAddress)", + "data": { "@context": "Organization.schema.json", "$schema": "Organization.schema.json", "id": "https://example.org/acme", "name": "ACME", "address": { "country": "DE", "streetAddress": "Main St 1" } }, + "valid": true, + "expectRdf": " _:b0 .\n \"ACME\" .\n_:b0 \"DE\" .\n_:b0 \"Main St 1\" .\n" + } + ] + } +] diff --git a/tests/data/oold/compliance/oold-vocab.json b/tests/data/oold/compliance/oold-vocab.json new file mode 100644 index 0000000..effded9 --- /dev/null +++ b/tests/data/oold/compliance/oold-vocab.json @@ -0,0 +1,85 @@ +[ + { + "$comment": "Keep in sync with meta/oold-meta-schema.json and meta/oold-ui-meta-schema.json: scripts/validate.mjs asserts every x-oold-* / x-oold-ui-* keyword defined there is covered by at least one case below. Each `schemas` entry is checked against the OO-LD meta-schema (well-formedness), not an instance.", + "description": "a fully annotated OO-LD schema is well-formed (covers every x-oold-* and x-oold-ui-* keyword)", + "schemas": [ + { + "description": "all keywords well-formed", + "valid": true, + "schema": { + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "x-oold-uuid": "b5203131-7321-46bb-8a11-acb3d1015840", + "x-oold-version": "1.0.0", + "x-oold-prior-version": "0.9.0", + "x-oold-backward-compatible-with": "0.9.0/Person.schema.json", + "x-oold-incompatible-with": "0.8.0/Person.schema.json", + "x-oold-iri": "schema:Person", + "x-oold-instance-rdf-type": ["schema:Person"], + "x-oold-multilang-title": { "en": "Person", "de": "Person" }, + "x-oold-multilang-description": { "en": "A person", "de": "Eine Person" }, + "x-oold-context": { "name": { "skos:prefLabel": {} } }, + "x-oold-reverse-properties": { "employees": { "type": "array", "title": "Employees" } }, + "x-oold-reverse-required": ["employees"], + "x-oold-reverse-default-properties": ["employees"], + "type": "object", + "properties": { + "ref": { "type": "string", "x-oold-range": { "allOf": [{ "x-oold-ref": "Person.schema.json" }] } }, + "role": { + "type": "string", + "enum": ["pi", "postdoc"], + "x-oold-ui-widget": "select", + "x-oold-ui-property-order": 1, + "x-oold-ui-property-group": "General", + "x-oold-ui-form-hidden": false, + "x-oold-ui-render-hidden": false, + "x-oold-ui-enum-titles": ["PI", "Postdoc"], + "x-oold-multilang-ui-enum-titles": { "en": ["PI", "Postdoc"] }, + "x-oold-ui-hint": "Role in the project", + "x-oold-multilang-ui-hint": { "en": "Role in the project" }, + "x-oold-ui-default-property": true, + "x-enum-varnames": ["PrincipalInvestigator", "PostDoc"], + "x-enum-descriptions": ["Leads the project", "Holds a doctorate"] + } + } + } + } + ] + }, + { + "description": "core x-oold-* keywords reject malformed values", + "schemas": [ + { "description": "x-oold-uuid not a uuid", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-uuid": "nope" } }, + { "description": "x-oold-version not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-version": 1 } }, + { "description": "x-oold-prior-version not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-prior-version": 1 } }, + { "description": "x-oold-backward-compatible-with not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-backward-compatible-with": 1 } }, + { "description": "x-oold-incompatible-with not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-incompatible-with": 1 } }, + { "description": "x-oold-iri not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-iri": 1 } }, + { "description": "x-oold-instance-rdf-type not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-instance-rdf-type": "schema:Person" } }, + { "description": "x-oold-ref not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ref": 1 } }, + { "description": "x-oold-range as a number", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-range": 42 } }, + { "description": "x-oold-multilang-title not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-multilang-title": "Person" } }, + { "description": "x-oold-multilang-description not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-multilang-description": "a person" } }, + { "description": "x-oold-context not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-context": "x" } }, + { "description": "x-oold-reverse-properties not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-reverse-properties": "x" } }, + { "description": "x-oold-reverse-required not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-reverse-required": "x" } }, + { "description": "x-oold-reverse-default-properties not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-reverse-default-properties": "x" } } + ] + }, + { + "description": "UI x-oold-ui-* / x-enum-* keywords reject malformed values", + "schemas": [ + { "description": "x-oold-ui-widget not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-widget": 1 } }, + { "description": "x-oold-ui-property-order not an integer", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-property-order": "first" } }, + { "description": "x-oold-ui-property-group not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-property-group": 1 } }, + { "description": "x-oold-ui-form-hidden not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-form-hidden": "x" } }, + { "description": "x-oold-ui-render-hidden not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-render-hidden": "x" } }, + { "description": "x-oold-ui-enum-titles not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-enum-titles": "x" } }, + { "description": "x-oold-multilang-ui-enum-titles not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-multilang-ui-enum-titles": "x" } }, + { "description": "x-oold-ui-hint not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-hint": 1 } }, + { "description": "x-oold-multilang-ui-hint not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-multilang-ui-hint": 1 } }, + { "description": "x-oold-ui-default-property not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-default-property": "x" } }, + { "description": "x-enum-varnames not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-enum-varnames": "x" } }, + { "description": "x-enum-descriptions not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-enum-descriptions": "x" } } + ] + } +] diff --git a/tests/data/oold/compliance/roundtrip-patterns.json b/tests/data/oold/compliance/roundtrip-patterns.json new file mode 100644 index 0000000..e2ae1b6 --- /dev/null +++ b/tests/data/oold/compliance/roundtrip-patterns.json @@ -0,0 +1,254 @@ +[ + { + "$comment": "Round-trip-safe projection of an ambiguous property range (literal | reference | embedded object), using the address = Text | PostalAddress | Place example from the specification (Property value forms, Projection to RDF and round-trip). The `lintSchemas` group is checked against meta/oold-pattern-lint.schema.json; the `tests` group projects each value form to RDF (expectRdf, dataset isomorphism).", + "description": "the pattern lint rejects a literal term coerced to xsd:string, accepts a plain literal term", + "lintSchemas": [ + { + "description": "a plain literal term (no @type) round-trips - lint passes", + "valid": true, + "schema": { + "@context": { + "schema": "http://schema.org/", + "address": { "@id": "schema:address" } + } + } + }, + { + "description": "a non-default datatype (xsd:date) coerces and round-trips - lint passes", + "valid": true, + "schema": { + "@context": { + "schema": "http://schema.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "founded": { "@id": "schema:foundingDate", "@type": "xsd:date" } + } + } + }, + { + "description": "@type: xsd:string is never selected on the way back from RDF - lint fails (CURIE form)", + "valid": false, + "schema": { + "@context": { + "schema": "http://schema.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "address": { "@id": "schema:address", "@type": "xsd:string" } + } + } + }, + { + "description": "@type as the full XSD string IRI is rejected the same way, incl. in a scoped @context", + "valid": false, + "schema": { + "@context": { + "schema": "http://schema.org/", + "address": { + "@id": "schema:address", + "@context": { + "streetAddress": { "@id": "schema:streetAddress", "@type": "http://www.w3.org/2001/XMLSchema#string" } + } + } + } + } + } + ] + }, + { + "feature": "value-form: one plain `address` term projects a literal, a reference, and an embedded object to distinct RDF shapes", + "schema": { + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "x-oold-instance-rdf-type": ["schema:Organization"], + "@context": { + "schema": "http://schema.org/", + "id": "@id", + "type": "@type", + "address": { + "@id": "schema:address", + "@context": { + "PostalAddress": "schema:PostalAddress", + "streetAddress": "schema:streetAddress", + "postalCode": "schema:postalCode" + } + } + }, + "type": "object", + "properties": { + "id": { "type": "string" }, + "address": { + "x-oold-range": ["schema:Text", "schema:PostalAddress", "schema:Place"], + "anyOf": [ + { "type": "string" }, + { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } } }, + { "type": "object", "properties": { "type": {}, "streetAddress": { "type": "string" }, "postalCode": { "type": "string" } } } + ] + } + } + }, + "tests": [ + { + "description": "a bare string value becomes a plain literal", + "data": { "id": "https://example.org/acme", "address": "Mainstreet 1, 10115 Example City" }, + "valid": true, + "expectRdf": " \"Mainstreet 1, 10115 Example City\" .\n" + }, + { + "description": "an object carrying only id becomes an IRI reference", + "data": { "id": "https://example.org/acme", "address": { "id": "https://example.org/address/A1" } }, + "valid": true, + "expectRdf": " .\n" + }, + { + "description": "a typed object becomes a blank node with rdf:type and its own properties", + "data": { "id": "https://example.org/acme", "address": { "type": "PostalAddress", "streetAddress": "Mainstreet 1", "postalCode": "10115" } }, + "valid": true, + "expectRdf": "_:b0 \"10115\" .\n_:b0 \"Mainstreet 1\" .\n_:b0 .\n _:b0 .\n" + } + ] + }, + { + "feature": "separate-keys: a canonical @type:@id `address` term (reference or embedded object) plus a plain `address_text` companion (literal), all projecting to schema:address", + "schema": { + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "x-oold-instance-rdf-type": ["schema:Organization"], + "@context": { + "schema": "http://schema.org/", + "id": "@id", + "type": "@type", + "address": { + "@id": "schema:address", + "@type": "@id", + "@context": { + "PostalAddress": "schema:PostalAddress", + "streetAddress": "schema:streetAddress", + "postalCode": "schema:postalCode" + } + }, + "address_text": { "@id": "schema:address" } + }, + "type": "object", + "properties": { + "id": { "type": "string" }, + "address": { + "x-oold-range": ["schema:PostalAddress", "schema:Place"], + "anyOf": [ + { "type": "string" }, + { "type": "object", "properties": { "type": {}, "streetAddress": { "type": "string" }, "postalCode": { "type": "string" } } } + ] + }, + "address_text": { "type": "string" } + } + }, + "tests": [ + { + "description": "the literal is written under the plain address_text companion", + "data": { "id": "https://example.org/acme", "address_text": "Mainstreet 1, 10115 Example City" }, + "valid": true, + "expectRdf": " \"Mainstreet 1, 10115 Example City\" .\n" + }, + { + "description": "a bare IRI string under the @type:@id address term is a reference", + "data": { "id": "https://example.org/acme", "address": "https://example.org/address/A1" }, + "valid": true, + "expectRdf": " .\n" + }, + { + "description": "a typed object under the address term is still an embedded blank node", + "data": { "id": "https://example.org/acme", "address": { "type": "PostalAddress", "streetAddress": "Mainstreet 1", "postalCode": "10115" } }, + "valid": true, + "expectRdf": "_:b0 \"10115\" .\n_:b0 \"Mainstreet 1\" .\n_:b0 .\n _:b0 .\n" + } + ] + }, + { + "feature": "language-tagged text: an @language term projects a string to a language-tagged literal", + "schema": { + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "x-oold-instance-rdf-type": ["schema:Organization"], + "@context": { + "schema": "http://schema.org/", + "id": "@id", + "name": { "@id": "schema:name", "@language": "de" } + }, + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "tests": [ + { + "description": "the name string carries the term's language tag in RDF", + "data": { "id": "https://example.org/acme", "name": "ACME GmbH" }, + "valid": true, + "expectRdf": " \"ACME GmbH\"@de .\n" + } + ] + }, + { + "feature": "reverse round-trip: an exported instance reconstructs from RDF through the minimal schema-derived frame (literals and references by compaction, embedded objects by framing, arrays kept stable by @container:@set)", + "$comment": "The instance carries its materialized root type (schema:Organization), as a compliant export must, so the schema-derived frame can pick it as the frame root and nest the embedded object beneath it. Reconstruction uses scripts/schema_to_frame.mjs; [roundtrip] asserts instance == reconstruction after canonicalization.", + "schema": { + "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "x-oold-instance-rdf-type": ["schema:Organization"], + "@context": { + "schema": "http://schema.org/", + "id": "@id", + "type": "@type", + "address": { + "@id": "schema:address", + "@context": { + "PostalAddress": "schema:PostalAddress", + "streetAddress": "schema:streetAddress", + "postalCode": "schema:postalCode" + } + }, + "keywords": { "@id": "schema:keywords", "@container": "@set" } + }, + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { "type": ["string", "array"] }, + "address": { + "x-oold-range": ["schema:Text", "schema:PostalAddress", "schema:Place"], + "anyOf": [ + { "type": "string" }, + { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } } }, + { "type": "object", "properties": { "type": {}, "streetAddress": { "type": "string" }, "postalCode": { "type": "string" } } } + ] + }, + "keywords": { "type": "array", "items": { "type": "string" } } + } + }, + "tests": [ + { + "description": "a literal address reconstructs by compaction", + "data": { "id": "https://example.org/acme", "type": "schema:Organization", "address": "Mainstreet 1, 10115 Example City" }, + "valid": true, + "roundtrip": true + }, + { + "description": "a reference address reconstructs as { id } (the target has no local triples)", + "data": { "id": "https://example.org/acme", "type": "schema:Organization", "address": { "id": "https://example.org/address/A1" } }, + "valid": true, + "roundtrip": true + }, + { + "description": "an embedded address reconstructs as a nested object through the frame", + "data": { "id": "https://example.org/acme", "type": "schema:Organization", "address": { "type": "PostalAddress", "streetAddress": "Mainstreet 1", "postalCode": "10115" } }, + "valid": true, + "roundtrip": true + }, + { + "description": "a multi-valued keywords array is kept as a set", + "data": { "id": "https://example.org/acme", "type": "schema:Organization", "keywords": ["a", "b"] }, + "valid": true, + "roundtrip": true + }, + { + "description": "a single-element keywords array stays an array (would collapse to a scalar without @container:@set)", + "data": { "id": "https://example.org/acme", "type": "schema:Organization", "keywords": ["x"] }, + "valid": true, + "roundtrip": true + } + ] + } +] diff --git a/tests/data/oold/remote_context/Leaf.schema.json b/tests/data/oold/remote_context/Leaf.schema.json new file mode 100644 index 0000000..cf9148c --- /dev/null +++ b/tests/data/oold/remote_context/Leaf.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "Leaf.schema.json", + "title": "Leaf", + "x-oold-instance-rdf-type": ["schema:Thing"], + "@context": [ + "../Thing.schema.json", + { + "schema": "http://schema.org/", + "nickname": "schema:alternateName" + } + ], + "type": "object", + "properties": { + "name": { "type": "string" }, + "nickname": { "type": "string" } + } +} diff --git a/tests/test_validation/__init__.py b/tests/test_validation/__init__.py new file mode 100644 index 0000000..24c3264 --- /dev/null +++ b/tests/test_validation/__init__.py @@ -0,0 +1 @@ +"""Tests for the OO-LD validation package.""" diff --git a/tests/test_validation/conftest.py b/tests/test_validation/conftest.py new file mode 100644 index 0000000..73254f3 --- /dev/null +++ b/tests/test_validation/conftest.py @@ -0,0 +1,88 @@ +"""Shared fixtures for the validation test suite.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +DATA = Path(__file__).parent.parent / "data" / "oold" +BROKEN = DATA / "broken" +COMPLIANCE = DATA / "compliance" +REMOTE_CONTEXT = DATA / "remote_context" + + +@pytest.fixture +def data_dir() -> Path: + """The committed slice of oold-schema's examples.""" + return DATA + + +@pytest.fixture +def broken_dir() -> Path: + """Deliberately broken schemas, which prove the checks actually fire.""" + return BROKEN + + +@pytest.fixture +def compliance_dir() -> Path: + return COMPLIANCE + + +@pytest.fixture +def remote_context_dir() -> Path: + return REMOTE_CONTEXT + + +@pytest.fixture +def isolated_cache(tmp_path, monkeypatch) -> Path: + """Point the document and meta caches at a temporary directory. + + Without this a test could pick up a warm cache from a previous run, which would hide an + offline-handling bug. + """ + cache = tmp_path / "cache" + monkeypatch.setenv("OOLD_CACHE_DIR", str(cache)) + return cache + + +@pytest.fixture +def resolver(tmp_path): + """An offline resolver with a private cache, so tests never share state or hit the network.""" + from oold.validation.resolve import Resolver + + return Resolver(cache_dir=tmp_path / "documents", offline=True) + + +@pytest.fixture(scope="session") +def bundle(): + """The latest tracked meta-schema bundle.""" + from oold.validation.meta_store import resolve_selection + + return resolve_selection(["latest"], offline=True)[0] + + +@pytest.fixture +def loader(resolver, data_dir): + from oold.validation.loader import DocumentLoader + + return DocumentLoader(resolver, directory=data_dir) + + +def read(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +@pytest.fixture +def upstream_dir() -> Path | None: + """A local oold-schema checkout, when OOLD_SCHEMA_DIR points at one. + + Used by the opt-in live parity tests. Returns None otherwise so they can skip. + """ + raw = os.environ.get("OOLD_SCHEMA_DIR") + if not raw: + return None + path = Path(raw) + return path if path.is_dir() else None diff --git a/tests/test_validation/test_checks.py b/tests/test_validation/test_checks.py new file mode 100644 index 0000000..38d6014 --- /dev/null +++ b/tests/test_validation/test_checks.py @@ -0,0 +1,190 @@ +"""Meta-schema validation, pattern lint and cyclic-context detection.""" + +from __future__ import annotations + +from oold.validation.context_graph import ( + context_file_refs, + cyclic_scoped_contexts, +) +from oold.validation.pattern_lint import ( + array_properties_missing_container, + context_terms, + iri_references_missing_format, + is_strict_array, + lint, +) +from oold.validation.schema_checks import check_usable_as_validator, validate_against_meta + +from .conftest import read + +# ------------------------------------------------------------------ meta-schema validation + + +def test_every_committed_example_is_a_valid_oold_schema(bundle, data_dir): + for path in sorted(data_dir.glob("*.schema.json")): + result = validate_against_meta(read(path), bundle) + assert result.valid, f"{path.name}: {result.errors[:2]}" + + +def test_jsonld_keywords_are_tolerated_as_annotations(bundle): + """Load-bearing for the whole package, so it is asserted rather than assumed. + + JSON Schema 2020-12 tolerates unknown keywords as annotations. If it did not, `@context` + at a schema root would make every OO-LD document invalid. + """ + result = validate_against_meta({"@context": {"ex": "https://example.org/"}, "@id": "x", "type": "object"}, bundle) + assert result.valid + assert "@context" in result.jsonld_keywords_found + + +def test_a_malformed_keyword_is_rejected(bundle): + result = validate_against_meta({"x-oold-instance-rdf-type": "not-an-array"}, bundle) + assert not result.valid + + +def test_broken_fixture_fails_the_meta_schema(bundle, broken_dir): + result = validate_against_meta(read(broken_dir / "invalid_meta.schema.json"), bundle) + assert not result.valid, "x-oold-uuid: 'not-a-uuid' must fail; format has to be asserted" + + +def test_non_object_root_is_reported_not_raised(bundle): + assert not validate_against_meta(["not", "an", "object"], bundle).valid + + +def test_uncompilable_schema_is_caught(): + assert check_usable_as_validator({"type": "string", "pattern": "([unclosed"}) + assert check_usable_as_validator({"type": "string"}) == [] + + +# ------------------------------------------------------------------ pattern lint + + +def test_committed_examples_pass_the_lint(bundle, data_dir): + for path in sorted(data_dir.glob("*.schema.json")): + result = lint(read(path), bundle) + assert not result.failed, f"{path.name}: {result.to_dict()}" + + +def test_xsd_string_coercion_is_a_must_failure(bundle, broken_dir): + result = lint(read(broken_dir / "xsd_string_coercion.schema.json"), bundle) + assert result.schema_errors, "a term coercing to xsd:string never round-trips" + assert result.failed + + +def test_array_without_container_is_a_must_failure(bundle, broken_dir): + result = lint(read(broken_dir / "array_without_container.schema.json"), bundle) + assert result.missing_container == ["tags"] + assert result.failed + + +def test_context_terms_skips_keywords_and_string_definitions(): + terms = context_terms({"@version": 1.1, "plain": "ex:plain", "full": {"@id": "ex:full"}}) + assert terms == {"full": {"@id": "ex:full"}} + + +def test_is_strict_array(): + assert is_strict_array({"type": "array"}) + assert is_strict_array({"items": {"type": "string"}}) + assert not is_strict_array({"type": ["array", "string"]}) + assert not is_strict_array({"type": "string"}) + + +def test_container_forms_are_all_accepted(): + for container in ("@set", "@list", ["@set"], ["@list", "@index"]): + schema = { + "@context": [{"t": {"@id": "ex:t", "@container": container}}], + "properties": {"t": {"type": "array"}}, + } + assert array_properties_missing_container(schema) == [] + + +def test_container_check_ignores_unmapped_and_flexible_properties(): + # No local term at all: mapped by an inherited context, so out of scope here. + assert ( + array_properties_missing_container({ + "@context": [{"other": "ex:other"}], + "properties": {"tags": {"type": "array"}}, + }) + == [] + ) + # Cardinality-flexible: the scalar form still validates after a round-trip. + assert ( + array_properties_missing_container({ + "@context": [{"tags": {"@id": "ex:t"}}], + "properties": {"tags": {"type": ["array", "string"]}}, + }) + == [] + ) + + +def test_iri_format_recommendation(): + base = {"@context": [{"knows": {"@id": "ex:knows", "@type": "@id"}}]} + assert iri_references_missing_format({ + **base, + "properties": {"knows": {"type": "string", "x-oold-range": "P"}}, + }) == ["knows"] + assert ( + iri_references_missing_format({ + **base, + "properties": {"knows": {"type": "string", "format": "iri-reference", "x-oold-range": "P"}}, + }) + == [] + ) + assert iri_references_missing_format({ + **base, + "properties": {"knows": {"type": "array", "items": {"type": "string", "x-oold-range": "P"}}}, + }) == ["knows[]"] + # Without x-oold-range it is not a typed reference, so the recommendation does not apply. + assert iri_references_missing_format({**base, "properties": {"knows": {"type": "string"}}}) == [] + + +def test_iri_format_finding_is_a_warning_not_a_failure(bundle): + result = lint( + { + "@context": [{"knows": {"@id": "ex:knows", "@type": "@id"}}], + "properties": {"knows": {"type": "string", "x-oold-range": "P"}}, + }, + bundle, + ) + assert result.has_warning + assert not result.failed, "the IRI lexical form is a SHOULD, so it must not fail a run" + + +# ------------------------------------------------------------------ context graph + + +def test_context_file_refs_finds_parent_and_scoped_references(): + context = [ + "Parent.schema.json", + {"ex": "https://example.org/", "p": {"@id": "ex:p", "@context": "Scoped.schema.json"}}, + ] + assert context_file_refs(context) == {"Parent.schema.json", "Scoped.schema.json"} + + +def test_context_file_refs_ignores_ordinary_iris(): + assert context_file_refs({"ex": "https://example.org/", "p": "ex:p"}) == set() + + +def test_committed_examples_have_no_cyclic_scoped_contexts(data_dir): + schemas = {p.name: read(p) for p in data_dir.glob("*.schema.json")} + assert cyclic_scoped_contexts(schemas) == set() + + +def test_a_cycle_is_detected_and_propagates_to_referrers(): + schemas = { + "A.schema.json": {"@context": [{"b": {"@id": "ex:b", "@context": "B.schema.json"}}]}, + "B.schema.json": {"@context": [{"a": {"@id": "ex:a", "@context": "A.schema.json"}}]}, + "C.schema.json": {"@context": ["A.schema.json"]}, + "D.schema.json": {"@context": [{"x": "ex:x"}]}, + } + found = cyclic_scoped_contexts(schemas) + assert found == {"A.schema.json", "B.schema.json", "C.schema.json"} + assert "D.schema.json" not in found + + +def test_unreadable_document_contributes_no_edges(): + assert cyclic_scoped_contexts({"A.schema.json": "not a dict"}) == set() + + +def test_reference_outside_the_set_is_ignored(): + assert cyclic_scoped_contexts({"A.schema.json": {"@context": ["Elsewhere.schema.json"]}}) == set() diff --git a/tests/test_validation/test_cli.py b/tests/test_validation/test_cli.py new file mode 100644 index 0000000..96b2e1c --- /dev/null +++ b/tests/test_validation/test_cli.py @@ -0,0 +1,128 @@ +"""The CLI surface, including exit codes so it works as a CI gate.""" + +from __future__ import annotations + +import json + +import pytest +from click.testing import CliRunner + +from oold.validation.cli import main + + +@pytest.fixture +def run(): + runner = CliRunner() + + def invoke(*args): + return runner.invoke(main, [*args], catch_exceptions=False) + + return invoke + + +def test_a_valid_directory_exits_zero(run, data_dir): + result = run("validate", str(data_dir), "--offline") + assert result.exit_code == 0, result.output + assert "PASS" in result.output + + +def test_a_broken_schema_exits_nonzero(run, broken_dir): + result = run("validate", str(broken_dir / "undefined_prefix.schema.json"), "--offline") + assert result.exit_code == 1 + assert "FAIL" in result.output + assert "context.predicates" in result.output + + +def test_failures_are_shown_by_default_and_passes_are_not(run, data_dir): + result = run("validate", str(data_dir), "--offline") + assert "hidden" in result.output + assert "OK schema.meta" not in result.output + + +def test_verbose_shows_every_check(run, data_dir): + result = run("validate", str(data_dir / "Thing.schema.json"), "--offline", "--verbose") + assert "schema.meta" in result.output + assert "context.predicates" in result.output + + +def test_json_output_is_machine_readable(run, data_dir): + result = run("validate", str(data_dir / "Thing.schema.json"), "--offline", "--json") + payload = json.loads(result.output) + assert payload["passed"] is True + assert payload["summary"]["ok"] > 0 + + +def test_output_file_is_written(run, data_dir, tmp_path): + target = tmp_path / "report.json" + run("validate", str(data_dir / "Thing.schema.json"), "--offline", "--output", str(target)) + assert json.loads(target.read_text(encoding="utf-8"))["passed"] is True + + +def test_validate_instance_command(run, data_dir): + result = run("validate-instance", str(data_dir / "RdfPerson.instance.json"), "--offline") + assert result.exit_code == 0 + assert "PASS" in result.output + + +def test_validate_instance_with_an_explicit_schema(run, data_dir): + result = run( + "validate-instance", + str(data_dir / "RdfPerson.instance.json"), + "--schema", + str(data_dir / "RdfPerson.schema.json"), + "--offline", + ) + assert result.exit_code == 0 + + +def test_an_explicit_schema_elsewhere_is_refused_with_a_reason(run, data_dir, tmp_path): + """Relative @context references only resolve from the instance's own directory.""" + stray = tmp_path / "Other.schema.json" + stray.write_text('{"type": "object"}', encoding="utf-8") + result = run( + "validate-instance", + str(data_dir / "RdfPerson.instance.json"), + "--schema", + str(stray), + "--offline", + ) + assert result.exit_code == 1 + assert "same directory" in result.output + + +def test_compliance_command(run, compliance_dir): + result = run("compliance", str(compliance_dir), "--offline", "--json") + payload = json.loads(result.output) + assert payload["summary"]["checks"] > 60 + + +def test_meta_list_shows_versions_and_cache_state(run, isolated_cache): + result = run("meta", "list") + assert result.exit_code == 0 + assert "tracked versions" in result.output + assert "(latest)" in result.output + assert "not cached" in result.output + + +def test_meta_list_json(run): + payload = json.loads(run("meta", "list", "--json").output) + assert payload["latest"] + assert payload["versions"] + + +def test_unknown_meta_version_reports_cleanly(run, data_dir): + result = run("validate", str(data_dir), "--meta", "9.9.9", "--offline") + assert result.exit_code == 1 + assert "ERROR" in result.output + assert "not tracked" in result.output + + +def test_meta_version_is_shown_in_the_report(run, data_dir): + result = run("validate", str(data_dir / "Thing.schema.json"), "--offline") + assert "meta-schema:" in result.output + + +def test_a_missing_target_is_rejected_by_the_argument_parser(run): + runner = CliRunner() + result = runner.invoke(main, ["validate", "no-such-path"]) + assert result.exit_code != 0 diff --git a/tests/test_validation/test_formats.py b/tests/test_validation/test_formats.py new file mode 100644 index 0000000..d892f42 --- /dev/null +++ b/tests/test_validation/test_formats.py @@ -0,0 +1,101 @@ +"""Format assertion, including the deliberate iri/iri-reference override.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from oold.validation.formats import ( + FORMAT_SAMPLES, + OOLD_FORMAT_CHECKER, + is_iri, + is_iri_reference, +) + +PARITY = json.loads((Path(__file__).parent.parent / "data" / "format_parity.json").read_text(encoding="utf-8"))[ + "formats" +] + +PARITY_CASES = [ + (name, value, expected) for name, values in sorted(PARITY.items()) for value, expected in values.items() +] + + +@pytest.mark.parametrize("name,value,expected", PARITY_CASES, ids=lambda v: str(v)[:30]) +def test_matches_the_reference_toolchain(name, value, expected): + """Every outcome here was captured from ajv, as the reference harness configures it. + + `format` is an assertion in this pipeline, so a disagreement with ajv is a disagreement + about whether a schema is satisfiable, which is exactly the kind of divergence the port + exists to avoid. Two cases are load-bearing and easy to get wrong: ajv-formats runs in + *full* mode, so `time` requires an offset and `email` requires a dotted domain. + """ + assert OOLD_FORMAT_CHECKER.conforms(value, name) is expected + + +@pytest.mark.parametrize("name,sample", sorted(FORMAT_SAMPLES.items())) +def test_every_generator_sample_satisfies_its_own_checker(name, sample): + """The generator emits these, and the validator then asserts them. They must agree.""" + assert OOLD_FORMAT_CHECKER.conforms(sample, name), f"{name} sample {sample!r} is invalid" + + +@pytest.mark.parametrize( + "name,value", + [ + ("date", "2026-13-01"), + ("date", "2026-02-30"), + ("date", "not-a-date"), + ("date-time", "2026-01-02T03:04:05"), # no offset + ("time", "25:00:00"), + ("time", "03:04:05"), # full mode requires an offset + ("duration", "1D"), + ("duration", "P"), + ("email", "not-an-email"), + ("uuid", "not-a-uuid"), + ("ipv4", "999.0.0.1"), + ("ipv6", "not::a::v6"), + ("regex", "([unclosed"), + ("uri", "https://exa mple.org"), + ("uri", "relative/path"), + ("iri", "not an iri"), + ("iri", "relative/path"), + ("iri-reference", 'has"quote'), + ], +) +def test_invalid_values_are_rejected(name, value): + assert not OOLD_FORMAT_CHECKER.conforms(value, name) + + +@pytest.mark.parametrize( + "value", + ["ex:alice", "urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66", "https://example.org/x"], +) +def test_compact_and_urn_iris_are_accepted(value): + """The override exists because ajv-formats-draft2019 wrongly rejects these. + + A compact IRI is a valid IRI: an IRI is a superset of a URI. The reference harness patches + the same two formats, so rejecting these would make the two implementations disagree on + most OO-LD schemas. + """ + assert is_iri(value) + assert is_iri_reference(value) + assert OOLD_FORMAT_CHECKER.conforms(value, "iri") + + +def test_iri_reference_accepts_relative_but_iri_does_not(): + assert is_iri_reference("Thing.schema.json") + assert not is_iri("Thing.schema.json") + + +def test_non_strings_are_not_constrained(): + """`format` applies to strings only; a number under `format: iri` is not a format error.""" + for name in FORMAT_SAMPLES: + assert OOLD_FORMAT_CHECKER.conforms(42, name) + assert OOLD_FORMAT_CHECKER.conforms(None, name) + + +def test_iri_allows_non_ascii_but_uri_does_not(): + assert is_iri_reference("https://example.org/ünïcode") + assert not OOLD_FORMAT_CHECKER.conforms("https://example.org/ünïcode", "uri") diff --git a/tests/test_validation/test_generate.py b/tests/test_validation/test_generate.py new file mode 100644 index 0000000..d98c5dc --- /dev/null +++ b/tests/test_validation/test_generate.py @@ -0,0 +1,159 @@ +"""Deterministic instance generation and variant enumeration.""" + +from __future__ import annotations + +from jsonschema import Draft202012Validator + +from oold.validation.formats import FORMAT_SAMPLES, OOLD_FORMAT_CHECKER +from oold.validation.generate import collect_variants, generate +from oold.validation.resolve import bound_schema + + +def _validator(schema): + return Draft202012Validator(schema, format_checker=OOLD_FORMAT_CHECKER) + + +def test_every_committed_schema_is_satisfiable(resolver, data_dir): + """The generated instance must validate against the schema that produced it.""" + for path in sorted(data_dir.glob("*.schema.json")): + schema = bound_schema(resolver.dereference(resolver.load(path)).schema) + schema.pop("$schema", None) + result = generate(schema) + assert result.ok, f"{path.name}: {result.error}" + errors = list(_validator(schema).iter_errors(result.instance)) + assert not errors, f"{path.name}: {errors[0].message} for {result.instance}" + + +def test_generation_is_deterministic(resolver, data_dir): + """A randomised generator turns a round-trip bug into a flaky CI failure.""" + path = data_dir / "PersonWithPet.schema.json" + schema = bound_schema(resolver.dereference(resolver.load(path)).schema) + schema.pop("$schema", None) + assert generate(schema).instance == generate(schema).instance + + +def test_authored_values_win_in_order(): + result = generate({ + "type": "object", + "properties": { + "a": {"const": "C"}, + "b": {"default": "D"}, + "c": {"examples": ["E"]}, + "d": {"enum": ["F", "G"]}, + }, + }) + assert result.instance == {"a": "C", "b": "D", "c": "E", "d": "F"} + + +def test_declared_formats_are_respected(): + properties = {name: {"type": "string", "format": name} for name in FORMAT_SAMPLES} + schema = {"type": "object", "properties": properties} + instance = generate(schema).instance + assert not list(_validator(schema).iter_errors(instance)) + + +def test_numeric_and_array_bounds_are_respected(): + schema = { + "type": "object", + "properties": { + "n": {"type": "integer", "minimum": 5}, + "m": {"type": "number", "maximum": -3}, + "a": {"type": "array", "items": {"type": "string"}, "minItems": 2}, + }, + } + instance = generate(schema).instance + assert not list(_validator(schema).iter_errors(instance)) + assert len(instance["a"]) == 2 + + +def test_inherited_properties_are_generated(): + """OO-LD models inheritance as allOf, so ignoring it would miss most of a schema.""" + instance = generate({ + "type": "object", + "properties": {"own": {"type": "string"}}, + "allOf": [{"type": "object", "properties": {"inherited": {"type": "string"}}}], + }).instance + assert set(instance) == {"own", "inherited"} + + +def test_cut_nodes_render_as_distinct_strings(): + """A cut must produce a string. + + At a typeless node a generator is free to emit a boolean or a number, and a non-string + under an `@type: "@id"` term becomes an RDF literal that cannot compact back, which reads + as a false round-trip loss. Distinctness matters because in RDF the same IRI is the same + node, so colliding cuts would merge and change the graph. + """ + instance = generate({ + "type": "object", + "properties": {"x": {"format": "x-oold-cut"}, "y": {"format": "x-oold-cut"}}, + }).instance + assert isinstance(instance["x"], str) + assert instance["x"] != instance["y"] + + +def test_generated_ids_are_unique(): + instance = generate({ + "type": "object", + "properties": { + "id": {"type": "string"}, + "child": {"type": "object", "properties": {"id": {"type": "string"}}}, + }, + }).instance + assert instance["id"] != instance["child"]["id"] + + +def test_a_pinned_const_id_is_not_rewritten(): + instance = generate({"type": "object", "properties": {"id": {"const": "ex:fixed"}}}).instance + assert instance["id"] == "ex:fixed" + + +def test_cyclic_schema_still_generates(resolver, tmp_path): + cyclic = {"type": "object", "properties": {}} + cyclic["properties"]["self"] = cyclic + result = generate(bound_schema(cyclic)) + assert result.ok + assert result.notes, "a generated cut should be reported" + + +def test_variants_are_enumerated_per_branch(): + schema = { + "type": "object", + "properties": {"address": {"anyOf": [{"type": "string"}, {"type": "object"}, {"type": "number"}]}}, + } + variants, total = collect_variants(schema) + assert total == 3 + assert [v.label for v in variants] == [ + "properties/address/anyOf[0]", + "properties/address/anyOf[1]", + "properties/address/anyOf[2]", + ] + # Each variant pins exactly one branch, which is what makes the others reachable at all. + assert variants[1].schema["properties"]["address"]["anyOf"] == [{"type": "object"}] + # The original is untouched. + assert len(schema["properties"]["address"]["anyOf"]) == 3 + + +def test_single_branch_alternatives_are_not_variants(): + assert collect_variants({"anyOf": [{"type": "string"}]}) == ([], 0) + + +def test_variants_are_capped_but_the_total_is_reported(): + schema = {"anyOf": [{"type": "string"}] * 10} + variants, total = collect_variants(schema, limit=4) + assert len(variants) == 4 + assert total == 10 + + +def test_each_variant_generates_a_valid_instance(resolver, data_dir): + path = data_dir / "Contact.schema.json" + schema = bound_schema(resolver.dereference(resolver.load(path)).schema) + schema.pop("$schema", None) + variants, _ = collect_variants(schema) + assert variants, "Contact.schema.json declares anyOf branches" + validator = _validator(schema) + for variant in variants: + produced = generate(variant.schema) + assert produced.ok, f"{variant.label}: {produced.error}" + errors = list(validator.iter_errors(produced.instance)) + assert not errors, f"{variant.label}: {errors[0].message}" diff --git a/tests/test_validation/test_jsonld.py b/tests/test_validation/test_jsonld.py new file mode 100644 index 0000000..5ff16a7 --- /dev/null +++ b/tests/test_validation/test_jsonld.py @@ -0,0 +1,299 @@ +"""The JSON-LD layer: loader, context resolution, framing, round-trip, attribution.""" + +from __future__ import annotations + +import json + +import pytest +from pyld import jsonld + +from oold.validation.context_resolution import find_alias_keys, resolve_context +from oold.validation.frame import ( + embedded_properties, + instance_rdf_types, + is_embed, + schema_to_frame, +) +from oold.validation.loader import DocumentLoader, describe_jsonld_error +from oold.validation.predicates import check_predicates +from oold.validation.resolve import bound_schema +from oold.validation.roundtrip import ( + canonical, + canonical_equal, + is_noop, + json_equal, + lost_keys, + roundtrip, +) + +from .conftest import read + +# ------------------------------------------------------------------ canonical / lost_keys + + +def test_scalar_and_single_element_array_are_equivalent(): + """JSON-LD semantics: "x" and ["x"] expand identically. + + Compaction picks one form or the other depending on @container, so cardinality may + legitimately differ between an instance and its round-trip without any loss. + """ + assert json_equal(canonical({"a": "x"}), canonical({"a": ["x"]})) + + +def test_array_order_is_ignored_because_rdf_sets_are_unordered(): + assert json_equal(canonical({"a": ["x", "y"]}), canonical({"a": ["y", "x"]})) + + +def test_blank_node_ids_are_dropped_but_real_ids_are_kept(): + assert canonical({"@id": "_:b0", "a": 1}) == {"a": [1]} + assert "@id" in canonical({"@id": "ex:a", "a": 1}) + + +def test_metadata_keys_are_dropped(): + assert canonical({"@context": "x", "$schema": "y", "a": 1}) == {"a": [1]} + + +def test_json_equal_keeps_booleans_distinct_from_numbers(): + """Python would treat True == 1 as equal; JSON and RDF do not.""" + assert not json_equal(True, 1) + assert not json_equal([False], [0]) + assert json_equal(1, 1.0) + + +def test_noop_values_are_not_reported_as_lost(): + assert is_noop(None) and is_noop([]) and is_noop([None, []]) + assert lost_keys({"a": None, "b": []}, {}) == [] + + +def test_lost_keys_reports_missing_keys_with_a_path(): + assert lost_keys({"a": 1}, {}) == ["a"] + assert lost_keys({"a": {"b": 1}}, {"a": {}}) == ["a.b"] + + +def test_value_coercion_is_not_a_loss(): + """A reference string resolving to an absolute IRI keeps the key, so it is not a loss.""" + assert lost_keys({"a": "x"}, {"a": "https://example.org/x"}) == [] + + +# ------------------------------------------------------------------ frame derivation + + +def test_is_embed_requires_an_object_with_properties(): + assert is_embed({"type": "object", "properties": {"a": {}}}) + assert is_embed({"items": {"type": "object", "properties": {"a": {}}}}) + assert is_embed({"anyOf": [{"type": "string"}, {"type": "object", "properties": {"a": {}}}]}) + assert not is_embed({"type": "string"}) + assert not is_embed({"type": "object"}) + + +def test_instance_rdf_types_is_most_derived_wins(): + assert instance_rdf_types({ + "x-oold-instance-rdf-type": ["ex:B"], + "allOf": [{"x-oold-instance-rdf-type": ["ex:A"]}], + }) == ["ex:B"] + # A subclass omitting its own declaration inherits through allOf. + assert instance_rdf_types({"allOf": [{"x-oold-instance-rdf-type": ["ex:A"]}]}) == ["ex:A"] + assert instance_rdf_types({"type": "object"}) is None + + +def test_schema_to_frame_carries_type_context_and_subframes(): + schema = { + "x-oold-instance-rdf-type": ["schema:Person"], + "@context": {"ex": "https://example.org/"}, + "properties": {"pet": {"type": "object", "properties": {"name": {}}}}, + } + frame = schema_to_frame(schema, "https://oo-ld.test/x/P.schema.json") + assert frame["@embed"] == "@once" + assert frame["@type"] == "schema:Person" + assert frame["@context"] == "https://oo-ld.test/x/P.schema.json" + assert frame["pet"] == {} + + +def test_schema_to_frame_uses_the_inline_context_when_no_reference_is_given(): + schema = {"@context": {"ex": "https://example.org/"}} + assert schema_to_frame(schema)["@context"] == {"ex": "https://example.org/"} + + +def test_embedded_properties_follows_allof_composition(): + schema = { + "allOf": [{"properties": {"inherited": {"type": "object", "properties": {"x": {}}}}}], + "properties": {"own": {"type": "string"}}, + } + assert embedded_properties(schema) == ["inherited"] + + +# ------------------------------------------------------------------ loader + + +def test_loader_maps_the_synthetic_base_onto_the_directory(loader, data_dir): + document = loader(loader.url_for("Thing.schema.json"))["document"] + assert document["title"] == "Thing" + + +def test_loader_hands_pyld_a_private_copy(resolver, data_dir): + """pyld rewrites a retrieved context's relative references to absolute *in place*. + + Returning the cached object would rewrite "Thing.schema.json" to a synthetic URL inside the + cache, and every later consumer would then fail to resolve it - far from the cause, and only + when checks happen to run in a particular order. + """ + loader = DocumentLoader(resolver, directory=data_dir) + uri = (data_dir / "Person.schema.json").resolve().as_uri() + before = json.dumps(resolver.fetch(uri)["@context"]) + + jsonld.expand( + {"@context": loader.url_for("Researcher.schema.json"), "@id": "https://example.org/d"}, + loader.options(base=loader.base_url), + ) + assert json.dumps(resolver.fetch(uri)["@context"]) == before + + +def test_loader_resolves_a_reference_that_leaves_the_directory(resolver, remote_context_dir): + """The reference harness cannot do this; its loader only maps names under its own base.""" + loader = DocumentLoader(resolver, directory=remote_context_dir) + expanded = jsonld.expand( + {"@context": loader.url_for("Leaf.schema.json"), "name": "Ada"}, + loader.options(base=loader.base_url), + ) + assert expanded, "the ../Thing.schema.json parent context did not resolve" + assert "http://schema.org/name" in expanded[0] + + +def test_loader_refuses_to_escape_its_root(resolver, remote_context_dir): + loader = DocumentLoader(resolver, directory=remote_context_dir) + with pytest.raises(jsonld.JsonLdError, match="escapes"): + loader("https://oo-ld.test/../../../../etc/passwd") + + +def test_offline_refusal_reaches_the_user(resolver, data_dir): + """pyld replaces a loader failure with generic text; the real reason must survive.""" + loader = DocumentLoader(resolver, directory=data_dir) + with pytest.raises(jsonld.JsonLdError) as excinfo: + jsonld.expand({"@context": "https://example.invalid/c.jsonld", "a": 1}, loader.options()) + assert "offline" in describe_jsonld_error(excinfo.value) + + +def test_missing_document_names_the_file(resolver, data_dir): + loader = DocumentLoader(resolver, directory=data_dir) + with pytest.raises(jsonld.JsonLdError) as excinfo: + loader(loader.url_for("NoSuchSchema.schema.json")) + assert "no such document" in describe_jsonld_error(excinfo.value) + + +# ------------------------------------------------------------------ context resolution + + +def test_context_chain_is_followed_through_schema_references(resolver, data_dir): + """OO-LD @context entries point at other schemas, not at context documents.""" + loaded = resolver.load(data_dir / "Researcher.schema.json") + context = resolve_context(loaded.schema, loaded.base_uri, resolver) + assert context.errors == [] + # Researcher -> Person -> Thing, so `name` (declared on Thing) must be reachable. + assert "name" in context.terms() + assert len(context.resolved_refs) == 2 + + +def test_unresolvable_context_reference_is_reported(resolver, broken_dir): + loaded = resolver.load(broken_dir / "unresolvable_context_ref.schema.json") + context = resolve_context(loaded.schema, loaded.base_uri, resolver) + assert context.errors and "NoSuchSchema" in context.errors[0] + + +def test_find_alias_keys_discovers_id_and_type_terms(): + assert find_alias_keys({"id": "@id", "type": "@type"}) == ("id", "type") + assert find_alias_keys({"identifier": {"@id": "@id"}}) == ("identifier", "@type") + assert find_alias_keys({"name": "ex:name"}) == ("@id", "@type") + + +# ------------------------------------------------------------------ round-trip + + +def test_committed_instances_round_trip_losslessly(resolver, data_dir, loader): + for path in sorted(data_dir.glob("*.instance.json")): + instance = read(path) + schema = bound_schema(resolver.dereference(resolver.load(data_dir / instance["$schema"])).schema) + schema.pop("$schema", None) + nquads = jsonld.to_rdf(instance, loader.options(base=loader.url_for(path.name), format="application/n-quads")) + assert nquads.strip(), f"{path.name} produced no triples" + back = jsonld.from_rdf(nquads, {"format": "application/n-quads", "useNativeTypes": True}) + if embedded_properties(schema): + restored = jsonld.frame( + back, + schema_to_frame(schema, loader.url_for(instance["$schema"])), + loader.options(base=loader.url_for(path.name), omitDefault=True), + ) + else: + restored = jsonld.compact( + back, + loader.url_for(instance["$schema"]), + loader.options(base=loader.url_for(path.name)), + ) + assert canonical_equal(instance, restored), f"{path.name} did not round-trip" + + +def test_scalar_instance_round_trips_trivially(loader): + result = roundtrip({"type": "string"}, "just a string", "https://example.org/c", loader) + assert result.ok and result.lost == [] + + +def test_roundtrip_reports_a_property_with_no_context_term(resolver, broken_dir): + loader = DocumentLoader(resolver, directory=broken_dir) + schema = read(broken_dir / "missing_context_term.schema.json") + result = roundtrip( + schema, + {"name": "Ada", "orphan": "lost"}, + loader.url_for("missing_context_term.schema.json"), + loader, + ) + assert result.lost == ["orphan"] + assert not result.ok + + +# ------------------------------------------------------------------ predicate attribution + + +def test_undefined_prefix_is_flagged_as_suspicious(): + """The dangerous case: the key survives and the round-trip is clean, but means nothing.""" + result = check_predicates({"latitude": 51.5}, {"latitude": "schema:latitude"}) + assert result.suspicious == {"latitude": "schema:latitude"} + assert not result.ok + + +def test_defined_prefix_is_mapped(): + result = check_predicates({"latitude": 51.5}, {"schema": "https://schema.org/", "latitude": "schema:latitude"}) + assert result.mapped == {"latitude": "https://schema.org/latitude"} + assert result.ok + + +def test_property_with_no_term_is_dropped(): + result = check_predicates({"nowhere": 1}, {"other": "https://example.org/other"}) + assert result.dropped == ["nowhere"] + + +def test_id_alias_is_not_mistaken_for_a_dropped_property(): + """A node carrying only @id is free floating and JSON-LD discards it on expansion. + + The anchor predicate keeps it alive, or a working @id alias would look broken. + """ + result = check_predicates({"id": "ex:a"}, {"id": "@id"}) + assert result.aliased == {"id": "@id"} + assert result.ok + + +def test_undeclared_keys_are_separated_from_dropped_ones(): + result = check_predicates( + {"known": 1, "extra": 2}, + {"known": "https://example.org/known", "extra": "https://example.org/extra"}, + declared_properties={"known"}, + ) + assert result.undeclared == ["extra"] + assert result.dropped == [] + + +def test_broken_fixture_reports_its_orphan_property(resolver, broken_dir): + loaded = resolver.load(broken_dir / "missing_context_term.schema.json") + context = resolve_context(loaded.schema, loaded.base_uri, resolver) + result = check_predicates( + {"name": "Ada", "orphan": "x"}, context.as_jsonld(), declared_properties={"name", "orphan"} + ) + assert result.dropped == ["orphan"] diff --git a/tests/test_validation/test_mcp_server.py b/tests/test_validation/test_mcp_server.py new file mode 100644 index 0000000..9ced4e5 --- /dev/null +++ b/tests/test_validation/test_mcp_server.py @@ -0,0 +1,104 @@ +"""The MCP server: tools are registered, delegate to the pipeline, and never raise.""" + +from __future__ import annotations + +import asyncio +import json + +import pytest + +mcp_server = pytest.importorskip("oold.validation.mcp_server", reason="the mcp extra is not installed") + + +def list_tools(): + """Drive the async listing directly, so the suite needs no async pytest plugin.""" + return asyncio.run(mcp_server.mcp.list_tools()) + + +EXPECTED_TOOLS = { + "validate_oold_schema", + "validate_oold_instance", + "validate_oold_directory", + "run_oold_compliance", + "generate_oold_instance", + "check_context_mapping", + "list_meta_versions", +} + + +def test_all_tools_are_registered(): + assert {tool.name for tool in list_tools()} == EXPECTED_TOOLS + + +def test_every_tool_documents_itself(): + """The description is the whole interface an agent sees.""" + for tool in list_tools(): + assert tool.description and len(tool.description) > 40, tool.name + + +def test_validate_schema_tool(data_dir): + result = mcp_server.validate_oold_schema(str(data_dir / "Thing.schema.json"), offline=True) + assert result["passed"] is True + assert result["problems"] == [] + + +def test_validate_schema_tool_reports_problems_as_data(broken_dir): + result = mcp_server.validate_oold_schema(str(broken_dir / "undefined_prefix.schema.json"), offline=True) + assert result["passed"] is False + assert any("not an absolute IRI" in p for p in result["problems"]) + + +def test_validate_schema_tool_accepts_raw_json(): + schema = json.dumps({ + "$id": "Inline.schema.json", + "@context": {"ex": "https://example.org/", "name": "ex:name"}, + "type": "object", + "properties": {"name": {"type": "string"}}, + }) + assert mcp_server.validate_oold_schema(schema, offline=True)["passed"] is True + + +def test_instance_tool(data_dir): + result = mcp_server.validate_oold_instance(str(data_dir / "PersonWithPet.instance.json"), offline=True) + assert result["passed"] is True + + +def test_directory_tool(data_dir): + result = mcp_server.validate_oold_directory(str(data_dir), offline=True) + assert result["passed"] is True + assert result["summary"]["targets"] > 10 + + +def test_generate_tool(data_dir): + result = mcp_server.generate_oold_instance(str(data_dir / "PersonWithPet.schema.json")) + assert result["ok"] is True + assert result["instance"]["name"] + + +def test_generate_tool_reports_a_missing_file_as_data(tmp_path): + result = mcp_server.generate_oold_instance(str(tmp_path / "nope.schema.json")) + assert result["ok"] is False + assert "not found" in result["error"] + + +def test_context_mapping_tool_finds_a_suspicious_predicate(): + document = json.dumps({"@context": {"latitude": "schema:latitude"}, "latitude": 51.5}) + result = mcp_server.check_context_mapping(document) + assert result["suspicious"] == {"latitude": "schema:latitude"} + + +def test_context_mapping_tool_requires_a_context(): + assert mcp_server.check_context_mapping(json.dumps({"a": 1}))["ok"] is False + + +def test_list_meta_versions_tool(): + result = mcp_server.list_meta_versions() + assert result["latest"] + assert result["versions"] + + +def test_unknown_meta_version_is_returned_as_data(data_dir): + """A caller asking about a broken setup wants the explanation, not an exception.""" + result = mcp_server.validate_oold_schema(str(data_dir / "Thing.schema.json"), meta=["9.9.9"], offline=True) + assert result["passed"] is False + assert "not tracked" in result["fatal_error"] diff --git a/tests/test_validation/test_meta_store.py b/tests/test_validation/test_meta_store.py new file mode 100644 index 0000000..3dc7795 --- /dev/null +++ b/tests/test_validation/test_meta_store.py @@ -0,0 +1,167 @@ +"""The meta-schema store: version discovery, selection, remote fetch, registry.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from oold.validation import meta_store +from oold.validation.meta_store import ( + META_SCHEMA_FILE, + MetaSchemaError, + describe_store, + latest_version, + load_tracked, + resolve_selection, + tracked_versions, +) + + +def test_at_least_one_version_is_tracked(): + versions = tracked_versions() + assert versions, "the package must ship at least one meta-schema version" + assert latest_version() == versions[-1] + + +def test_versions_sort_numerically_not_lexically(): + # 0.10.0 must come after 0.9.0. Sorting as strings would put it before. + assert meta_store._version_key("0.10.0") > meta_store._version_key("0.9.0") + assert meta_store._version_key("1.0.0") > meta_store._version_key("0.99.0") + + +def test_index_records_provenance_for_every_tracked_version(): + index = meta_store.load_index() + for version in tracked_versions(): + entry = index["versions"][version] + assert entry["tag"], f"{version} records no upstream tag" + assert len(entry["commit"]) == 40, f"{version} records no full commit sha" + + +def test_recorded_checksums_match_the_shipped_files(): + """The store is curated by hand, so the checksums are what catch a bad copy.""" + index = meta_store.load_index() + for version in tracked_versions(): + recorded = index["versions"][version].get("sha256") or {} + for name, digest in recorded.items(): + content = (meta_store.meta_dir() / version / name).read_bytes() + assert hashlib.sha256(content).hexdigest() == digest, f"{version}/{name} was modified" + + +def test_bundle_self_check_is_clean(): + bundle = load_tracked(latest_version()) + assert bundle.self_check() == [] + + +def test_bundle_exposes_the_three_documents(): + bundle = load_tracked(latest_version()) + assert bundle.meta["$id"] + assert bundle.ui_meta["$id"] + assert bundle.pattern_lint["$id"] + + +def test_declared_keywords_are_found(): + keywords = load_tracked(latest_version()).declared_keywords() + assert "x-oold-instance-rdf-type" in keywords + assert any(k.startswith("x-oold-ui-") or k.startswith("x-enum-") for k in keywords) + + +def test_unknown_version_names_what_is_available(): + with pytest.raises(MetaSchemaError, match="not tracked"): + load_tracked("9.9.9") + + +def test_selection_expands_and_deduplicates(): + latest = latest_version() + bundles = resolve_selection(["latest", "all", latest], offline=True) + assert [b.version for b in bundles] == tracked_versions() + + +def test_selection_accepts_a_bare_string(): + assert resolve_selection("latest", offline=True)[0].version == latest_version() + + +def test_registry_resolves_the_ui_meta_schema_cross_reference(): + """The core meta-schema $refs the UI one, so a bad registry silently stops asserting.""" + bundle = load_tracked(latest_version()) + validator = bundle.meta_validator() + # x-oold-ui-* keywords are defined only in the UI meta-schema. + assert validator.is_valid({"x-oold-ui-title": "ok"}) + assert not validator.is_valid({"x-oold-instance-rdf-type": "must-be-an-array"}) + + +def test_registry_resolves_by_file_name_when_the_id_domain_differs(tmp_path, monkeypatch): + """A released copy stamps its version into $id while $refs may still say `latest`. + + The $id domain has already moved once upstream, so the registry must not depend on any + particular URL. This rewrites the ids and asserts validation still works. + """ + version = latest_version() + source = meta_store.meta_dir() / version + target = tmp_path / "meta" / "9.9.9" + target.mkdir(parents=True) + for name in meta_store.meta_files(): + document = json.loads((source / name).read_text(encoding="utf-8")) + if "$id" in document: + document["$id"] = ( + document["$id"] + .replace("/latest/", "/9.9.9/") + .replace("oo-ld.github.io/oold-schema", "example.invalid/elsewhere") + ) + (target / name).write_text(json.dumps(document), encoding="utf-8") + + monkeypatch.setattr(meta_store, "meta_dir", lambda: tmp_path / "meta") + bundle = load_tracked("9.9.9") + assert bundle.self_check() == [] + assert bundle.meta_validator().is_valid({"x-oold-ui-title": "still works"}) + + +def test_describe_store_reports_versions_and_cache_state(isolated_cache): + store = describe_store() + assert store["latest"] == latest_version() + assert META_SCHEMA_FILE in store["files"] + assert store["remote"]["cached"] is False + + +def test_remote_is_refused_offline_when_not_cached(isolated_cache): + with pytest.raises(MetaSchemaError, match="offline"): + resolve_selection(["remote"], offline=True) + + +def test_remote_fetch_writes_only_to_the_cache(isolated_cache, monkeypatch, tmp_path): + """A remote fetch must never touch the tracked version history.""" + documents = { + name: json.loads((meta_store.meta_dir() / latest_version() / name).read_text("utf-8")) + for name in meta_store.meta_files() + } + before = {path: path.read_bytes() for path in meta_store.meta_dir().rglob("*.json")} + + def fake_get(uri, timeout=10.0): + return documents[uri.rsplit("/", 1)[-1]] + + monkeypatch.setattr(meta_store, "http_get_json", fake_get) + bundle = meta_store.load_remote(offline=False) + + assert bundle.version == "remote" + assert bundle.self_check() == [] + assert Path(meta_store.remote_cache_dir(), META_SCHEMA_FILE).is_file() + after = {path: path.read_bytes() for path in meta_store.meta_dir().rglob("*.json")} + assert after == before, "the tracked meta-schema folder was modified by a remote fetch" + + +def test_cached_remote_is_usable_offline(isolated_cache, monkeypatch): + documents = { + name: json.loads((meta_store.meta_dir() / latest_version() / name).read_text("utf-8")) + for name in meta_store.meta_files() + } + monkeypatch.setattr(meta_store, "http_get_json", lambda uri, timeout=10.0: documents[uri.rsplit("/", 1)[-1]]) + meta_store.fetch_remote() + # Now offline: the cached copy must satisfy the request without any fetch. + monkeypatch.setattr( + meta_store, + "http_get_json", + lambda *a, **k: pytest.fail("offline mode fetched over the network"), + ) + assert meta_store.load_remote(offline=True).version == "remote" diff --git a/tests/test_validation/test_parity_live.py b/tests/test_validation/test_parity_live.py new file mode 100644 index 0000000..97ad582 --- /dev/null +++ b/tests/test_validation/test_parity_live.py @@ -0,0 +1,119 @@ +"""Opt-in parity tests against a real oold-schema checkout. + +These are the tests that catch drift from the reference implementation. They are skipped unless +``OOLD_SCHEMA_DIR`` points at a local checkout:: + + OOLD_SCHEMA_DIR=../oold-schema uv run pytest tests/test_validation -q + +The committed fixture slice is a snapshot and cannot notice upstream changes; this can. CI does +not depend on it, so the suite stays self-contained by default. +""" + +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +from oold.validation import Options, run_compliance, validate_directory + +pytestmark = pytest.mark.parity + + +@pytest.fixture +def upstream(upstream_dir): + if upstream_dir is None: + pytest.skip("set OOLD_SCHEMA_DIR to a local oold-schema checkout to run parity tests") + if not (upstream_dir / "examples").is_dir(): + pytest.skip(f"{upstream_dir} does not look like an oold-schema checkout") + return upstream_dir + + +def _options(upstream_dir): + """Validate against the meta-schemas the upstream checkout actually declares. + + Its examples track `main`, which can be ahead of the newest released version this package + tracks, so pinning to `latest` would compare against the wrong rules. + """ + return Options(meta=("remote",), offline=False) + + +def test_upstream_examples_pass(upstream): + report = validate_directory(upstream / "examples", _options(upstream)) + assert report.passed, [f"{c.id} {c.target}: {c.message}" for c in report.failures()] + + +def test_upstream_compliance_suite_passes(upstream): + report = run_compliance(upstream / "examples" / "compliance", _options(upstream)) + assert report.passed, [f"{c.target}: {c.message}" for c in report.failures()] + + +def test_vocabulary_coverage_matches_upstream(upstream): + """Fails when oold-schema adds a keyword whose fixture this package cannot see.""" + report = run_compliance(upstream / "examples" / "compliance", _options(upstream)) + coverage = [c for c in report.checks if c.id == "coverage.vocab"] + assert coverage and all(c.status == "ok" for c in coverage), [c.message for c in coverage] + + +def test_verdict_agrees_with_the_reference_harness(upstream): + """Run `node scripts/validate.mjs` and require the same overall verdict. + + Check *counts* legitimately differ: this port splits some of the reference's combined + sections and adds `context.predicates`. The verdict must not. + """ + node = shutil.which("node") + if node is None: + pytest.skip("node is not available") + script = upstream / "scripts" / "validate.mjs" + if not script.is_file() or not (upstream / "node_modules").is_dir(): + pytest.skip("the reference harness is not installed (run npm install in oold-schema)") + + # S603: a fixed script inside a checkout the developer pointed us at. + completed = subprocess.run( # noqa: S603 + [node, str(script)], + cwd=str(upstream), + capture_output=True, + text=True, + timeout=600, + ) + reference_passed = completed.returncode == 0 + + ours = validate_directory(upstream / "examples", _options(upstream)) + theirs_compliance = run_compliance(upstream / "examples" / "compliance", _options(upstream)) + combined = ours.passed and theirs_compliance.passed + + assert combined == reference_passed, ( + f"reference exit={completed.returncode}, this port passed={combined}\n" + f"our failures: {[f'{c.id} {c.target}: {c.message}' for c in ours.failures()]}\n" + f"reference tail:\n{completed.stdout[-2000:]}" + ) + + +def test_the_reference_cannot_resolve_a_context_leaving_the_directory(upstream, remote_context_dir): + """Documents the one capability this port adds, by demonstrating the difference. + + If upstream ever gains this ability, the divergence note in the docs is stale. + """ + node = shutil.which("node") + if node is None: + pytest.skip("node is not available") + script = upstream / "scripts" / "validate.mjs" + if not script.is_file() or not (upstream / "node_modules").is_dir(): + pytest.skip("the reference harness is not installed") + + # S603: a fixed script inside a checkout the developer pointed us at. + completed = subprocess.run( # noqa: S603 + [node, str(script), str(remote_context_dir.resolve())], + cwd=str(upstream), + capture_output=True, + text=True, + timeout=600, + ) + ours = validate_directory(remote_context_dir, Options(meta=("remote",), offline=False)) + + assert ours.passed, "this port is expected to resolve a ../ context reference" + assert completed.returncode != 0, ( + "the reference harness now resolves a context reference that leaves the directory; " + "update the divergence note in docs/how-to/validation.md" + ) diff --git a/tests/test_validation/test_pipeline.py b/tests/test_validation/test_pipeline.py new file mode 100644 index 0000000..e36b4ed --- /dev/null +++ b/tests/test_validation/test_pipeline.py @@ -0,0 +1,216 @@ +"""End-to-end pipeline behaviour, including that the broken fixtures actually fail.""" + +from __future__ import annotations + +import pytest + +from oold.validation import Options, run_compliance, validate_directory, validate_instance, validate_schema +from oold.validation.report import FAIL, OK, SKIP, WARN + +OFFLINE = Options(meta=("latest",), offline=True) + + +def _ids(report, status=None): + return {c.id for c in report.checks if status is None or c.status == status} + + +# ------------------------------------------------------------------ the good path + + +def test_the_committed_slice_passes_completely(data_dir): + report = validate_directory(data_dir, OFFLINE) + assert report.passed, [f"{c.id} {c.target}: {c.message}" for c in report.failures()] + assert report.counts[FAIL] == 0 + + +def test_every_expected_check_runs_over_the_slice(data_dir): + """A check that silently stops running would otherwise look like a clean pass.""" + report = validate_directory(data_dir, OFFLINE) + assert _ids(report) >= { + "schema.meta", + "schema.refs", + "lint.pattern", + "lint.container", + "generate.satisfiable", + "roundtrip.generated", + "context.remote", + "context.predicates", + "variants", + "instance.schema", + "roundtrip.instance", + } + + +def test_a_single_schema_can_be_validated(data_dir): + report = validate_schema(data_dir / "PersonWithPet.schema.json", OFFLINE) + assert report.passed + assert report.targets() == ["PersonWithPet.schema.json"] + + +def test_an_instance_is_validated_against_the_schema_it_names(data_dir): + report = validate_instance(data_dir / "PersonWithPet.instance.json", options=OFFLINE) + assert report.passed + assert _ids(report) == {"instance.schema", "roundtrip.instance"} + + +def test_roundtrip_reports_triples_and_method(data_dir): + report = validate_instance(data_dir / "PersonWithPet.instance.json", options=OFFLINE) + check = next(c for c in report.checks if c.id == "roundtrip.instance") + assert check.detail["method"] == "framed" + assert check.detail["triples"] == 3 + + +def test_a_context_chain_leaving_the_directory_resolves(remote_context_dir): + """The capability the reference harness lacks: its loader only maps its own directory.""" + report = validate_schema(remote_context_dir / "Leaf.schema.json", OFFLINE) + assert report.passed, [f"{c.id}: {c.message}" for c in report.failures()] + assert "context.remote" in _ids(report, OK) + + +# ------------------------------------------------------------------ the broken fixtures + + +@pytest.mark.parametrize( + "fixture,check_id", + [ + ("invalid_meta.schema.json", "schema.meta"), + ("missing_context_term.schema.json", "roundtrip.generated"), + ("undefined_prefix.schema.json", "context.predicates"), + ("unresolvable_context_ref.schema.json", "context.predicates"), + ("xsd_string_coercion.schema.json", "lint.pattern"), + ("array_without_container.schema.json", "lint.container"), + ], +) +def test_each_broken_fixture_fails_the_check_it_targets(broken_dir, fixture, check_id): + """Proves the checks fire, rather than only that valid input passes.""" + report = validate_schema(broken_dir / fixture, OFFLINE) + assert not report.passed, f"{fixture} was expected to fail" + assert check_id in _ids(report, FAIL), ( + f"{fixture} failed, but not on {check_id}: {[(c.id, c.message) for c in report.failures()]}" + ) + + +def test_undefined_prefix_is_reported_as_suspicious_not_dropped(broken_dir): + report = validate_schema(broken_dir / "undefined_prefix.schema.json", OFFLINE) + check = next(c for c in report.checks if c.id == "context.predicates") + assert check.status == FAIL + assert check.detail["suspicious"] == {"latitude": "schema:latitude"} + + +def test_missing_context_term_names_the_orphan_property(broken_dir): + report = validate_schema(broken_dir / "missing_context_term.schema.json", OFFLINE) + failures = {c.id: c for c in report.failures()} + assert "orphan" in failures["roundtrip.generated"].message + assert failures["context.predicates"].detail["dropped"] == ["orphan"] + + +# ------------------------------------------------------------------ meta versions + + +def test_only_version_dependent_checks_are_tagged_with_a_version(data_dir): + """Fanning every check across versions would multiply the report for no information.""" + report = validate_directory(data_dir, OFFLINE) + tagged = {c.id for c in report.checks if c.meta_version} + assert tagged == {"schema.meta", "lint.pattern"} + + +def test_multiple_versions_only_repeat_the_dependent_checks(data_dir, monkeypatch): + from oold.validation import meta_store + + latest = meta_store.latest_version() + single = validate_directory(data_dir, Options(meta=(latest,), offline=True)) + # Selecting the same version twice must deduplicate rather than double the work. + twice = validate_directory(data_dir, Options(meta=(latest, "latest"), offline=True)) + assert len(single.checks) == len(twice.checks) + + +def test_unknown_meta_version_is_a_fatal_error_not_a_traceback(data_dir): + report = validate_directory(data_dir, Options(meta=("9.9.9",), offline=True)) + assert report.fatal_error and "not tracked" in report.fatal_error + assert not report.passed + + +def test_offline_without_a_cached_remote_explains_itself(data_dir, isolated_cache): + report = validate_directory(data_dir, Options(meta=("remote",), offline=True)) + assert report.fatal_error and "offline" in report.fatal_error + + +# ------------------------------------------------------------------ filtering and errors + + +def test_checks_can_be_filtered(data_dir): + report = validate_directory(data_dir, Options(meta=("latest",), offline=True, only=("schema.",))) + assert _ids(report) == {"schema.meta", "schema.refs"} + + +def test_checks_can_be_skipped(data_dir): + report = validate_directory(data_dir, Options(meta=("latest",), offline=True, skip=("roundtrip.", "variants"))) + assert not {i for i in _ids(report) if i.startswith("roundtrip.")} + + +def test_a_missing_directory_is_reported(tmp_path): + report = validate_directory(tmp_path / "nope", OFFLINE) + assert report.fatal_error and "not a directory" in report.fatal_error + + +def test_an_empty_directory_is_reported(tmp_path): + report = validate_directory(tmp_path, OFFLINE) + assert report.fatal_error and "no *" in report.fatal_error + + +def test_an_instance_without_a_schema_reference_is_reported(tmp_path): + (tmp_path / "x.instance.json").write_text('{"a": 1}', encoding="utf-8") + (tmp_path / "y.schema.json").write_text('{"type": "object"}', encoding="utf-8") + report = validate_directory(tmp_path, OFFLINE) + check = next(c for c in report.checks if c.id == "instance.schema") + assert check.status == FAIL and "$schema" in check.message + + +# ------------------------------------------------------------------ compliance + + +def test_the_compliance_suite_passes(compliance_dir): + """The fixtures and the tracked meta-schema are both snapshots of the same release. + + That consistency is the point: the lint rules a fixture asserts only exist in the version + that introduced them, so mixing a newer fixture set with an older meta-schema produces + failures that say nothing about this code. Upstream's current state is covered separately + by the opt-in parity tests, which use `--meta remote`. + """ + report = run_compliance(compliance_dir, OFFLINE) + assert report.passed, [f"{c.target}: {c.message}" for c in report.failures()] + + +def test_vocabulary_coverage_is_checked(compliance_dir): + report = run_compliance(compliance_dir, OFFLINE) + coverage = [c for c in report.checks if c.id == "coverage.vocab"] + assert coverage and coverage[0].status == OK, "every meta-schema keyword needs a fixture" + + +def test_compliance_on_a_missing_directory_is_reported(tmp_path): + report = run_compliance(tmp_path / "nope", OFFLINE) + assert report.fatal_error + + +# ------------------------------------------------------------------ report shape + + +def test_report_serialises_at_both_verbosities(data_dir): + report = validate_schema(data_dir / "Thing.schema.json", OFFLINE) + summary = report.to_dict("summary") + full = report.to_dict("full") + assert summary["passed"] is True + assert summary["summary"]["checks"] == len(report.checks) + assert any("detail" in c for c in full["checks"]) + assert not any("detail" in c for c in summary["checks"]) + + +def test_warnings_do_not_fail_a_run(): + from oold.validation.report import Report + + report = Report(source="x") + report.add("lint.iri-format", "a.schema.json", WARN, "recommendation") + report.add("schema.meta", "a.schema.json", SKIP, "skipped") + assert report.passed + report.add("schema.meta", "a.schema.json", FAIL, "broken") + assert not report.passed diff --git a/tests/test_validation/test_resolve.py b/tests/test_validation/test_resolve.py new file mode 100644 index 0000000..1179ac0 --- /dev/null +++ b/tests/test_validation/test_resolve.py @@ -0,0 +1,183 @@ +"""Document loading, $ref dereferencing and schema bounding.""" + +from __future__ import annotations + +import json + +import pytest + +from oold.validation.resolve import ( + CUT_FORMAT, + Resolver, + SchemaResolutionError, + bound_schema, +) + + +def test_load_accepts_a_path_a_dict_and_raw_json(resolver, data_dir): + from_path = resolver.load(data_dir / "Thing.schema.json") + assert from_path.schema["title"] == "Thing" + assert from_path.base_uri.startswith("file:") + + from_dict = resolver.load({"$id": "x", "title": "T"}) + assert from_dict.base_uri == "x" + + from_string = resolver.load('{"title": "T"}') + assert from_string.schema["title"] == "T" + + +def test_missing_file_is_reported_clearly(resolver): + with pytest.raises(SchemaResolutionError, match="not found"): + resolver.load("no-such-file.schema.json") + + +def test_invalid_json_names_the_file(resolver, tmp_path): + bad = tmp_path / "bad.schema.json" + bad.write_text("{not json", encoding="utf-8") + with pytest.raises(SchemaResolutionError, match="not valid JSON"): + resolver.load(bad) + + +def test_offline_refuses_an_uncached_fetch(resolver): + with pytest.raises(SchemaResolutionError, match="offline"): + resolver.fetch("https://example.invalid/schema.json") + + +def test_non_http_urls_are_refused_before_being_opened(): + """`urlopen` would otherwise happily read a `file:` or custom-handler URL.""" + from oold.validation.resolve import http_get_json + + with pytest.raises(SchemaResolutionError, match="non-http"): + http_get_json("file:///etc/passwd") + + +def test_file_uris_round_trip_through_uri_to_path(tmp_path, data_dir): + """Every file URI this package handles comes from `Path.as_uri()`, so both must agree. + + The pre-3.13 branch is hand-rolled, since `url2pathname`'s Windows implementation is + deprecated from 3.14, so the round-trip is asserted rather than assumed. + """ + from oold.validation.resolve import uri_to_path + + for target in (tmp_path.resolve(), (data_dir / "Thing.schema.json").resolve()): + assert uri_to_path(target.as_uri()) == target + + +def test_uri_to_path_ignores_other_schemes(): + from oold.validation.resolve import uri_to_path + + assert uri_to_path("https://example.org/x.json") is None + + +def test_dereference_inlines_local_refs(resolver, data_dir): + loaded = resolver.load(data_dir / "PersonWithPet.schema.json") + result = resolver.dereference(loaded) + assert result.unresolved == [] + assert any("Pet.schema.json" in ref for ref in result.resolved_refs) + + +def test_dereference_resolves_a_document_whose_root_is_a_ref(resolver, tmp_path): + """A referenced document can itself be `{"$ref": ..., "format": ...}`. + + This is how the schema.org-derived corpus models a refined datatype, and copying such a + document's keys verbatim would leave a live $ref in supposedly dereferenced output. + """ + (tmp_path / "Base.schema.json").write_text( + json.dumps({"$id": "Base.schema.json", "type": "string"}), encoding="utf-8" + ) + (tmp_path / "Refined.schema.json").write_text( + json.dumps({"$id": "Refined.schema.json", "$ref": "Base.schema.json", "format": "email"}), + encoding="utf-8", + ) + (tmp_path / "Holder.schema.json").write_text( + json.dumps({ + "$id": "Holder.schema.json", + "type": "object", + "properties": {"mail": {"$ref": "Refined.schema.json"}}, + }), + encoding="utf-8", + ) + + result = resolver.dereference(resolver.load(tmp_path / "Holder.schema.json")) + mail = bound_schema(result.schema)["properties"]["mail"] + assert mail == {"type": "string", "format": "email"} + assert "$ref" not in json.dumps(bound_schema(result.schema)) + + +def test_unresolvable_ref_is_data_not_an_exception(resolver, tmp_path): + (tmp_path / "A.schema.json").write_text( + json.dumps({"properties": {"x": {"$ref": "Nope.schema.json"}}}), encoding="utf-8" + ) + result = resolver.dereference(resolver.load(tmp_path / "A.schema.json")) + assert result.unresolved + assert not result.ok + + +def test_bound_schema_cuts_a_cycle(): + cyclic = {"type": "object", "properties": {}} + cyclic["properties"]["self"] = cyclic + out = bound_schema(cyclic) + assert out["properties"]["self"] == {"format": CUT_FORMAT} + json.dumps(out) # must be finite and serialisable + + +def test_bound_schema_keeps_shared_nodes_intact(): + """A node reached by two paths is shared, not cyclic, and must not be cut.""" + leaf = {"type": "string"} + out = bound_schema({"type": "object", "properties": {"a": leaf, "b": leaf}}) + assert out["properties"]["a"] == {"type": "string"} + assert out["properties"]["b"] == {"type": "string"} + + +def test_bound_schema_drops_identity_keywords(): + out = bound_schema({"$id": "x", "$schema": "y", "type": "object"}) + assert out == {"type": "object"} + + +def test_composition_hops_do_not_consume_depth(): + """allOf adds JSON depth without nesting the instance. + + Counting it would cut inherited property constraints on any schema a few subclasses deep, + silently turning them permissive. + """ + deep = {"allOf": [{"allOf": [{"allOf": [{"allOf": [{"type": "string"}]}]}]}]} + assert bound_schema(deep, max_depth=2) == deep + + +def test_items_nesting_consumes_depth(): + nested = {"items": {"items": {"items": {"items": {"type": "string"}}}}} + out = bound_schema(nested, max_depth=2) + assert out == {"items": {"items": {"items": {"format": CUT_FORMAT}}}} + + +def test_properties_nesting_is_depth_neutral(): + """Verified against the reference implementation rather than inferred. + + `properties` enqueues its members at depth+1 but also enqueues the map itself at the + current depth, and walking that map re-enqueues the members at the lower value, which wins. + Only the instance keywords actually cut on depth. Reproduced deliberately for parity; + termination does not rely on it, since cycles are cut path-locally. + """ + nested = {"properties": {"a": {"properties": {"b": {"properties": {"c": {"type": "string"}}}}}}} + assert bound_schema(nested, max_depth=2) == nested + + +def test_disk_cache_is_reused(tmp_path, monkeypatch): + cache = tmp_path / "cache" + calls = [] + + def fake_get(uri, timeout=10.0): + calls.append(uri) + return {"title": "Remote"} + + monkeypatch.setattr("oold.validation.resolve.http_get_json", fake_get) + + first = Resolver(cache_dir=cache) + assert first.fetch("https://example.org/a.json")["title"] == "Remote" + assert calls == ["https://example.org/a.json"] + + # A fresh resolver with the same cache directory must not fetch again. + second = Resolver(cache_dir=cache) + assert second.fetch("https://example.org/a.json")["title"] == "Remote" + assert calls == ["https://example.org/a.json"], "the disk cache was not reused" + assert second.fetched == [] diff --git a/uv.lock b/uv.lock index 2eeb5b7..274af40 100644 --- a/uv.lock +++ b/uv.lock @@ -879,6 +879,63 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + [[package]] name = "datamodel-code-generator" version = "0.54.1" @@ -1374,6 +1431,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -1439,6 +1509,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -1670,6 +1756,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/94/a8066f84d62ab666d61ef97deba1a33126e3e5c0c0da2c458ada17053ed6/jsondiff-2.2.1-py3-none-any.whl", hash = "sha256:b1f0f7e2421881848b1d556d541ac01a91680cfcc14f51a9b62cdf4da0e56722", size = 13440, upload-time = "2024-08-29T04:09:04.955Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "jupyter-bokeh" version = "4.1.0" @@ -1991,6 +2105,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] +[[package]] +name = "mcp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.6.1" @@ -2529,15 +2681,25 @@ dependencies = [ [package.optional-dependencies] all = [ { name = "anywidget" }, + { name = "click" }, { name = "ipykernel" }, { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jsonschema" }, { name = "jupyter-bokeh" }, + { name = "mcp" }, { name = "nicegui" }, { name = "panel" }, { name = "param" }, + { name = "referencing" }, { name = "traitlets" }, ] +mcp = [ + { name = "click" }, + { name = "jsonschema" }, + { name = "mcp" }, + { name = "referencing" }, +] ui = [ { name = "anywidget" }, { name = "ipykernel" }, @@ -2564,11 +2726,19 @@ ui-panel = [ { name = "panel" }, { name = "param" }, ] +validation = [ + { name = "click" }, + { name = "jsonschema" }, + { name = "referencing" }, +] [package.dev-dependencies] dev = [ + { name = "click" }, { name = "deptry" }, { name = "jsondiff" }, + { name = "jsonschema" }, + { name = "mcp" }, { name = "mkdocstrings-python" }, { name = "panel" }, { name = "pre-commit" }, @@ -2577,6 +2747,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-playwright" }, { name = "python-semantic-release" }, + { name = "referencing" }, { name = "ruff" }, { name = "tox-uv" }, { name = "ty" }, @@ -2588,6 +2759,9 @@ requires-dist = [ { name = "anywidget", marker = "extra == 'all'" }, { name = "anywidget", marker = "extra == 'ui'" }, { name = "anywidget", marker = "extra == 'ui-jupyter'" }, + { name = "click", marker = "extra == 'all'", specifier = ">=8.1" }, + { name = "click", marker = "extra == 'mcp'", specifier = ">=8.1" }, + { name = "click", marker = "extra == 'validation'", specifier = ">=8.1" }, { name = "datamodel-code-generator", specifier = ">=0.51.0,<0.55.0" }, { name = "ipykernel", marker = "extra == 'all'" }, { name = "ipykernel", marker = "extra == 'ui'" }, @@ -2596,9 +2770,14 @@ requires-dist = [ { name = "ipython", marker = "extra == 'ui'" }, { name = "ipython", marker = "extra == 'ui-jupyter'" }, { name = "jsondiff" }, + { name = "jsonschema", marker = "extra == 'all'", specifier = ">=4.20" }, + { name = "jsonschema", marker = "extra == 'mcp'", specifier = ">=4.20" }, + { name = "jsonschema", marker = "extra == 'validation'", specifier = ">=4.20" }, { name = "jupyter-bokeh", marker = "extra == 'all'" }, { name = "jupyter-bokeh", marker = "extra == 'ui'" }, { name = "jupyter-bokeh", marker = "extra == 'ui-panel'" }, + { name = "mcp", marker = "extra == 'all'", specifier = ">=1.2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2" }, { name = "nicegui", marker = "extra == 'all'" }, { name = "nicegui", marker = "extra == 'ui'" }, { name = "nicegui", marker = "extra == 'ui-nicegui'" }, @@ -2612,18 +2791,24 @@ requires-dist = [ { name = "pyld" }, { name = "pyyaml" }, { name = "rdflib" }, + { name = "referencing", marker = "extra == 'all'", specifier = ">=0.30" }, + { name = "referencing", marker = "extra == 'mcp'", specifier = ">=0.30" }, + { name = "referencing", marker = "extra == 'validation'", specifier = ">=0.30" }, { name = "sparqlwrapper" }, { name = "traitlets", marker = "extra == 'all'" }, { name = "traitlets", marker = "extra == 'ui'" }, { name = "traitlets", marker = "extra == 'ui-jupyter'" }, { name = "typing-extensions" }, ] -provides-extras = ["ui-panel", "ui-jupyter", "ui-nicegui", "ui", "all"] +provides-extras = ["validation", "mcp", "ui-panel", "ui-jupyter", "ui-nicegui", "ui", "all"] [package.metadata.requires-dev] dev = [ + { name = "click", specifier = ">=8.1" }, { name = "deptry", specifier = ">=0.25.1" }, { name = "jsondiff" }, + { name = "jsonschema", specifier = ">=4.20" }, + { name = "mcp", specifier = ">=1.2" }, { name = "mkdocstrings-python", specifier = ">=1.0.3" }, { name = "panel" }, { name = "pre-commit", specifier = ">=4.5.1" }, @@ -2632,12 +2817,25 @@ dev = [ { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-playwright" }, { name = "python-semantic-release", specifier = ">=10.0.0" }, + { name = "referencing", specifier = ">=0.30" }, { name = "ruff", specifier = ">=0.15.7" }, { name = "tox-uv", specifier = ">=1.33.4" }, { name = "ty", specifier = ">=0.0.24" }, { name = "zensical", specifier = ">=0.0.26" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -3481,6 +3679,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyld" version = "3.0.0" @@ -3785,6 +4000,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/5a/f8c0868199bbb231a02616286ce8a4ccb85f5387b9215510297dcfedd214/pyviz_comms-3.0.6-py3-none-any.whl", hash = "sha256:4eba6238cd4a7f4add2d11879ce55411785b7d38a7c5dba42c7a0826ca53e6c2", size = 84275, upload-time = "2025-06-20T16:50:28.826Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3947,6 +4187,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -3999,6 +4254,262 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "ruff" version = "0.15.17" @@ -4075,6 +4586,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/89/176e3db96e31e795d7dfd91dd67749d3d1f0316bb30c6931a6140e1a0477/SPARQLWrapper-2.0.0-py3-none-any.whl", hash = "sha256:c99a7204fff676ee28e6acef327dc1ff8451c6f7217dcd8d49e8872f324a8a20", size = 28620, upload-time = "2022-03-13T23:13:58.969Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -4282,6 +4806,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "ty" version = "0.0.49" diff --git a/zensical.toml b/zensical.toml index 64402cb..f4bcdbd 100644 --- a/zensical.toml +++ b/zensical.toml @@ -20,6 +20,7 @@ nav = [ { "Object Graph Mapping" = "how-to/object-graph-mapping.md" }, { "Backends" = "how-to/backends.md" }, { "RDF Export" = "how-to/rdf-export.md" }, + { "Validation" = "how-to/validation.md" }, { "BaseController" = "how-to/controller.md" }, ]}, { "Architecture" = "architecture.md" }, From 8e96921d72d05a8842348ce715004748ecdcc423 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 31 Jul 2026 14:05:21 +0200 Subject: [PATCH 02/29] feat(validation): track meta-schema v0.8.0 - Vendors v0.8.0 into the meta-schema version history; `latest` now resolves to it, 0.7.0 stays - Refreshes the fixture slice from the same tag - Excludes vendored meta-schemas and fixtures from pre-commit's pretty-format-json and whitespace fixers, which broke their recorded sha256 checksums - Adds .gitattributes marking vendored files -text to stop core.autocrlf rewriting line endings - Splits test_selection_expands_and_deduplicates into three tests pinning selector order, `all` version order, and explicit order --- .gitattributes | 7 ++ .mcp.json | 9 +- .pre-commit-config.yaml | 12 +- docs/how-to/validation.md | 24 +++- .../meta/0.8.0/oold-meta-schema.json | 106 ++++++++++++++++++ .../meta/0.8.0/oold-pattern-lint.schema.json | 53 +++++++++ .../meta/0.8.0/oold-ui-meta-schema.json | 93 +++++++++++++++ src/oold/validation/meta/README.md | 16 ++- src/oold/validation/meta/index.json | 13 +++ tests/data/format_parity.json | 4 +- tests/data/oold/Address.schema.json | 2 +- tests/data/oold/Contact.schema.json | 2 +- .../data/oold/ContactSeparateKeys.schema.json | 2 +- tests/data/oold/Minimal.schema.json | 2 +- tests/data/oold/Organization.schema.json | 18 ++- tests/data/oold/OwlOrganization.schema.json | 2 +- tests/data/oold/Person.schema.json | 2 +- tests/data/oold/README.md | 6 +- tests/data/oold/RdfPerson.schema.json | 2 +- tests/data/oold/Researcher.schema.json | 2 +- tests/data/oold/Thing.schema.json | 2 +- tests/data/oold/UiAnnotations.schema.json | 2 +- .../array_without_container.schema.json | 33 +++--- .../data/oold/broken/invalid_meta.schema.json | 25 +++-- .../broken/missing_context_term.schema.json | 32 +++--- .../oold/broken/undefined_prefix.schema.json | 26 +++-- .../unresolvable_context_ref.schema.json | 29 +++-- .../broken/xsd_string_coercion.schema.json | 31 +++-- tests/data/oold/compliance/oold-vocab.json | 56 ++++----- .../oold/compliance/roundtrip-patterns.json | 78 ++++++++++++- .../data/oold/remote_context/Leaf.schema.json | 42 ++++--- tests/test_validation/test_meta_store.py | 41 ++++++- 32 files changed, 621 insertions(+), 153 deletions(-) create mode 100644 .gitattributes create mode 100644 src/oold/validation/meta/0.8.0/oold-meta-schema.json create mode 100644 src/oold/validation/meta/0.8.0/oold-pattern-lint.schema.json create mode 100644 src/oold/validation/meta/0.8.0/oold-ui-meta-schema.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e8d6472 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Files copied verbatim from the oold-schema repository at a release tag must keep their exact +# bytes: `src/oold/validation/meta/index.json` records a sha256 for each vendored meta-schema, and +# the fixtures are refreshed by copying bytes straight out of a tag. With `core.autocrlf=true` - +# the Windows default - git would rewrite their line endings on checkout, silently breaking those +# checksums and making every refresh from upstream show a whole-file diff. +src/oold/validation/meta/0.*/** -text +tests/data/oold/** -text diff --git a/.mcp.json b/.mcp.json index f4e35fa..234be34 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,14 @@ "mcpServers": { "oold-validation": { "command": "uv", - "args": ["run", "--directory", ".", "python", "-m", "oold.validation.mcp_server"] + "args": [ + "run", + "--directory", + ".", + "python", + "-m", + "oold.validation.mcp_server" + ] } } } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 347cdfe..88aac64 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,6 +2,14 @@ # `pre-commit install` (no --hook-type flags needed). default_install_hook_types: [pre-commit, commit-msg] +# Files copied verbatim from the oold-schema repository at a release tag: the vendored +# meta-schemas (whose sha256 is recorded in meta/index.json) and the OO-LD test fixtures. +# Reformatting them would break those checksums and make every future refresh from upstream +# produce a spurious diff, so the whitespace/JSON-formatting hooks must leave them alone. +# `check-json` still runs on them - it validates without rewriting. +x-vendored: &vendored >- + ^(src/oold/validation/meta/[0-9]|tests/data/oold/) + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: "v6.0.0" @@ -13,10 +21,12 @@ repos: - id: check-json exclude: ^.devcontainer/devcontainer.json - id: pretty-format-json - exclude: ^.devcontainer/devcontainer.json + exclude: ^(\.devcontainer/devcontainer\.json|src/oold/validation/meta/[0-9]|tests/data/oold/) args: [--autofix, --no-sort-keys] - id: end-of-file-fixer + exclude: *vendored - id: trailing-whitespace + exclude: *vendored - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.15.7" diff --git a/docs/how-to/validation.md b/docs/how-to/validation.md index d58e46d..510c6f7 100644 --- a/docs/how-to/validation.md +++ b/docs/how-to/validation.md @@ -45,7 +45,7 @@ Exit code is 0 only when no check failed. Warnings do not fail a run. | Option | Meaning | |---|---| -| `--meta VERSION` | `latest` (default), a version such as `0.7.0`, `remote`, or `all`. Repeatable. | +| `--meta VERSION` | `latest` (default), a version such as `0.8.0`, `remote`, or `all`. Repeatable. | | `--offline` | Never fetch; use local files and the cache only. | | `--verbose` | Show passing checks too, not just problems. | | `--json` | Emit the report as JSON. | @@ -58,16 +58,28 @@ version under `src/oold/validation/meta//`, so validation works offline can be checked against several versions at once. ```bash -oold validate ./schemas --meta 0.7.0 --meta remote +oold validate ./schemas --meta 0.7.0 --meta 0.8.0 ``` Only two checks depend on the version, `schema.meta` and `lint.pattern`, and only those are -repeated per version; everything else runs once. Results carry the version they came from: - -``` -FAIL lint.pattern roundtrip-patterns.json [0.7.0]: expected lint fail, got pass +repeated per version; everything else runs once. Results carry the version they came from, so a +difference between releases is visible rather than confusing. This is reproducible against the +committed fixtures: + +```console +$ oold compliance tests/data/oold/compliance --offline --meta 0.7.0 --meta 0.8.0 +FAIL tests/data/oold/compliance + meta-schema: 0.7.0, 0.8.0 + 138 ok, 2 failed, 0 warning(s), 0 skipped, across 52 target(s) + + FAIL compliance.lint ... @type: xsd:integer is never selected on the way back ... [0.7.0] + FAIL compliance.lint ... @type: xsd:boolean and xsd:double are rejected ... [0.7.0] ``` +Both failures are real and expected: 0.8.0 extended the no-coercion rule from `xsd:string` to every +natively-JSON-encoded datatype, so fixtures written for 0.8.0 assert something 0.7.0's lint cannot +catch. The `[0.7.0]` tag is what tells you this is a version difference rather than a broken schema. + `remote` fetches the unreleased `main` state into `~/.cache/oold/meta/` (override with `OOLD_CACHE_DIR`). It never writes into the tracked history, so a released version cannot change meaning behind your back. Adding a version is documented in diff --git a/src/oold/validation/meta/0.8.0/oold-meta-schema.json b/src/oold/validation/meta/0.8.0/oold-meta-schema.json new file mode 100644 index 0000000..db33f9d --- /dev/null +++ b/src/oold/validation/meta/0.8.0/oold-meta-schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$dynamicAnchor": "meta", + "title": "OO-LD dialect meta-schema", + "$comment": "The $id uses the versioned hosting at oo-ld.org/ (the source keeps the /latest/ placeholder; each released copy is stamped per release). The OO-LD vocabulary is declared optional (false) so that generic JSON-Schema 2020-12 validators still process OO-LD schemas. The UI keyword definitions are included via the oold-ui-meta-schema #keywords anchor so a schema carrying x-oold-ui-* annotations validates in one pass.", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://oo-ld.org/latest/vocab/oold": false + }, + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + { "$ref": "https://oo-ld.org/latest/meta/oold-ui-meta-schema.json#keywords" } + ], + "properties": { + "@context": { + "description": "JSON-LD context for instances of this schema. The schema is consumed as a remote JSON-LD context; this entry is ignored by JSON-Schema validators." + }, + "x-oold-context": { + "description": "Extended term mappings (synonyms): an object keyed by term, each holding a dict keyed by synonym IRI whose value is a JSON-LD term-definition fragment plus an optional strippable x-sssom block. Supports more than two mappings per term, override under composition (most-derived-wins; null removes), prefix-driven ontology-family prioritization, and SSSOM round-trip. Promoted into @context by OO-LD-aware tooling; see the 'Term mappings and synonyms' section.", + "type": "object", + "examples": [ + { "name": { "skos:prefLabel": { "x-sssom": { "predicate_id": "skos:exactMatch", "confidence": 0.95 } } } } + ] + }, + "x-oold-uuid": { + "description": "Stable UUID identifying this schema across versions and locations.", + "type": "string", + "format": "uuid" + }, + "x-oold-version": { + "description": "Semantic version of this schema.", + "type": "string" + }, + "x-oold-prior-version": { + "description": "Identifier or version of the immediately preceding schema version.", + "type": "string" + }, + "x-oold-backward-compatible-with": { + "description": "URI of a prior schema version this schema is backward-compatible with.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-incompatible-with": { + "description": "URI of a prior schema version this schema is NOT compatible with.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-iri": { + "description": "Ontology IRI (or compact IRI) denoting the class described by this schema.", + "type": "string" + }, + "x-oold-instance-rdf-type": { + "description": "The rdf:type(s) carried by instances of this schema, as a list of IRIs (e.g. [\"schema:Person\"]). OO-LD tooling materializes these as @type when exporting an instance to JSON-LD / RDF.", + "type": "array", + "items": { "type": "string" } + }, + "x-oold-ref": { + "description": "Reference to another OO-LD schema. Use x-oold-ref (not the standard $ref) for references that appear inside OO-LD custom keywords such as x-oold-range: there a plain $ref would be eagerly - and, for cyclic schema graphs, dangerously - dereferenced by generic JSON-Schema bundlers (the behaviour is undefined per Core section 9.4.2). Keep using the standard $ref for ordinary schema composition (allOf, properties, $defs), which bundlers are expected to resolve. x-oold-ref is resolved only by OO-LD-aware tools, lazily and with cycle handling.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-range": { + "description": "Type constraint on the target of an IRI-valued property: an IRI string, an array of IRIs, or an OO-LD subschema (using x-oold-ref for references). See the 'Range of properties' section.", + "anyOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "$comment": "OO-LD subschema form; references inside it use x-oold-ref. The reverse-property keywords (x-oold-reverse-*) are intentionally not validated within a range subschema for now." + } + ] + }, + "x-oold-multilang-title": { + "description": "Language map of translated `title` values keyed by BCP-47 language code.", + "type": "object", + "additionalProperties": { "type": "string" } + }, + "x-oold-multilang-description": { + "description": "Language map of translated `description` values keyed by BCP-47 language code.", + "type": "object", + "additionalProperties": { "type": "string" } + }, + "x-oold-reverse-properties": { + "description": "Properties stored on the related object but editable from this side, mapped via JSON-LD @reverse.", + "type": "object" + }, + "x-oold-reverse-required": { + "description": "Names of reverse properties that are required.", + "type": "array", + "items": { "type": "string" } + }, + "x-oold-reverse-default-properties": { + "description": "Deprecated. Names of reverse properties shown by default in generated user interfaces. Like the object-level defaultProperties array this is extend-only under composition; prefer a per-reverse-property x-oold-ui-default-property boolean, which is overridable.", + "deprecated": true, + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/src/oold/validation/meta/0.8.0/oold-pattern-lint.schema.json b/src/oold/validation/meta/0.8.0/oold-pattern-lint.schema.json new file mode 100644 index 0000000..d0b74c1 --- /dev/null +++ b/src/oold/validation/meta/0.8.0/oold-pattern-lint.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-pattern-lint.schema.json", + "title": "OO-LD round-trip pattern lint", + "description": "SHOULD-level constraints on a schema's @context that keep instances round-trip-safe, checkable by JSON Schema alone. This is distinct from oold-meta-schema.json, which asserts MUST-level well-formedness. It currently enforces that no term coerces a literal to a datatype JSON encodes natively (xsd:string, xsd:boolean, xsd:integer, xsd:double, xsd:float): xsd:string is RDF's default datatype and is elided from plain literals, and the round-trip contract reconstructs boolean/numeric literals as native JSON values (fromRDF with native types), so in both cases the value carries no @type and a term declaring one is never selected when the value is compacted back from RDF - the property returns under its full IRI and the round-trip is lossy (see the specification, Property value forms). JSON-LD derives the correct RDF datatype from the native JSON type, so these coercions are also redundant. Datatypes without a native JSON encoding (xsd:date, xsd:dateTime, ...) keep their @type through the round-trip and coerce fine. CURIEs are matched in their conventional xsd: form and as the full XSD IRI; a term that coerces through a non-standard prefix mapping is beyond what a single JSON Schema can resolve and is left to tooling.", + "type": "object", + "properties": { + "@context": { "$ref": "#/$defs/context" } + }, + "$defs": { + "context": { + "oneOf": [ + { "type": "null" }, + { "type": "string" }, + { "type": "array", "items": { "$ref": "#/$defs/context" } }, + { "$ref": "#/$defs/contextObject" } + ] + }, + "contextObject": { + "type": "object", + "patternProperties": { + "^@": true, + "^[^@]": { "$ref": "#/$defs/termValue" } + }, + "additionalProperties": { "$ref": "#/$defs/termValue" } + }, + "termValue": { + "oneOf": [ + { "type": "null" }, + { "type": "string" }, + { "$ref": "#/$defs/termDefinition" } + ] + }, + "termDefinition": { + "type": "object", + "properties": { + "@type": { "$ref": "#/$defs/notNativeJsonDatatype" }, + "@context": { "$ref": "#/$defs/context" } + } + }, + "notNativeJsonDatatype": { + "not": { + "enum": [ + "xsd:string", "http://www.w3.org/2001/XMLSchema#string", + "xsd:boolean", "http://www.w3.org/2001/XMLSchema#boolean", + "xsd:integer", "http://www.w3.org/2001/XMLSchema#integer", + "xsd:double", "http://www.w3.org/2001/XMLSchema#double", + "xsd:float", "http://www.w3.org/2001/XMLSchema#float" + ] + } + } + } +} diff --git a/src/oold/validation/meta/0.8.0/oold-ui-meta-schema.json b/src/oold/validation/meta/0.8.0/oold-ui-meta-schema.json new file mode 100644 index 0000000..1d5e981 --- /dev/null +++ b/src/oold/validation/meta/0.8.0/oold-ui-meta-schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-ui-meta-schema.json", + "$dynamicAnchor": "meta", + "title": "OO-LD UI dialect meta-schema", + "$comment": "The $id uses the versioned hosting at oo-ld.org/ (the source keeps the /latest/ placeholder; each released copy is stamped per release). The oold-ui vocabulary is declared optional (false) so that generic JSON-Schema 2020-12 validators still process the schema. The x-oold-ui-* keyword definitions live in $defs.keywords (plain anchor #keywords) so the main OO-LD meta-schema can include just them, without re-introducing the 2020-12 reference or a second dynamic anchor. Each keyword carries a description and an example so the vocabulary can be rendered into documentation. As with the core dialect, this meta-schema only validates that the keywords are well-formed; the behaviour is supplied by OO-LD-aware form generators (for example jedison).", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://oo-ld.org/latest/vocab/oold-ui": false + }, + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + { "$ref": "#keywords" } + ], + "$defs": { + "keywords": { + "$anchor": "keywords", + "properties": { + "x-oold-ui-widget": { + "description": "Widget hint for a value whose intended widget is not a registered JSON Schema format (for example table, tabs, grid, autocomplete, textarea, checkbox, markdown, color). Registered formats (date, uri, uuid, ...) stay in `format`. Maps to jedison `x-format`.", + "type": "string", + "examples": ["table", "autocomplete", "markdown"] + }, + "x-oold-ui-property-order": { + "description": "Display order of this property within its object or group; lower sorts first. Maps to jedison `x-categoryOrder`.", + "type": "integer", + "examples": [1] + }, + "x-oold-ui-property-group": { + "description": "Name of the group, tab or category this property belongs to. Maps to jedison `x-category` / `x-propGroup`.", + "type": "string", + "examples": ["General", "Contact"] + }, + "x-oold-ui-form-hidden": { + "description": "Hide this property in the editing form. Maps to jedison `x-hidden`.", + "type": "boolean", + "examples": [true] + }, + "x-oold-ui-render-hidden": { + "description": "Hide this property in the rendered (read) view.", + "type": "boolean", + "examples": [true] + }, + "x-oold-ui-enum-titles": { + "description": "Human display labels for the default language, aligned positionally with `enum`: the Nth label is the title of the Nth enum value. For `enum: [\"pi\", \"postdoc\", \"phd\"]` the value `[\"Principal investigator\", \"Postdoc\", \"PhD student\"]` labels each option. Localize with `x-oold-multilang-ui-enum-titles`. Distinct from the identifier-safe code names in `x-enum-varnames`. Maps to jedison `x-enumTitles`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["Principal investigator", "Postdoc", "PhD student"]] + }, + "x-oold-multilang-ui-enum-titles": { + "description": "BCP-47 language map of `x-oold-ui-enum-titles` arrays (mirrors `x-oold-multilang-title`); each array aligns positionally with `enum`. For `enum: [\"pi\", \"postdoc\", \"phd\"]`: {\"en\": [\"Principal investigator\", \"Postdoc\", \"PhD student\"], \"de\": [\"Projektleitung\", \"Postdoc\", \"Doktorand\"]}.", + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "string" } }, + "examples": [{ "en": ["Principal investigator", "Postdoc", "PhD student"], "de": ["Projektleitung", "Postdoc", "Doktorand"] }] + }, + "x-oold-ui-hint": { + "description": "Short help text shown with the field, in the default language. Localize with `x-oold-multilang-ui-hint`. Maps to jedison `x-info`.", + "type": "string", + "examples": ["Full name"] + }, + "x-oold-multilang-ui-hint": { + "description": "BCP-47 language map of the `x-oold-ui-hint` text (mirrors `x-oold-multilang-title`).", + "type": "object", + "additionalProperties": { "type": "string" }, + "examples": [{ "en": "Full name", "de": "Vollständiger Name" }] + }, + "x-oold-ui-default-property": { + "description": "Whether this optional property is shown by default in generated user interfaces. Replaces the object-level `defaultProperties` array: a per-property boolean is overridable under composition (most-derived-wins), so a derived schema can set it false, whereas the merged array form was extend-only.", + "type": "boolean", + "examples": [true] + }, + "x-enum-varnames": { + "description": "Identifier-safe code names aligned positionally with `enum`, for code generation. For `enum: [\"m\", \"s\"]` the value `[\"metre\", \"second\"]` names each option (so a generator can emit `Unit.metre` instead of `Unit.m`). An established vendor extension (OpenAPI Generator; NSwag uses the camelCase `x-enumNames`). Kept as-is; distinct from the human labels in `x-oold-ui-enum-titles`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["metre", "second"]] + }, + "x-enum-descriptions": { + "description": "Per-value descriptions aligned positionally with `enum`, the established companion of `x-enum-varnames`. For `enum: [\"m\", \"s\"]`: `[\"SI base unit of length\", \"SI base unit of time\"]`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["SI base unit of length", "SI base unit of time"]] + } + } + } + } +} diff --git a/src/oold/validation/meta/README.md b/src/oold/validation/meta/README.md index d8aa75e..5d33e0d 100644 --- a/src/oold/validation/meta/README.md +++ b/src/oold/validation/meta/README.md @@ -8,6 +8,7 @@ one schema can be checked against several meta-schema versions in a single run. meta/ ├── index.json provenance: upstream tag, commit, checksums ├── 0.7.0/ oold-meta-schema.json, oold-pattern-lint.schema.json, oold-ui-meta-schema.json +├── 0.8.0/ same three files; `latest` resolves here └── / ``` @@ -34,8 +35,19 @@ Extract from the **tag**, not from the working tree. The two diverge: at the tim `main` had already changed all three files, including the canonical `$id` domain. Then add an entry to `index.json` with the tag, commit, commit date, the `$id` base in use for that -release, and the checksums. Add `--meta $V` to a test run and confirm the suite still passes: -`uv run pytest tests/test_validation -q`. +release, and the checksums. + +Finally refresh the fixture slice in `tests/data/oold/` from the **same tag** (see its README), so +that fixtures and meta-schemas always come from one release, and confirm both still pass: + +```bash +uv run oold validate tests/data/oold --offline --meta all +make validate && uv run pytest tests/test_validation -q +``` + +Keeping the two in step is not cosmetic. A compliance fixture asserts the lint rules of the release +that introduced them, so a newer fixture set combined with an older meta-schema fails in ways that +say nothing about the code. ## Why `id_base` is recorded and not assumed diff --git a/src/oold/validation/meta/index.json b/src/oold/validation/meta/index.json index 00b70b7..780aeaf 100644 --- a/src/oold/validation/meta/index.json +++ b/src/oold/validation/meta/index.json @@ -22,6 +22,19 @@ "oold-pattern-lint.schema.json": "1e10d3efee06e590c81757fdf273695ecef6f5128ade5d8dd391c6f28fed7c1b", "oold-ui-meta-schema.json": "f584a8f535529369914a196af80adac7b937ffbb3fbb7c03d5ec0ef6d9d6cb7d" } + }, + "0.8.0": { + "tag": "v0.8.0", + "commit": "2a4546a95a6fe30573aa00e5f7f63e4080f979db", + "committed": "2026-07-31T07:43:29+02:00", + "added": "2026-07-31", + "id_base": "https://oo-ld.org/latest/meta/", + "notes": "Canonical domain moved to oo-ld.org, and the no-coercion pattern-lint rule was extended from xsd:string to every natively-JSON-encoded datatype (xsd:boolean, xsd:integer, xsd:double, xsd:float).", + "sha256": { + "oold-meta-schema.json": "b6b11b4bec20f997cb264fb828cd1cb7fd9ebf9bc177474e615c2dc090ff9c90", + "oold-pattern-lint.schema.json": "0537e7f7604ce666f666deab30cde90ca09c69a30ca068ea5ed9f8c69a81c6c3", + "oold-ui-meta-schema.json": "dd389d13a5e03268d4a4ff845dec7f4f28238f7edbd9fe0992399b37ac358212" + } } } } diff --git a/tests/data/format_parity.json b/tests/data/format_parity.json index b9537df..25b27d2 100644 --- a/tests/data/format_parity.json +++ b/tests/data/format_parity.json @@ -122,14 +122,14 @@ "urn:uuid:abc": true, "relative/path": false, "not an iri": false, - "https://example.org/ünïcode": true + "https://example.org/\u00fcn\u00efcode": true }, "iri-reference": { "https://example.org/thing": true, "ex:alice": true, "Thing.schema.json": true, "has\"quote": false, - "https://example.org/ünïcode": true, + "https://example.org/\u00fcn\u00efcode": true, "": true } } diff --git a/tests/data/oold/Address.schema.json b/tests/data/oold/Address.schema.json index eb1c102..ca8377d 100644 --- a/tests/data/oold/Address.schema.json +++ b/tests/data/oold/Address.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "Address.schema.json", "x-oold-uuid": "e8536464-a654-49ee-bd44-df60424b73b1", "x-oold-version": "1.0.0", diff --git a/tests/data/oold/Contact.schema.json b/tests/data/oold/Contact.schema.json index 3ae80c3..87684d9 100644 --- a/tests/data/oold/Contact.schema.json +++ b/tests/data/oold/Contact.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "Contact.schema.json", "x-oold-uuid": "f3a1c2d4-5b6e-47a8-9c0d-1e2f3a4b5c6d", "x-oold-version": "1.0.0", diff --git a/tests/data/oold/ContactSeparateKeys.schema.json b/tests/data/oold/ContactSeparateKeys.schema.json index b854814..1746dd7 100644 --- a/tests/data/oold/ContactSeparateKeys.schema.json +++ b/tests/data/oold/ContactSeparateKeys.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "ContactSeparateKeys.schema.json", "x-oold-uuid": "a7b8c9d0-1e2f-4a3b-8c5d-6e7f8a9b0c1d", "x-oold-version": "1.0.0", diff --git a/tests/data/oold/Minimal.schema.json b/tests/data/oold/Minimal.schema.json index 4d3efc2..01c3cae 100644 --- a/tests/data/oold/Minimal.schema.json +++ b/tests/data/oold/Minimal.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "Minimal.schema.json", "@context": { "schema": "http://schema.org/", diff --git a/tests/data/oold/Organization.schema.json b/tests/data/oold/Organization.schema.json index f9f3b83..9a28e0e 100644 --- a/tests/data/oold/Organization.schema.json +++ b/tests/data/oold/Organization.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "Organization.schema.json", "x-oold-uuid": "c6314242-8432-57cc-9b22-bd4e2026951f", "x-oold-version": "1.0.0", @@ -8,7 +8,8 @@ "Thing.schema.json", { "schema": "http://schema.org/", - "address": { "@id": "schema:address", "@context": "Address.schema.json" } + "address": { "@id": "schema:address", "@context": "Address.schema.json" }, + "employees": { "@reverse": "schema:worksFor", "@type": "@id" } } ], "title": "Organization", @@ -21,5 +22,18 @@ "$ref": "Address.schema.json", "description": "Postal address of the organization" } + }, + "x-oold-reverse-properties": { + "employees": { + "type": "array", + "title": "Employees", + "description": "Persons who work for this organization; stored on each Person as works_for, editable here via JSON-LD @reverse.", + "x-oold-ui-default-property": true, + "items": { + "type": "string", + "format": "iri-reference", + "x-oold-range": "Person.schema.json" + } + } } } diff --git a/tests/data/oold/OwlOrganization.schema.json b/tests/data/oold/OwlOrganization.schema.json index 7fcaa53..ecb77ec 100644 --- a/tests/data/oold/OwlOrganization.schema.json +++ b/tests/data/oold/OwlOrganization.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "OwlOrganization.schema.json", "x-oold-iri": "schema:Organization", "@context": { diff --git a/tests/data/oold/Person.schema.json b/tests/data/oold/Person.schema.json index fc05a9d..695c944 100644 --- a/tests/data/oold/Person.schema.json +++ b/tests/data/oold/Person.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "Person.schema.json", "x-oold-uuid": "b5203131-7321-46bb-8a11-acb3d1015840", "x-oold-version": "1.0.0", diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md index f09cc54..15ccee3 100644 --- a/tests/data/oold/README.md +++ b/tests/data/oold/README.md @@ -1,8 +1,8 @@ # OO-LD test fixtures A snapshot of [oold-schema](https://github.com/OO-LD/oold-schema) `examples/`, taken at tag -**v0.7.0** - the same release the tracked meta-schemas in `src/oold/validation/meta/0.7.0/` come -from. +**v0.8.0** - the same release the newest tracked meta-schemas in +`src/oold/validation/meta/0.8.0/` come from. That pairing matters. A compliance fixture asserts the lint rules of the version that introduced them, so combining a newer fixture set with an older meta-schema produces failures that say @@ -10,7 +10,7 @@ nothing about this code. Upstream's current `main` is covered instead by the opt (`tests/test_validation/test_parity_live.py`), which validate against `--meta remote`. ``` -. examples/ from v0.7.0, plus compliance/ +. examples/ from v0.8.0, plus compliance/ broken/ deliberately broken schemas: the checks must fail on these remote_context/ a schema whose @context chain leaves its directory ``` diff --git a/tests/data/oold/RdfPerson.schema.json b/tests/data/oold/RdfPerson.schema.json index e025778..fa4067f 100644 --- a/tests/data/oold/RdfPerson.schema.json +++ b/tests/data/oold/RdfPerson.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "RdfPerson.schema.json", "x-oold-iri": "schema:Person", "@context": { diff --git a/tests/data/oold/Researcher.schema.json b/tests/data/oold/Researcher.schema.json index 6ad9693..33c2993 100644 --- a/tests/data/oold/Researcher.schema.json +++ b/tests/data/oold/Researcher.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "Researcher.schema.json", "x-oold-uuid": "d7425353-9543-48dd-ac33-ce5f3137a62a", "x-oold-version": "1.0.0", diff --git a/tests/data/oold/Thing.schema.json b/tests/data/oold/Thing.schema.json index bf8d67e..065913e 100644 --- a/tests/data/oold/Thing.schema.json +++ b/tests/data/oold/Thing.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "Thing.schema.json", "x-oold-uuid": "1b4de2a0-9d3f-4c2e-9a1b-0e7c5f8a2d10", "x-oold-version": "1.0.0", diff --git a/tests/data/oold/UiAnnotations.schema.json b/tests/data/oold/UiAnnotations.schema.json index a3a287c..a9c7ff0 100644 --- a/tests/data/oold/UiAnnotations.schema.json +++ b/tests/data/oold/UiAnnotations.schema.json @@ -1,5 +1,5 @@ { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "UiAnnotations.schema.json", "@context": [ { diff --git a/tests/data/oold/broken/array_without_container.schema.json b/tests/data/oold/broken/array_without_container.schema.json index 3900866..056f169 100644 --- a/tests/data/oold/broken/array_without_container.schema.json +++ b/tests/data/oold/broken/array_without_container.schema.json @@ -1,13 +1,20 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "array_without_container.schema.json", - "title": "ArrayWithoutContainer", - "@context": { - "ex": "https://example.org/", - "tags": { "@id": "ex:tags" } - }, - "type": "object", - "properties": { - "tags": { "type": "array", "items": { "type": "string" } } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "array_without_container.schema.json", + "title": "ArrayWithoutContainer", + "@context": { + "ex": "https://example.org/", + "tags": { + "@id": "ex:tags" + } + }, + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/tests/data/oold/broken/invalid_meta.schema.json b/tests/data/oold/broken/invalid_meta.schema.json index b2052c1..922a3cb 100644 --- a/tests/data/oold/broken/invalid_meta.schema.json +++ b/tests/data/oold/broken/invalid_meta.schema.json @@ -1,9 +1,16 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "invalid_meta.schema.json", - "title": "InvalidMeta", - "x-oold-uuid": "not-a-uuid", - "@context": { "ex": "https://example.org/", "name": "ex:name" }, - "type": "object", - "properties": { "name": { "type": "string" } } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "invalid_meta.schema.json", + "title": "InvalidMeta", + "x-oold-uuid": "not-a-uuid", + "@context": { + "ex": "https://example.org/", + "name": "ex:name" + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/tests/data/oold/broken/missing_context_term.schema.json b/tests/data/oold/broken/missing_context_term.schema.json index f8ded75..9792fe6 100644 --- a/tests/data/oold/broken/missing_context_term.schema.json +++ b/tests/data/oold/broken/missing_context_term.schema.json @@ -1,14 +1,18 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "missing_context_term.schema.json", - "title": "MissingContextTerm", - "@context": { - "ex": "https://example.org/", - "name": "ex:name" - }, - "type": "object", - "properties": { - "name": { "type": "string" }, - "orphan": { "type": "string" } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "missing_context_term.schema.json", + "title": "MissingContextTerm", + "@context": { + "ex": "https://example.org/", + "name": "ex:name" + }, + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "orphan": { + "type": "string" + } + } +} diff --git a/tests/data/oold/broken/undefined_prefix.schema.json b/tests/data/oold/broken/undefined_prefix.schema.json index 4e8bc9b..255ddbd 100644 --- a/tests/data/oold/broken/undefined_prefix.schema.json +++ b/tests/data/oold/broken/undefined_prefix.schema.json @@ -1,12 +1,14 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "undefined_prefix.schema.json", - "title": "UndefinedPrefix", - "@context": { - "latitude": "schema:latitude" - }, - "type": "object", - "properties": { - "latitude": { "type": "number" } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "undefined_prefix.schema.json", + "title": "UndefinedPrefix", + "@context": { + "latitude": "schema:latitude" + }, + "type": "object", + "properties": { + "latitude": { + "type": "number" + } + } +} diff --git a/tests/data/oold/broken/unresolvable_context_ref.schema.json b/tests/data/oold/broken/unresolvable_context_ref.schema.json index 8c9451c..2944d3a 100644 --- a/tests/data/oold/broken/unresolvable_context_ref.schema.json +++ b/tests/data/oold/broken/unresolvable_context_ref.schema.json @@ -1,11 +1,18 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "unresolvable_context_ref.schema.json", - "title": "UnresolvableContextRef", - "@context": [ - "NoSuchSchema.schema.json", - { "ex": "https://example.org/", "name": "ex:name" } - ], - "type": "object", - "properties": { "name": { "type": "string" } } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "unresolvable_context_ref.schema.json", + "title": "UnresolvableContextRef", + "@context": [ + "NoSuchSchema.schema.json", + { + "ex": "https://example.org/", + "name": "ex:name" + } + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/tests/data/oold/broken/xsd_string_coercion.schema.json b/tests/data/oold/broken/xsd_string_coercion.schema.json index 6a9bda6..db0ba40 100644 --- a/tests/data/oold/broken/xsd_string_coercion.schema.json +++ b/tests/data/oold/broken/xsd_string_coercion.schema.json @@ -1,12 +1,19 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "xsd_string_coercion.schema.json", - "title": "XsdStringCoercion", - "@context": { - "ex": "https://example.org/", - "xsd": "http://www.w3.org/2001/XMLSchema#", - "name": { "@id": "ex:name", "@type": "xsd:string" } - }, - "type": "object", - "properties": { "name": { "type": "string" } } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "xsd_string_coercion.schema.json", + "title": "XsdStringCoercion", + "@context": { + "ex": "https://example.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "name": { + "@id": "ex:name", + "@type": "xsd:string" + } + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/tests/data/oold/compliance/oold-vocab.json b/tests/data/oold/compliance/oold-vocab.json index effded9..61a4c1f 100644 --- a/tests/data/oold/compliance/oold-vocab.json +++ b/tests/data/oold/compliance/oold-vocab.json @@ -7,7 +7,7 @@ "description": "all keywords well-formed", "valid": true, "schema": { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-uuid": "b5203131-7321-46bb-8a11-acb3d1015840", "x-oold-version": "1.0.0", "x-oold-prior-version": "0.9.0", @@ -48,38 +48,38 @@ { "description": "core x-oold-* keywords reject malformed values", "schemas": [ - { "description": "x-oold-uuid not a uuid", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-uuid": "nope" } }, - { "description": "x-oold-version not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-version": 1 } }, - { "description": "x-oold-prior-version not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-prior-version": 1 } }, - { "description": "x-oold-backward-compatible-with not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-backward-compatible-with": 1 } }, - { "description": "x-oold-incompatible-with not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-incompatible-with": 1 } }, - { "description": "x-oold-iri not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-iri": 1 } }, - { "description": "x-oold-instance-rdf-type not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-instance-rdf-type": "schema:Person" } }, - { "description": "x-oold-ref not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ref": 1 } }, - { "description": "x-oold-range as a number", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-range": 42 } }, - { "description": "x-oold-multilang-title not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-multilang-title": "Person" } }, - { "description": "x-oold-multilang-description not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-multilang-description": "a person" } }, - { "description": "x-oold-context not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-context": "x" } }, - { "description": "x-oold-reverse-properties not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-reverse-properties": "x" } }, - { "description": "x-oold-reverse-required not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-reverse-required": "x" } }, - { "description": "x-oold-reverse-default-properties not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-reverse-default-properties": "x" } } + { "description": "x-oold-uuid not a uuid", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-uuid": "nope" } }, + { "description": "x-oold-version not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-version": 1 } }, + { "description": "x-oold-prior-version not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-prior-version": 1 } }, + { "description": "x-oold-backward-compatible-with not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-backward-compatible-with": 1 } }, + { "description": "x-oold-incompatible-with not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-incompatible-with": 1 } }, + { "description": "x-oold-iri not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-iri": 1 } }, + { "description": "x-oold-instance-rdf-type not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-instance-rdf-type": "schema:Person" } }, + { "description": "x-oold-ref not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ref": 1 } }, + { "description": "x-oold-range as a number", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-range": 42 } }, + { "description": "x-oold-multilang-title not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-multilang-title": "Person" } }, + { "description": "x-oold-multilang-description not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-multilang-description": "a person" } }, + { "description": "x-oold-context not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-context": "x" } }, + { "description": "x-oold-reverse-properties not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-reverse-properties": "x" } }, + { "description": "x-oold-reverse-required not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-reverse-required": "x" } }, + { "description": "x-oold-reverse-default-properties not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-reverse-default-properties": "x" } } ] }, { "description": "UI x-oold-ui-* / x-enum-* keywords reject malformed values", "schemas": [ - { "description": "x-oold-ui-widget not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-widget": 1 } }, - { "description": "x-oold-ui-property-order not an integer", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-property-order": "first" } }, - { "description": "x-oold-ui-property-group not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-property-group": 1 } }, - { "description": "x-oold-ui-form-hidden not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-form-hidden": "x" } }, - { "description": "x-oold-ui-render-hidden not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-render-hidden": "x" } }, - { "description": "x-oold-ui-enum-titles not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-enum-titles": "x" } }, - { "description": "x-oold-multilang-ui-enum-titles not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-multilang-ui-enum-titles": "x" } }, - { "description": "x-oold-ui-hint not a string", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-hint": 1 } }, - { "description": "x-oold-multilang-ui-hint not an object", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-multilang-ui-hint": 1 } }, - { "description": "x-oold-ui-default-property not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-oold-ui-default-property": "x" } }, - { "description": "x-enum-varnames not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-enum-varnames": "x" } }, - { "description": "x-enum-descriptions not an array", "valid": false, "schema": { "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", "x-enum-descriptions": "x" } } + { "description": "x-oold-ui-widget not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-widget": 1 } }, + { "description": "x-oold-ui-property-order not an integer", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-property-order": "first" } }, + { "description": "x-oold-ui-property-group not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-property-group": 1 } }, + { "description": "x-oold-ui-form-hidden not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-form-hidden": "x" } }, + { "description": "x-oold-ui-render-hidden not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-render-hidden": "x" } }, + { "description": "x-oold-ui-enum-titles not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-enum-titles": "x" } }, + { "description": "x-oold-multilang-ui-enum-titles not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-multilang-ui-enum-titles": "x" } }, + { "description": "x-oold-ui-hint not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-hint": 1 } }, + { "description": "x-oold-multilang-ui-hint not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-multilang-ui-hint": 1 } }, + { "description": "x-oold-ui-default-property not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-default-property": "x" } }, + { "description": "x-enum-varnames not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-enum-varnames": "x" } }, + { "description": "x-enum-descriptions not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-enum-descriptions": "x" } } ] } ] diff --git a/tests/data/oold/compliance/roundtrip-patterns.json b/tests/data/oold/compliance/roundtrip-patterns.json index e2ae1b6..0e15625 100644 --- a/tests/data/oold/compliance/roundtrip-patterns.json +++ b/tests/data/oold/compliance/roundtrip-patterns.json @@ -1,7 +1,7 @@ [ { "$comment": "Round-trip-safe projection of an ambiguous property range (literal | reference | embedded object), using the address = Text | PostalAddress | Place example from the specification (Property value forms, Projection to RDF and round-trip). The `lintSchemas` group is checked against meta/oold-pattern-lint.schema.json; the `tests` group projects each value form to RDF (expectRdf, dataset isomorphism).", - "description": "the pattern lint rejects a literal term coerced to xsd:string, accepts a plain literal term", + "description": "the pattern lint rejects a literal term coerced to a natively-JSON-encoded datatype (xsd:string, xsd:boolean, xsd:integer, xsd:double, xsd:float), accepts a plain literal term", "lintSchemas": [ { "description": "a plain literal term (no @type) round-trips - lint passes", @@ -49,13 +49,81 @@ } } } + }, + { + "description": "@type: xsd:integer is never selected on the way back either (fromRDF with native types returns an untyped native number) - lint fails", + "valid": false, + "schema": { + "@context": { + "schema": "http://schema.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "userInteractionCount": { "@id": "schema:userInteractionCount", "@type": "xsd:integer" } + } + } + }, + { + "description": "@type: xsd:boolean and xsd:double are rejected for the same reason (native JSON encodings)", + "valid": false, + "schema": { + "@context": { + "schema": "http://schema.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "isAccessibleForFree": { "@id": "schema:isAccessibleForFree", "@type": "xsd:boolean" }, + "longitude": { "@id": "schema:longitude", "@type": "xsd:double" } + } + } + } + ] + }, + { + "feature": "native literals: plain terms (no @type coercion) round-trip boolean and numeric JSON values, deriving the RDF datatype from the native JSON type", + "$comment": "The complement of the lint above: numbers and booleans MUST be mapped by plain terms. JSON-LD projects them to xsd:integer/xsd:double/xsd:boolean literals from the native JSON type alone, and reconstruction (fromRDF with native types) returns untyped native values, which only a plain term compacts back to its key.", + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "x-oold-instance-rdf-type": ["schema:InteractionCounter"], + "@context": { + "schema": "http://schema.org/", + "id": "@id", + "type": "@type", + "userInteractionCount": { "@id": "schema:userInteractionCount" }, + "longitude": { "@id": "schema:longitude" }, + "isAccessibleForFree": { "@id": "schema:isAccessibleForFree" } + }, + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { "type": ["string", "array"] }, + "userInteractionCount": { "type": "integer" }, + "longitude": { "type": "number" }, + "isAccessibleForFree": { "type": "boolean" } + } + }, + "tests": [ + { + "description": "an integer projects to an xsd:integer literal from the native JSON type", + "data": { "id": "https://example.org/counter", "type": "schema:InteractionCounter", "userInteractionCount": 5 }, + "valid": true, + "expectRdf": " \"5\"^^ .\n .\n", + "roundtrip": true + }, + { + "description": "a fractional number projects to xsd:double and reconstructs as a native number", + "data": { "id": "https://example.org/counter", "type": "schema:InteractionCounter", "longitude": 13.4 }, + "valid": true, + "roundtrip": true + }, + { + "description": "a boolean projects to xsd:boolean and reconstructs as a native boolean", + "data": { "id": "https://example.org/counter", "type": "schema:InteractionCounter", "isAccessibleForFree": true }, + "valid": true, + "roundtrip": true } ] }, { "feature": "value-form: one plain `address` term projects a literal, a reference, and an embedded object to distinct RDF shapes", "schema": { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-instance-rdf-type": ["schema:Organization"], "@context": { "schema": "http://schema.org/", @@ -107,7 +175,7 @@ { "feature": "separate-keys: a canonical @type:@id `address` term (reference or embedded object) plus a plain `address_text` companion (literal), all projecting to schema:address", "schema": { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-instance-rdf-type": ["schema:Organization"], "@context": { "schema": "http://schema.org/", @@ -161,7 +229,7 @@ { "feature": "language-tagged text: an @language term projects a string to a language-tagged literal", "schema": { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-instance-rdf-type": ["schema:Organization"], "@context": { "schema": "http://schema.org/", @@ -187,7 +255,7 @@ "feature": "reverse round-trip: an exported instance reconstructs from RDF through the minimal schema-derived frame (literals and references by compaction, embedded objects by framing, arrays kept stable by @container:@set)", "$comment": "The instance carries its materialized root type (schema:Organization), as a compliant export must, so the schema-derived frame can pick it as the frame root and nest the embedded object beneath it. Reconstruction uses scripts/schema_to_frame.mjs; [roundtrip] asserts instance == reconstruction after canonicalization.", "schema": { - "$schema": "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-instance-rdf-type": ["schema:Organization"], "@context": { "schema": "http://schema.org/", diff --git a/tests/data/oold/remote_context/Leaf.schema.json b/tests/data/oold/remote_context/Leaf.schema.json index cf9148c..55f93e9 100644 --- a/tests/data/oold/remote_context/Leaf.schema.json +++ b/tests/data/oold/remote_context/Leaf.schema.json @@ -1,18 +1,24 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "Leaf.schema.json", - "title": "Leaf", - "x-oold-instance-rdf-type": ["schema:Thing"], - "@context": [ - "../Thing.schema.json", - { - "schema": "http://schema.org/", - "nickname": "schema:alternateName" - } - ], - "type": "object", - "properties": { - "name": { "type": "string" }, - "nickname": { "type": "string" } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "Leaf.schema.json", + "title": "Leaf", + "x-oold-instance-rdf-type": [ + "schema:Thing" + ], + "@context": [ + "../Thing.schema.json", + { + "schema": "http://schema.org/", + "nickname": "schema:alternateName" + } + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "nickname": { + "type": "string" + } + } +} diff --git a/tests/test_validation/test_meta_store.py b/tests/test_validation/test_meta_store.py index 3dc7795..0e2fce7 100644 --- a/tests/test_validation/test_meta_store.py +++ b/tests/test_validation/test_meta_store.py @@ -41,13 +41,25 @@ def test_index_records_provenance_for_every_tracked_version(): def test_recorded_checksums_match_the_shipped_files(): - """The store is curated by hand, so the checksums are what catch a bad copy.""" + """The store is curated by hand, so the checksums are what catch a bad copy. + + Two things have broken this in practice, and both are worth naming in the failure message + because neither is obvious from a hash mismatch: a JSON-formatting pre-commit hook rewriting + the file, and git's `core.autocrlf` converting line endings on checkout. `.gitattributes` + marks these paths `-text` and `.pre-commit-config.yaml` excludes them; if either is lost, + this test is what notices. + """ index = meta_store.load_index() for version in tracked_versions(): recorded = index["versions"][version].get("sha256") or {} for name, digest in recorded.items(): content = (meta_store.meta_dir() / version / name).read_bytes() - assert hashlib.sha256(content).hexdigest() == digest, f"{version}/{name} was modified" + assert hashlib.sha256(content).hexdigest() == digest, ( + f"{version}/{name} no longer matches the sha256 recorded in meta/index.json. " + "It is a verbatim copy of an oold-schema release tag, so either it was edited, " + "a formatting hook rewrote it, or git converted its line endings " + "(check .gitattributes marks it -text)." + ) def test_bundle_self_check_is_clean(): @@ -73,10 +85,31 @@ def test_unknown_version_names_what_is_available(): load_tracked("9.9.9") -def test_selection_expands_and_deduplicates(): +def test_selection_deduplicates_and_keeps_selector_order(): + """Selectors are honoured in the order given, not re-sorted by version. + + `--meta 0.8.0 --meta 0.7.0` should report in that order, because the caller chose it. So + `latest` first then `all` puts the newest first and backfills the rest, which is the + documented contract rather than an accident. + """ latest = latest_version() bundles = resolve_selection(["latest", "all", latest], offline=True) - assert [b.version for b in bundles] == tracked_versions() + versions = [b.version for b in bundles] + assert versions[0] == latest + assert sorted(versions) == sorted(tracked_versions()) + assert len(versions) == len(set(versions)) + + +def test_all_on_its_own_is_in_version_order(): + assert [b.version for b in resolve_selection(["all"], offline=True)] == tracked_versions() + + +def test_explicit_order_is_preserved(): + versions = tracked_versions() + if len(versions) < 2: + pytest.skip("needs at least two tracked versions") + reverse = list(reversed(versions)) + assert [b.version for b in resolve_selection(reverse, offline=True)] == reverse def test_selection_accepts_a_bare_string(): From f39b28057f24609789b5718099579fcffb49d560 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 31 Jul 2026 19:24:35 +0200 Subject: [PATCH 03/29] feat(validation): cite the specification's rule ids in findings - MetaBundle loads the optional oold-rules.json, exposing rule(), has_rules and checkable_rules() - Check gains a `rule` field, surfaced in the CLI (--verbose), the JSON report and MCP payloads - CHECK_RULES maps checks to rules centrally in _Run.add, covering the four checks that enforce a single identifiable requirement - New `oold rules list|explain` commands (--area, --unchecked) and a list_oold_rules MCP tool - New coverage.rules check reports checkable rules with no check; it only ever warns --- docs/how-to/validation.md | 41 +++- src/oold/validation/cli.py | 115 +++++++++- src/oold/validation/mcp_server.py | 55 ++++- src/oold/validation/meta_store.py | 51 +++++ src/oold/validation/pipeline.py | 88 +++++++- src/oold/validation/report.py | 10 +- tests/test_validation/test_mcp_server.py | 1 + tests/test_validation/test_meta_store.py | 17 +- tests/test_validation/test_parity_live.py | 21 ++ tests/test_validation/test_rules.py | 260 ++++++++++++++++++++++ 10 files changed, 650 insertions(+), 9 deletions(-) create mode 100644 tests/test_validation/test_rules.py diff --git a/docs/how-to/validation.md b/docs/how-to/validation.md index 510c6f7..8b71abc 100644 --- a/docs/how-to/validation.md +++ b/docs/how-to/validation.md @@ -85,6 +85,44 @@ catch. The `[0.7.0]` tag is what tells you this is a version difference rather t meaning behind your back. Adding a version is documented in `src/oold/validation/meta/README.md`. +## Rule citations + +Every finding can cite the normative statement it enforces. Rule ids come from the specification's +catalog (`meta/oold-rules.json`, generated upstream from the spec prose) and are permanent, so they +can be quoted in a review or a changelog: + +```console +$ oold validate Author.schema.json --verbose + FAIL OOLD-RT-002 lint.container Author.schema.json: strict array property without @container + https://oo-ld.org/latest/spec/#rule-OOLD-RT-002 +``` + +```bash +oold rules list # every rule, and the check that enforces it +oold rules list --area RT # just round-trip safety +oold rules list --unchecked # checkable rules no check enforces yet +oold rules explain OOLD-RT-002 # level, binding, spec text and link +``` + +The catalog was introduced upstream after 0.8.0, so no tracked version ships one yet; use +`--meta remote` until a release includes it. A version without a catalog is fully supported: it +validates exactly as before, findings simply carry no citation, and `coverage.rules` reports +`skip`. + +Each rule records **who it binds**, which decides what can enforce it: + +| `applies_to` | Meaning | +|---|---| +| `document` | Checkable by validating a schema or instance. These are what the validator can enforce | +| `implementation` | Constrains a library rather than a document; needs a conformance suite | +| `advisory` | Guidance that nothing verifies automatically | + +`coverage.rules` reports the gap between the checkable rules and the checks that exist. It is a +**warning**, never a failure: the gap is what the catalog exists to make visible, and an id absent +from an older catalog is indistinguishable from a typo, so failing would break validation against +older meta versions for no reason. A genuine typo is caught instead by the opt-in parity test, +which resolves every mapping against the current upstream catalog. + ## The checks | Check | What it asserts | @@ -102,6 +140,7 @@ meaning behind your back. Adding a version is documented in | `instance.schema` | A committed instance validates against its schema, with `format` asserted. | | `roundtrip.instance` | It round-trips through RDF unchanged. | | `compliance.*`, `coverage.vocab` | Fixture suites with exact expected outcomes, plus a cross-check that every meta-schema keyword has a test. | +| `coverage.rules` | *(warning)* Which checkable rules no check enforces yet. | ### Cyclic scoped contexts @@ -144,7 +183,7 @@ A working config is committed at `.mcp.json`: Transport is stdio. Tools: `validate_oold_schema`, `validate_oold_instance`, `validate_oold_directory`, `run_oold_compliance`, `generate_oold_instance`, -`check_context_mapping`, `list_meta_versions`. Each takes `verbosity` as `"summary"` (default) or +`check_context_mapping`, `list_meta_versions`, `list_oold_rules`. Each takes `verbosity` as `"summary"` (default) or `"full"`, and returns errors as data rather than raising. ## Differences from the reference harness diff --git a/src/oold/validation/cli.py b/src/oold/validation/cli.py index 06ce163..8cfaa2d 100644 --- a/src/oold/validation/cli.py +++ b/src/oold/validation/cli.py @@ -16,13 +16,17 @@ import click -from .meta_store import MetaSchemaError, describe_store, fetch_remote +from .meta_store import MetaSchemaError, describe_store, fetch_remote, resolve_selection from .pipeline import Options, run_compliance, validate_directory, validate_instance, validate_schema from .report import FAIL, OK, SKIP, WARN, Report EXIT_OK = 0 EXIT_FAILED = 1 +#: Where a rule id resolves in the published specification. The anchor is emitted by +#: oold-schema's spec renderer, so a report can link straight to the requirement it cites. +SPEC_RULE_URL = "https://oo-ld.org/latest/spec/#rule-" + _STATUS_STYLE = { OK: {"fg": "green"}, FAIL: {"fg": "red", "bold": True}, @@ -82,9 +86,12 @@ def _print_human(report: Report, verbose: bool) -> None: for check in shown: style = _STATUS_STYLE.get(check.status, {}) label = click.style(check.status.upper().ljust(4), **style) + rule = click.style(f" {check.rule}", fg="blue") if check.rule else "" version = f" [{check.meta_version}]" if check.meta_version else "" message = f": {check.message}" if check.message else "" - click.echo(f" {label} {check.id:<22} {check.target}{version}{message}") + click.echo(f" {label}{rule} {check.id:<22} {check.target}{version}{message}") + if check.rule and verbose: + click.echo(f" {SPEC_RULE_URL}{check.rule}") for note in report.notes[1:] if report.notes else []: click.echo(f" note: {note}") @@ -215,6 +222,109 @@ def meta_fetch(force: bool) -> None: click.echo(f"fetched into {target}") +@click.group("rules") +def rules_group() -> None: + """Look up the normative rules the validator cites.""" + + +@rules_group.command("list") +@_meta_option +@_offline_option +@click.option("--area", help="Only rules in this area, e.g. RT, CMP, INS.") +@click.option( + "--unchecked", + is_flag=True, + help="Only checkable rules that no check enforces yet, which is the coverage gap.", +) +@_json_option +def rules_list(meta, offline: bool, area: str | None, unchecked: bool, as_json: bool) -> None: + """List the rules in the specification's catalog.""" + from .pipeline import CHECK_RULES + + bundle = _rules_bundle(meta, offline) + rules = bundle.rules + if area: + rules = [r for r in rules if r["area"].upper() == area.upper()] + if unchecked: + enforced = set(CHECK_RULES.values()) + rules = [r for r in bundle.checkable_rules() if r["id"] not in enforced] + + if as_json: + click.echo(json.dumps(rules, indent=2)) + return + if not rules: + click.echo("no rules match") + return + + enforced_by = {v: k for k, v in CHECK_RULES.items()} + for rule in rules: + flag = "!" if rule.get("deprecated") else " " + check = enforced_by.get(rule["id"], "-") + click.echo( + f"{flag}{click.style(rule['id'], fg='blue')} {rule['level']:<10} " + f"{rule['applies_to']:<14} {check:<20} {rule['summary']}" + ) + click.echo() + click.echo(f" {len(rules)} rule(s); the column before the summary is the check that enforces each") + + +@rules_group.command("explain") +@click.argument("rule_id") +@_meta_option +@_offline_option +@_json_option +def rules_explain(rule_id: str, meta, offline: bool, as_json: bool) -> None: + """Show one rule in full: its level, what it binds, and the specification text.""" + from .pipeline import CHECK_RULES + + bundle = _rules_bundle(meta, offline) + rule = bundle.rule(rule_id.upper()) + if rule is None: + raise click.ClickException( + f"{rule_id} is not in the catalog for meta-schema {bundle.version}. " + "Try `oold rules list` to see what is available." + ) + if as_json: + click.echo(json.dumps(rule, indent=2)) + return + + enforced_by = {v: k for k, v in CHECK_RULES.items()} + click.echo(click.style(rule["id"], fg="blue", bold=True) + f" {rule['level']}") + click.echo(f" {rule['summary']}") + click.echo() + click.echo(f" area {rule['area']}") + click.echo(f" applies to {rule['applies_to']}") + click.echo( + f" checkable {rule['checkable']}" + + (f" (enforced by {enforced_by[rule['id']]})" if rule["id"] in enforced_by else "") + ) + click.echo(f" since {rule['since']}") + if rule.get("deprecated"): + click.echo( + f" {click.style('DEPRECATED', fg='yellow')} superseded by {', '.join(rule.get('superseded_by', [])) or 'nothing'}" + ) + click.echo(f" spec {SPEC_RULE_URL}{rule['id']}") + click.echo() + click.echo(click.style(" specification text:", bold=True)) + click.echo(click.wrap_text(rule["text"], width=94, initial_indent=" ", subsequent_indent=" ")) + + +def _rules_bundle(meta, offline: bool): + """The first selected bundle that actually ships a catalog.""" + try: + bundles = resolve_selection(tuple(meta) or ("latest",), offline=offline) + except MetaSchemaError as exc: + raise click.ClickException(str(exc)) from exc + for bundle in bundles: + if bundle.has_rules: + return bundle + names = ", ".join(b.version for b in bundles) + raise click.ClickException( + f"meta-schema version(s) {names} ship no rule catalog. It was introduced upstream after " + "0.8.0, so try `--meta remote` once oold-schema has published it." + ) + + @click.group() @click.version_option(package_name="oold") def main() -> None: @@ -225,6 +335,7 @@ def main() -> None: main.add_command(validate_instance_command) main.add_command(compliance_command) main.add_command(meta_group) +main.add_command(rules_group) if __name__ == "__main__": diff --git a/src/oold/validation/mcp_server.py b/src/oold/validation/mcp_server.py index c3dfdc2..75b7714 100644 --- a/src/oold/validation/mcp_server.py +++ b/src/oold/validation/mcp_server.py @@ -29,8 +29,9 @@ # decorator and `run(transport=...)` - are identical across both. from mcp.server.fastmcp import FastMCP as _Server # ty: ignore[unresolved-import] +from .cli import SPEC_RULE_URL from .generate import generate -from .meta_store import MetaSchemaError, describe_store +from .meta_store import MetaSchemaError, describe_store, resolve_selection from .pipeline import Options, run_compliance, validate_directory, validate_instance, validate_schema from .predicates import check_predicates from .report import Report, failure_reasons @@ -249,6 +250,58 @@ def check_context_mapping(document: str, context: str | None = None) -> dict[str return check_predicates(payload, active).to_dict(include_documents=True) +@mcp.tool() +def list_oold_rules( + meta: list[str] | None = None, + area: str | None = None, + unenforced_only: bool = False, + offline: bool = False, +) -> dict[str, Any]: + """List the normative rules the specification defines, and which checks enforce them. + + Every validation finding cites a rule id such as OOLD-RT-002; this resolves those ids to the + requirement text, its level (MUST / SHOULD / ...), and the specification URL. Use it to + explain a finding, or with unenforced_only to see which requirements the validator does not + yet check. + + Args: + meta: Meta-schema versions to read the catalog from. The catalog was introduced upstream + after 0.8.0, so ["remote"] may be needed until a release ships it. + area: Restrict to one area, e.g. RT (round-trip), CMP (composition), INS (instances). + unenforced_only: Only checkable rules that no check enforces yet. + offline: Never fetch over the network. + """ + from .pipeline import CHECK_RULES + + try: + bundles = resolve_selection(tuple(meta) if meta else ("latest",), offline=offline) + except MetaSchemaError as exc: + return {"rules": [], "error": str(exc)} + + bundle = next((b for b in bundles if b.has_rules), None) + if bundle is None: + return { + "rules": [], + "error": ( + f"meta-schema version(s) {', '.join(b.version for b in bundles)} ship no rule " + "catalog; try meta=['remote']" + ), + } + + enforced_by = {v: k for k, v in CHECK_RULES.items()} + rules = bundle.checkable_rules() if unenforced_only else bundle.rules + if area: + rules = [r for r in rules if r["area"].upper() == area.upper()] + if unenforced_only: + rules = [r for r in rules if r["id"] not in enforced_by] + + return { + "meta_version": bundle.version, + "count": len(rules), + "rules": [{**r, "enforced_by": enforced_by.get(r["id"]), "spec_url": SPEC_RULE_URL + r["id"]} for r in rules], + } + + @mcp.tool() def list_meta_versions() -> dict[str, Any]: """List the tracked meta-schema versions, which one is `latest`, and the remote cache state. diff --git a/src/oold/validation/meta_store.py b/src/oold/validation/meta_store.py index 7d3e60c..b3ebb67 100644 --- a/src/oold/validation/meta_store.py +++ b/src/oold/validation/meta_store.py @@ -40,6 +40,12 @@ UI_META_SCHEMA_FILE = "oold-ui-meta-schema.json" PATTERN_LINT_FILE = "oold-pattern-lint.schema.json" +#: The rule catalog, generated upstream from the specification prose. Unlike the three +#: meta-schemas this file is **optional**: it was introduced after 0.8.0, so a version predating +#: it must still load, simply reporting no rule ids. Findings then carry no citation rather than +#: the run failing, which is what lets an older meta version stay usable. +RULES_FILE = "oold-rules.json" + #: Selector for the unreleased upstream state. REMOTE = "remote" LATEST = "latest" @@ -123,11 +129,33 @@ class MetaBundle: origin: str documents: dict[str, Any] registry: Registry = field(repr=False) + #: The rule catalog for this version, empty when it predates one. + rules: list[dict[str, Any]] = field(default_factory=list, repr=False) @property def meta(self) -> dict[str, Any]: return self.documents[META_SCHEMA_FILE] + @property + def has_rules(self) -> bool: + return bool(self.rules) + + def rule(self, rule_id: str) -> dict[str, Any] | None: + """Look up one rule, or None when this version ships no catalog or lacks the id.""" + return next((r for r in self.rules if r["id"] == rule_id), None) + + def checkable_rules(self) -> list[dict[str, Any]]: + """Rules a validator can enforce by inspecting a document. + + `implementation` rules constrain a library rather than a document, and `advisory` ones + constrain nobody, so neither belongs in a validator's coverage figure. + """ + return [ + r + for r in self.rules + if r.get("checkable") and r.get("applies_to") == "document" and not r.get("deprecated") + ] + @property def ui_meta(self) -> dict[str, Any]: return self.documents[UI_META_SCHEMA_FILE] @@ -201,6 +229,21 @@ def retrieve(uri: str) -> Resource: return Registry(retrieve=retrieve).with_resources(pairs) +def _read_rules(directory: Path) -> list[dict[str, Any]]: + """Load the optional rule catalog from a version directory. + + A malformed catalog is treated as absent rather than fatal: rule ids are an annotation on + findings, so losing them must never stop a schema from being validated. + """ + path = directory / RULES_FILE + if not path.is_file(): + return [] + try: + return json.loads(path.read_text(encoding="utf-8")).get("rules", []) + except (OSError, json.JSONDecodeError): + return [] + + def _read_documents(directory: Path, label: str) -> dict[str, Any]: documents: dict[str, Any] = {} for name in meta_files(): @@ -226,6 +269,7 @@ def load_tracked(version: str) -> MetaBundle: origin=str(directory), documents=documents, registry=_build_registry(documents), + rules=_read_rules(directory), ) @@ -244,6 +288,12 @@ def fetch_remote(force: bool = False, timeout: float = 10.0) -> Path: for name in meta_files(): document = http_get_json(base + name, timeout=timeout) (target / name).write_text(json.dumps(document, indent=2), encoding="utf-8") + try: + catalog = http_get_json(base + RULES_FILE, timeout=timeout) + (target / RULES_FILE).write_text(json.dumps(catalog, indent=2), encoding="utf-8") + except SchemaResolutionError: + # Upstream has not published a catalog yet; the bundle is still complete without it. + (target / RULES_FILE).unlink(missing_ok=True) stamp.write_text( json.dumps( { @@ -286,6 +336,7 @@ def load_remote(offline: bool = False, timeout: float = 10.0) -> MetaBundle: origin=origin, documents=documents, registry=_build_registry(documents), + rules=_read_rules(target), ) diff --git a/src/oold/validation/pipeline.py b/src/oold/validation/pipeline.py index 84e7cde..ac02c30 100644 --- a/src/oold/validation/pipeline.py +++ b/src/oold/validation/pipeline.py @@ -37,6 +37,19 @@ SCHEMA_SUFFIX = ".schema.json" INSTANCE_SUFFIX = ".instance.json" +#: Which normative rule each check enforces, so a finding can cite the requirement rather than +#: only this package's internal check name. Deliberately partial: a check is mapped only where it +#: enforces one identifiable requirement. `schema.meta` for instance asserts the whole meta-schema +#: rather than any single statement, and `roundtrip.*` asserts a contract the specification states +#: across several bullets. Leaving those unmapped is what makes `coverage.rules` meaningful - an +#: invented mapping would report coverage the validator does not actually have. +CHECK_RULES: dict[str, str] = { + "lint.pattern": "OOLD-RT-001", # no coercion to a natively-JSON-encoded datatype + "lint.container": "OOLD-RT-002", # a strict array declares @container @set/@list + "lint.iri-format": "OOLD-EXT-006", # an IRI-valued property constrains its lexical form + "context.predicates": "OOLD-EXT-007", # a compact-IRI prefix is defined in the @context +} + #: Why a schema's JSON-LD checks were skipped. Shared so the message is identical everywhere. CYCLIC_NOTE = ( "reaches a cyclic scoped @context, which neither PyLD nor jsonld.js can process " @@ -85,8 +98,22 @@ def bounded(self, name: str) -> dict[str, Any]: return self._bounded[name] def add(self, check_id: str, *args: Any, **kwargs: Any) -> None: - if self.options.wants(check_id): - self.report.add(check_id, *args, **kwargs) + """Record a check, tagging it with the rule it enforces where one is known. + + The rule is attached here rather than at each call site so the mapping stays in one + place, and it is only attached when the meta version in use actually ships a catalog + containing that id - an older version reports findings with no citation. + """ + if not self.options.wants(check_id): + return + kwargs.setdefault("rule", self.rule_for(check_id)) + self.report.add(check_id, *args, **kwargs) + + def rule_for(self, check_id: str) -> str | None: + rule_id = CHECK_RULES.get(check_id) + if not rule_id: + return None + return rule_id if any(b.rule(rule_id) for b in self.bundles) else None # ---------------------------------------------------------------------------- setup @@ -610,4 +637,61 @@ def run_compliance(path: str | Path, options: Options | None = None) -> Report: f"all {len(bundle.declared_keywords())} keywords covered", meta_version=bundle.version, ) + _check_rule_coverage(run, directory.name, bundle) return run.report + + +def _check_rule_coverage(run: _Run, target: str, bundle: MetaBundle) -> None: + """Report which checkable rules this validator actually enforces. + + Both directions are reported as a warning rather than a failure, for different reasons. + + An unenforced checkable rule is the gap the catalog exists to expose; failing on it would + block every run on requirements nobody has implemented a check for yet. + + A mapped id the catalog does not contain looks like a dangling reference, but it is + ambiguous: it is equally what a *older* meta version looks like, one minted before that rule + existed. Failing would make validating against an older version break for no reason. A + genuine typo in `CHECK_RULES` is caught instead by the shape test and by the live parity test + that resolves every mapping against the current upstream catalog. + """ + if not bundle.has_rules: + run.add( + "coverage.rules", + target, + SKIP, + f"meta-schema {bundle.version} ships no rule catalog", + meta_version=bundle.version, + ) + return + + unknown = sorted({r for r in CHECK_RULES.values() if not bundle.rule(r)}) + checkable = bundle.checkable_rules() + missing = sorted(r["id"] for r in checkable if r["id"] not in set(CHECK_RULES.values())) + + notes: list[str] = [] + if missing: + notes.append(f"{len(missing)}/{len(checkable)} checkable rule(s) have no check: " + ", ".join(missing)) + if unknown: + notes.append( + f"{len(unknown)} mapped rule id(s) absent from this catalog (newer than " + f"{bundle.version}, or renamed): " + ", ".join(unknown) + ) + + if notes: + run.add( + "coverage.rules", + target, + WARN, + "; ".join(notes), + {"unenforced": missing, "unknown": unknown}, + bundle.version, + ) + else: + run.add( + "coverage.rules", + target, + OK, + f"all {len(checkable)} checkable rules are enforced", + meta_version=bundle.version, + ) diff --git a/src/oold/validation/report.py b/src/oold/validation/report.py index 11326c7..24d2d7d 100644 --- a/src/oold/validation/report.py +++ b/src/oold/validation/report.py @@ -40,6 +40,9 @@ class Check: message: str = "" detail: dict[str, Any] = field(default_factory=dict) meta_version: str | None = None + #: The normative rule this check enforces, e.g. ``OOLD-RT-002``. None when the check maps to + #: no single requirement, or when the meta version in use predates the rule catalog. + rule: str | None = None @property def failed(self) -> bool: @@ -55,6 +58,8 @@ def to_dict(self, verbosity: Verbosity = "summary") -> dict[str, Any]: payload["message"] = self.message if self.meta_version is not None: payload["meta_version"] = self.meta_version + if self.rule is not None: + payload["rule"] = self.rule if self.detail and verbosity == "full": payload["detail"] = self.detail return payload @@ -62,9 +67,10 @@ def to_dict(self, verbosity: Verbosity = "summary") -> dict[str, Any]: def line(self) -> str: """A single-line rendering, in the reference harness's column style.""" label = self.status.upper().ljust(4) + rule = f" {self.rule}" if self.rule else "" version = f" [{self.meta_version}]" if self.meta_version else "" message = f": {self.message}" if self.message else "" - return f"{label} {self.id:<24} {self.target}{version}{message}" + return f"{label}{rule} {self.id:<24} {self.target}{version}{message}" @dataclass @@ -87,6 +93,7 @@ def add( message: str = "", detail: dict[str, Any] | None = None, meta_version: str | None = None, + rule: str | None = None, ) -> Check: check = Check( id=id, @@ -95,6 +102,7 @@ def add( message=message, detail=detail or {}, meta_version=meta_version, + rule=rule, ) self.checks.append(check) return check diff --git a/tests/test_validation/test_mcp_server.py b/tests/test_validation/test_mcp_server.py index 9ced4e5..88c3d65 100644 --- a/tests/test_validation/test_mcp_server.py +++ b/tests/test_validation/test_mcp_server.py @@ -23,6 +23,7 @@ def list_tools(): "generate_oold_instance", "check_context_mapping", "list_meta_versions", + "list_oold_rules", } diff --git a/tests/test_validation/test_meta_store.py b/tests/test_validation/test_meta_store.py index 0e2fce7..ebcbe6d 100644 --- a/tests/test_validation/test_meta_store.py +++ b/tests/test_validation/test_meta_store.py @@ -18,6 +18,7 @@ resolve_selection, tracked_versions, ) +from oold.validation.resolve import SchemaResolutionError def test_at_least_one_version_is_tracked(): @@ -172,7 +173,12 @@ def test_remote_fetch_writes_only_to_the_cache(isolated_cache, monkeypatch, tmp_ before = {path: path.read_bytes() for path in meta_store.meta_dir().rglob("*.json")} def fake_get(uri, timeout=10.0): - return documents[uri.rsplit("/", 1)[-1]] + # Upstream serves the three meta-schemas but not (yet) the optional rule catalog; + # a missing file surfaces as a resolution error, the same as a real 404. + name = uri.rsplit("/", 1)[-1] + if name not in documents: + raise SchemaResolutionError(f"could not fetch {uri}: 404") + return documents[name] monkeypatch.setattr(meta_store, "http_get_json", fake_get) bundle = meta_store.load_remote(offline=False) @@ -189,7 +195,14 @@ def test_cached_remote_is_usable_offline(isolated_cache, monkeypatch): name: json.loads((meta_store.meta_dir() / latest_version() / name).read_text("utf-8")) for name in meta_store.meta_files() } - monkeypatch.setattr(meta_store, "http_get_json", lambda uri, timeout=10.0: documents[uri.rsplit("/", 1)[-1]]) + + def fake_get(uri, timeout=10.0): + name = uri.rsplit("/", 1)[-1] + if name not in documents: + raise SchemaResolutionError(f"could not fetch {uri}: 404") + return documents[name] + + monkeypatch.setattr(meta_store, "http_get_json", fake_get) meta_store.fetch_remote() # Now offline: the cached copy must satisfy the request without any fetch. monkeypatch.setattr( diff --git a/tests/test_validation/test_parity_live.py b/tests/test_validation/test_parity_live.py index 97ad582..892448e 100644 --- a/tests/test_validation/test_parity_live.py +++ b/tests/test_validation/test_parity_live.py @@ -117,3 +117,24 @@ def test_the_reference_cannot_resolve_a_context_leaving_the_directory(upstream, "the reference harness now resolves a context reference that leaves the directory; " "update the divergence note in docs/how-to/validation.md" ) + + +def test_every_mapped_rule_resolves_against_the_upstream_catalog(upstream): + """The authoritative guard against a typo in CHECK_RULES. + + Per-version coverage only warns about an unknown id, because a catalog predating a mapped + rule is indistinguishable from a mistake. Against the *current* upstream catalog there is no + such ambiguity: every id this package cites must exist, or reports would quote a code that + resolves to nothing. + """ + import json + + from oold.validation.pipeline import CHECK_RULES + + catalog = upstream / "meta" / "oold-rules.json" + if not catalog.is_file(): + pytest.skip("upstream has not published a rule catalog yet") + + known = {r["id"] for r in json.loads(catalog.read_text(encoding="utf-8"))["rules"]} + unknown = {check: rule for check, rule in CHECK_RULES.items() if rule not in known} + assert not unknown, f"CHECK_RULES cites ids absent from the upstream catalog: {unknown}" diff --git a/tests/test_validation/test_rules.py b/tests/test_validation/test_rules.py new file mode 100644 index 0000000..6fb7fc2 --- /dev/null +++ b/tests/test_validation/test_rules.py @@ -0,0 +1,260 @@ +"""Consumption of the upstream rule catalog. + +The catalog was introduced in oold-schema after 0.8.0, so no tracked meta version ships one yet. +These tests build a synthetic version folder instead of depending on a warm remote cache, which +keeps them offline, deterministic, and honest about the shape they expect. +""" + +from __future__ import annotations + +import json + +import pytest +from click.testing import CliRunner + +from oold.validation import meta_store +from oold.validation.cli import main +from oold.validation.meta_store import RULES_FILE, load_tracked +from oold.validation.pipeline import CHECK_RULES + +SAMPLE_RULES = { + "spec_version": "0.9.0", + "rules": [ + { + "id": "OOLD-RT-002", + "area": "RT", + "level": "MUST", + "applies_to": "document", + "section": "round-trip", + "summary": "A strictly array-typed property must declare @container @set or @list.", + "text": "Because the reconstruction MUST re-validate, a property that is strictly an array MUST declare @container.", + "checkable": True, + "since": "0.8.0", + "deprecated": False, + }, + { + "id": "OOLD-INS-003", + "area": "INS", + "level": "MUST", + "applies_to": "implementation", + "section": "identity", + "summary": "An exported identifiable entity must carry an IRI.", + "text": "When it exports an identifiable entity it MUST assign an @id.", + "checkable": False, + "since": "0.8.0", + "deprecated": False, + }, + { + "id": "OOLD-VER-001", + "area": "VER", + "level": "MUST", + "applies_to": "document", + "section": "identification", + "summary": "A schema must have a $id.", + "text": "OO-LD schemas MUST have a $id.", + "checkable": True, + "since": "0.8.0", + "deprecated": False, + }, + { + "id": "OOLD-RT-009", + "area": "RT", + "level": "MUST", + "applies_to": "document", + "section": "round-trip", + "summary": "A retired rule.", + "text": "This rule MUST no longer be applied.", + "checkable": True, + "since": "0.8.0", + "deprecated": True, + "superseded_by": ["OOLD-RT-002"], + }, + ], +} + + +@pytest.fixture +def catalog_version(tmp_path, monkeypatch): + """A tracked meta version that additionally ships a rule catalog.""" + source = meta_store.meta_dir() / meta_store.latest_version() + target = tmp_path / "meta" / "9.9.9" + target.mkdir(parents=True) + for name in meta_store.meta_files(): + (target / name).write_bytes((source / name).read_bytes()) + (target / RULES_FILE).write_text(json.dumps(SAMPLE_RULES), encoding="utf-8") + (tmp_path / "meta" / "index.json").write_text( + json.dumps({"files": meta_store.meta_files(), "versions": {"9.9.9": {}}, "remote": {}}), + encoding="utf-8", + ) + monkeypatch.setattr(meta_store, "meta_dir", lambda: tmp_path / "meta") + meta_store.load_index.cache_clear() + yield "9.9.9" + meta_store.load_index.cache_clear() + + +# ------------------------------------------------------------------ loading + + +def test_a_version_without_a_catalog_still_loads(): + """The catalog postdates 0.8.0, so every tracked version must work without one.""" + for version in meta_store.tracked_versions(): + bundle = load_tracked(version) + assert bundle.has_rules is False + assert bundle.rules == [] + assert bundle.rule("OOLD-RT-002") is None + + +def test_catalog_is_loaded_when_present(catalog_version): + bundle = load_tracked(catalog_version) + assert bundle.has_rules + assert bundle.rule("OOLD-RT-002")["level"] == "MUST" + assert bundle.rule("OOLD-NOPE-001") is None + + +def test_a_malformed_catalog_is_treated_as_absent(catalog_version, tmp_path): + """Rule ids annotate findings; losing them must never stop a schema being validated.""" + (tmp_path / "meta" / catalog_version / RULES_FILE).write_text("{ not json", encoding="utf-8") + bundle = load_tracked(catalog_version) + assert bundle.has_rules is False + assert bundle.meta_validator().is_valid({"type": "object"}) + + +def test_checkable_rules_exclude_implementation_advisory_and_deprecated(catalog_version): + ids = [r["id"] for r in load_tracked(catalog_version).checkable_rules()] + assert ids == ["OOLD-RT-002", "OOLD-VER-001"] + assert "OOLD-INS-003" not in ids, "an implementation rule is not checkable by a validator" + assert "OOLD-RT-009" not in ids, "a deprecated rule is not counted" + + +# ------------------------------------------------------------------ mapping + + +def test_every_mapped_rule_id_is_well_formed(): + """A typo here would make findings cite a code that resolves to nothing.""" + for check_id, rule_id in CHECK_RULES.items(): + assert rule_id.startswith("OOLD-"), f"{check_id} maps to {rule_id!r}" + assert len(rule_id.split("-")) == 3 + + +def test_findings_carry_no_rule_when_the_version_has_no_catalog(data_dir): + from oold.validation import Options, validate_schema + + report = validate_schema(data_dir / "Thing.schema.json", Options(meta=("latest",), offline=True)) + assert report.passed + assert all(c.rule is None for c in report.checks) + + +def test_findings_cite_a_rule_when_the_catalog_has_it(catalog_version, broken_dir): + from oold.validation import Options, validate_schema + + report = validate_schema( + broken_dir / "array_without_container.schema.json", + Options(meta=(catalog_version,), offline=True), + ) + container = next(c for c in report.checks if c.id == "lint.container") + assert container.rule == "OOLD-RT-002" + # lint.pattern maps to a rule this sample catalog does not contain, so it stays uncited + # rather than quoting a dangling code. + assert next(c for c in report.checks if c.id == "lint.pattern").rule is None + + +def test_rule_appears_in_the_serialised_report(catalog_version, broken_dir): + from oold.validation import Options, validate_schema + + report = validate_schema( + broken_dir / "array_without_container.schema.json", + Options(meta=(catalog_version,), offline=True), + ) + payload = report.to_dict("summary") + cited = [c for c in payload["checks"] if c.get("rule")] + assert any(c["rule"] == "OOLD-RT-002" for c in cited) + + +# ------------------------------------------------------------------ coverage + + +def test_coverage_is_skipped_when_the_version_ships_no_catalog(compliance_dir): + from oold.validation import Options, run_compliance + + report = run_compliance(compliance_dir, Options(meta=("latest",), offline=True)) + coverage = [c for c in report.checks if c.id == "coverage.rules"] + assert coverage and coverage[0].status == "skip" + + +def test_unenforced_rules_are_a_warning_not_a_failure(catalog_version, compliance_dir): + """The gap is what the catalog exists to show; failing on it would block every run.""" + from oold.validation import Options, run_compliance + + report = run_compliance(compliance_dir, Options(meta=(catalog_version,), offline=True)) + coverage = next(c for c in report.checks if c.id == "coverage.rules") + assert coverage.status == "warn" + assert "OOLD-VER-001" in coverage.detail["unenforced"] + + +def test_a_mapped_rule_missing_from_an_older_catalog_is_not_a_failure(catalog_version, compliance_dir): + """A catalog predating a mapped rule is indistinguishable from a typo, so it only warns. + + The sample catalog omits OOLD-RT-001, which CHECK_RULES maps to. Failing there would break + validation against any meta version older than the newest rule this package enforces. + """ + from oold.validation import Options, run_compliance + + report = run_compliance(compliance_dir, Options(meta=(catalog_version,), offline=True)) + coverage = next(c for c in report.checks if c.id == "coverage.rules") + assert coverage.status == "warn" + assert "OOLD-RT-001" in coverage.detail["unknown"] + assert report.passed, "an older catalog must not fail the run" + + +# ------------------------------------------------------------------ CLI + + +@pytest.fixture +def run(): + runner = CliRunner() + return lambda *args: runner.invoke(main, list(args), catch_exceptions=False) + + +def test_rules_list(run, catalog_version): + result = run("rules", "list", "--meta", catalog_version) + assert result.exit_code == 0 + assert "OOLD-RT-002" in result.output + assert "lint.container" in result.output, "the enforcing check is shown" + + +def test_rules_list_filters_by_area(run, catalog_version): + out = run("rules", "list", "--meta", catalog_version, "--area", "VER").output + assert "OOLD-VER-001" in out + assert "OOLD-RT-002" not in out + + +def test_rules_list_unchecked_shows_the_gap(run, catalog_version): + out = run("rules", "list", "--meta", catalog_version, "--unchecked").output + assert "OOLD-VER-001" in out, "no check enforces it" + assert "OOLD-RT-002" not in out, "lint.container enforces it" + + +def test_rules_explain(run, catalog_version): + out = run("rules", "explain", "OOLD-RT-002", "--meta", catalog_version).output + assert "MUST" in out + assert "enforced by lint.container" in out + assert "#rule-OOLD-RT-002" in out + assert "MUST re-validate" in out, "the specification text is shown" + + +def test_rules_explain_is_case_insensitive(run, catalog_version): + assert run("rules", "explain", "oold-rt-002", "--meta", catalog_version).exit_code == 0 + + +def test_rules_explain_unknown_id_suggests_listing(run, catalog_version): + result = run("rules", "explain", "OOLD-NOPE-001", "--meta", catalog_version) + assert result.exit_code != 0 + assert "oold rules list" in result.output + + +def test_rules_command_explains_a_missing_catalog(run): + """The common case today: no released version ships one yet.""" + result = run("rules", "list", "--meta", meta_store.latest_version(), "--offline") + assert result.exit_code != 0 + assert "no rule catalog" in result.output + assert "remote" in result.output, "the message points at where a catalog can be found" From 3030aa879104dad3e52693f152505f58e240017f Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 31 Jul 2026 19:35:55 +0200 Subject: [PATCH 04/29] feat(validation): enforce ten more normative rules - MUST-level: rule.id (OOLD-VER-001), rule.id-fragment (OOLD-CMP-005), rule.range-ref (OOLD-EXT-005), rule.instance-type (OOLD-INS-002), rule.free-text-iri (OOLD-INS-009), rule.closed-object (OOLD-INS-005) - SHOULD-level: rule.version (OOLD-VER-002), rule.id-alias (OOLD-INS-007), rule.dialect (OOLD-EXT-002), rule.processing-mode (OOLD-EXT-001) - Checks are declared in a registry and export their own check-id-to-rule mapping - Checks judge the resolved context rather than the schema's literal @context - Takes coverage.rules from 21 of 25 checkable rules unchecked to 11 --- docs/how-to/validation.md | 22 ++ src/oold/validation/pipeline.py | 22 ++ src/oold/validation/rule_checks.py | 299 ++++++++++++++++++++++ tests/test_validation/test_rule_checks.py | 220 ++++++++++++++++ tests/test_validation/test_rules.py | 22 +- 5 files changed, 582 insertions(+), 3 deletions(-) create mode 100644 src/oold/validation/rule_checks.py create mode 100644 tests/test_validation/test_rule_checks.py diff --git a/docs/how-to/validation.md b/docs/how-to/validation.md index 8b71abc..806955a 100644 --- a/docs/how-to/validation.md +++ b/docs/how-to/validation.md @@ -142,6 +142,28 @@ which resolves every mapping against the current upstream catalog. | `compliance.*`, `coverage.vocab` | Fixture suites with exact expected outcomes, plus a cross-check that every meta-schema keyword has a test. | | `coverage.rules` | *(warning)* Which checkable rules no check enforces yet. | +### Single-rule checks + +Alongside the broad checks above, the `rule.*` family each enforce exactly one normative +statement and cite it. A MUST fails the run, a SHOULD warns. + +| Check | Rule | Asserts | +|---|---|---| +| `rule.id` | `OOLD-VER-001` | The schema declares a `$id`. | +| `rule.id-fragment` | `OOLD-CMP-005` | That `$id` carries no non-empty fragment. | +| `rule.range-ref` | `OOLD-EXT-005` | References inside `x-oold-range` use `x-oold-ref`, never `$ref`. | +| `rule.instance-type` | `OOLD-INS-002` | A pinned `type` agrees with `x-oold-instance-rdf-type`. | +| `rule.free-text-iri` | `OOLD-INS-009` | A property whose range mixes free text with references is not coerced with `@type: "@id"`. | +| `rule.closed-object` | `OOLD-INS-005` | A schema closing its objects still permits `$schema` and `@context`. | +| `rule.version` | `OOLD-VER-002` | *(warning)* The schema states `x-oold-version`. | +| `rule.id-alias` | `OOLD-INS-007` | *(warning)* `@id` is reachable through an alias such as `id`. | +| `rule.dialect` | `OOLD-EXT-002` | *(warning)* `$schema` names the OO-LD dialect meta-schema. | +| `rule.processing-mode` | `OOLD-EXT-001` | *(warning)* The context declares `"@version": 1.1` as a JSON number. | + +These judge the **resolved** context, not the schema's literal `@context`. OO-LD contexts inherit, +so a subclass gets `@version` and the `id` alias from its parent; checking the literal form would +report violations that are not real. + ### Cyclic scoped contexts When a schema's `@context` references form a cycle - a type whose scoped context embeds itself - diff --git a/src/oold/validation/pipeline.py b/src/oold/validation/pipeline.py index ac02c30..7764f01 100644 --- a/src/oold/validation/pipeline.py +++ b/src/oold/validation/pipeline.py @@ -32,6 +32,7 @@ from .report import FAIL, OK, SKIP, WARN, Report from .resolve import Resolver, SchemaResolutionError, bound_schema from .roundtrip import roundtrip +from .rule_checks import RULE_CHECK_MAP, ContextView, run_rule_checks from .schema_checks import check_usable_as_validator, validate_against_meta SCHEMA_SUFFIX = ".schema.json" @@ -48,6 +49,9 @@ "lint.container": "OOLD-RT-002", # a strict array declares @container @set/@list "lint.iri-format": "OOLD-EXT-006", # an IRI-valued property constrains its lexical form "context.predicates": "OOLD-EXT-007", # a compact-IRI prefix is defined in the @context + # Checks that each enforce exactly one rule live in rule_checks.py and declare their own + # mapping, so adding a rule check cannot forget to register it here. + **RULE_CHECK_MAP, } #: Why a schema's JSON-LD checks were skipped. Shared so the message is identical everywhere. @@ -381,6 +385,8 @@ def _check_predicates(run: _Run, name: str, raw, schema, sample) -> None: run.add("context.predicates", name, SKIP, "schema declares no @context") return + _run_rule_checks(run, name, raw, ContextView(terms=context.terms(), entries=list(context.context))) + id_key, type_key = find_alias_keys(context.terms()) declared = set(collect_composed_properties(schema)) | {id_key, type_key} result = check_predicates(sample, context.as_jsonld(), declared_properties=declared) @@ -416,6 +422,22 @@ def _check_predicates(run: _Run, name: str, raw, schema, sample) -> None: # ---------------------------------------------------------------------------- instances +def _run_rule_checks(run: _Run, name: str, raw: dict[str, Any], context: ContextView) -> None: + """Report the narrow, single-rule checks for one schema. + + A passing check is still recorded, so `--verbose` shows which requirements were verified and + the counts line up with what `oold rules list` claims is enforced. + """ + for finding in run_rule_checks(raw, context): + run.add( + finding.check_id, + name, + finding.status, + finding.message, + finding.detail, + ) + + def _check_instance_file(run: _Run, name: str, instance: Any = None) -> None: """Validate and round-trip one instance. diff --git a/src/oold/validation/rule_checks.py b/src/oold/validation/rule_checks.py new file mode 100644 index 0000000..a4d259f --- /dev/null +++ b/src/oold/validation/rule_checks.py @@ -0,0 +1,299 @@ +"""Checks implementing individual normative rules from the specification catalog. + +The general-workflow checks ported from the reference harness each assert a broad property - +"the schema is well formed", "the instance round-trips". This module holds the narrower checks, +each enforcing exactly one statement in the specification and citing its rule id. + +Keeping them together, declared rather than hand-wired, means the mapping from check to rule is +visible in one place and `coverage.rules` can be trusted: a rule appears as enforced only when a +check here actually implements it. + +Every check is written to avoid false positives in preference to catching every violation. A +validator that cries wolf on valid schemas gets switched off, and an unenforced rule is already +reported honestly by `coverage.rules`. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from .frame import collect_composed_properties, instance_rdf_types +from .report import FAIL, OK, WARN, Status + +#: A `$schema` naming the OO-LD dialect, on either canonical domain. The domain moved from +#: oo-ld.github.io to oo-ld.org, and released copies stamp a version in place of `latest`, so the +#: check matches the file name rather than any single URL. +_OOLD_META = re.compile(r"oold-meta-schema\.json$") + + +@dataclass +class RuleFinding: + """One rule's outcome for one schema.""" + + check_id: str + rule: str + status: Status + message: str = "" + detail: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ContextView: + """A schema's *resolved* context, as the rules need to see it. + + Rules are about what an instance actually experiences, and OO-LD contexts inherit: a schema + whose `@context` is `["Thing.schema.json", {...}]` gets `@version` and the `id` alias from + Thing. Judging such a schema on its own literal `@context` reports violations that are not + real, so every rule here is given the resolved form. + """ + + terms: dict[str, Any] = field(default_factory=dict) + entries: list[Any] = field(default_factory=list) + + def keyword(self, name: str) -> Any: + """The effective value of a context keyword such as ``@version``, or None.""" + for entry in self.entries: + if isinstance(entry, dict) and name in entry: + return entry[name] + return None + + +@dataclass +class RuleCheck: + """A check that enforces exactly one rule.""" + + check_id: str + rule: str + #: FAIL for a MUST, WARN for a SHOULD. The specification's level, not a taste judgement. + level: Status + describe: str + run: Callable[[dict[str, Any], ContextView], list[str]] + + def __call__(self, schema: dict[str, Any], context: ContextView) -> RuleFinding: + problems = self.run(schema, context) + if not problems: + return RuleFinding(self.check_id, self.rule, OK) + return RuleFinding(self.check_id, self.rule, self.level, "; ".join(problems), {"problems": problems}) + + +# ---------------------------------------------------------------------------- individual rules + + +def _missing_id(schema: dict[str, Any], context: ContextView) -> list[str]: + if not schema.get("$id"): + return ["schema declares no $id, so it has no global identifier"] + return [] + + +def _id_has_fragment(schema: dict[str, Any], context: ContextView) -> list[str]: + identifier = schema.get("$id") + if not isinstance(identifier, str) or "#" not in identifier: + return [] + fragment = identifier.split("#", 1)[1] + # An empty fragment is explicitly allowed; only a non-empty one is forbidden. + return [f"$id carries a non-empty fragment: {identifier!r}"] if fragment else [] + + +def _range_uses_ref(schema: dict[str, Any], context: ContextView) -> list[str]: + """`x-oold-range` must reference with `x-oold-ref`, never `$ref`. + + A plain `$ref` inside a range would be eagerly dereferenced by a generic bundler, which for a + cyclic schema graph is exactly the unbounded recursion OO-LD avoids by keeping range + references lazy. + """ + found: list[str] = [] + + def walk(node: Any, path: str) -> None: + if isinstance(node, list): + for index, item in enumerate(node): + walk(item, f"{path}[{index}]") + return + if not isinstance(node, dict): + return + if "$ref" in node: + found.append(f"{path} uses $ref; x-oold-range must use x-oold-ref") + for key, value in node.items(): + if key != "$ref": + walk(value, f"{path}/{key}") + + for name, prop in collect_composed_properties(schema).items(): + if isinstance(prop, dict) and isinstance(prop.get("x-oold-range"), (dict, list)): + walk(prop["x-oold-range"], f"properties/{name}/x-oold-range") + return found + + +def _inline_type_disagrees(schema: dict[str, Any], context: ContextView) -> list[str]: + """An inline `type` must agree with the schema's declared `x-oold-instance-rdf-type`. + + Only a *pinned* type is checked - `const`, `default`, or a single-entry `enum`. An open + `type: string` says nothing about what instances will carry, so it cannot disagree. + """ + declared = instance_rdf_types(schema) + if not declared: + return [] + prop = collect_composed_properties(schema).get("type") + if not isinstance(prop, dict): + return [] + + pinned: list[Any] = [] + for key in ("const", "default"): + if key in prop: + pinned = prop[key] if isinstance(prop[key], list) else [prop[key]] + break + else: + enum = prop.get("enum") + if isinstance(enum, list) and len(enum) == 1: + pinned = enum if not isinstance(enum[0], list) else enum[0] + + if not pinned: + return [] + stray = [t for t in pinned if t not in declared] + if stray: + return [f"the type property pins {stray!r}, which is absent from x-oold-instance-rdf-type {declared!r}"] + return [] + + +def _free_text_range_coerced_to_iri(schema: dict[str, Any], context: ContextView) -> list[str]: + """A property whose range includes free text must not use ``@type: "@id"``. + + ``@type: "@id"`` coerces *every* string to an IRI, so free text becomes an often invalid IRI + and is dropped. The property is only flagged when its own schema clearly admits a bare string + alongside a non-string form, which is what "the range includes free text" means. + """ + problems = [] + for name, prop in collect_composed_properties(schema).items(): + definition = context.terms.get(name) + if not isinstance(definition, dict) or definition.get("@type") != "@id": + continue + if _admits_free_text(prop): + problems.append( + f"{name!r} admits a bare string but its term coerces every value with " + '@type: "@id", so free text becomes an invalid IRI' + ) + return problems + + +def _admits_free_text(prop: Any) -> bool: + """True when a property permits a plain string *and* some other shape. + + A reference typed only as a string is the ordinary bare-IRI form and is correct; the + violation is a property that mixes free text with references or embedded objects. + """ + if not isinstance(prop, dict): + return False + for keyword in ("anyOf", "oneOf"): + branches = prop.get(keyword) + if not isinstance(branches, list) or len(branches) < 2: + continue + kinds = {b.get("type") for b in branches if isinstance(b, dict)} + # A string branch with no `format` and no `x-oold-range` is free text rather than an IRI. + text = any( + isinstance(b, dict) and b.get("type") == "string" and not b.get("format") and "x-oold-range" not in b + for b in branches + ) + if text and kinds - {"string"}: + return True + return False + + +def _closed_object_rejects_metadata(schema: dict[str, Any], context: ContextView) -> list[str]: + """A schema closing its objects must still permit `$schema` and `@context`. + + An instance carries both as ordinary members, so a schema with + ``additionalProperties: false`` that does not declare them rejects its own conforming + instances. + """ + closed = schema.get("additionalProperties") is False or schema.get("unevaluatedProperties") is False + if not closed: + return [] + declared = set(collect_composed_properties(schema)) + missing = [key for key in ("$schema", "@context") if key not in declared] + if missing: + return [ + "the schema closes its objects but does not declare " + + ", ".join(missing) + + ", so a conforming instance carrying them would be rejected" + ] + return [] + + +def _missing_version(schema: dict[str, Any], context: ContextView) -> list[str]: + if not schema.get("x-oold-version"): + return ["schema declares no x-oold-version"] + return [] + + +def _id_not_aliased(schema: dict[str, Any], context: ContextView) -> list[str]: + """`@id` should be reachable through a variable-name-friendly alias.""" + if not context.terms: + return [] + aliases = [t for t, d in context.terms.items() if (d.get("@id") if isinstance(d, dict) else d) == "@id"] + if not aliases: + return ["no @context term aliases @id, so instances must use the @id key directly"] + return [] + + +def _dialect_not_declared(schema: dict[str, Any], context: ContextView) -> list[str]: + declared = schema.get("$schema") + if not isinstance(declared, str) or not _OOLD_META.search(declared): + return [f"$schema is {declared!r}, not the OO-LD dialect meta-schema"] + return [] + + +def _processing_mode_not_declared(schema: dict[str, Any], context: ContextView) -> list[str]: + """`@version` must be the JSON number 1.1, not the string "1.1".""" + value = context.keyword("@version") + if value is None: + return ['the resolved @context declares no "@version": 1.1'] + if value == 1.1 and not isinstance(value, str): + return [] + return [f"@version is {value!r}; it must be the JSON number 1.1, not a string"] + + +# ---------------------------------------------------------------------------- registry + +#: Every rule this package enforces beyond the ported general-workflow checks. Order is the order +#: findings are reported in. +RULE_CHECKS: list[RuleCheck] = [ + RuleCheck("rule.id", "OOLD-VER-001", FAIL, "a schema has a $id", _missing_id), + RuleCheck("rule.id-fragment", "OOLD-CMP-005", FAIL, "a $id has no non-empty fragment", _id_has_fragment), + RuleCheck("rule.range-ref", "OOLD-EXT-005", FAIL, "x-oold-range references use x-oold-ref", _range_uses_ref), + RuleCheck( + "rule.instance-type", + "OOLD-INS-002", + FAIL, + "a pinned type agrees with x-oold-instance-rdf-type", + _inline_type_disagrees, + ), + RuleCheck( + "rule.free-text-iri", + "OOLD-INS-009", + FAIL, + "a free-text range is not coerced to @id", + _free_text_range_coerced_to_iri, + ), + RuleCheck( + "rule.closed-object", + "OOLD-INS-005", + FAIL, + "a closed object still permits $schema and @context", + _closed_object_rejects_metadata, + ), + RuleCheck("rule.version", "OOLD-VER-002", WARN, "a schema states x-oold-version", _missing_version), + RuleCheck("rule.id-alias", "OOLD-INS-007", WARN, "@id is exposed through an alias", _id_not_aliased), + RuleCheck("rule.dialect", "OOLD-EXT-002", WARN, "a schema declares the OO-LD dialect", _dialect_not_declared), + RuleCheck( + "rule.processing-mode", "OOLD-EXT-001", WARN, "a context declares @version 1.1", _processing_mode_not_declared + ), +] + +#: check id -> rule id, for the pipeline's citation mapping and coverage figure. +RULE_CHECK_MAP: dict[str, str] = {c.check_id: c.rule for c in RULE_CHECKS} + + +def run_rule_checks(schema: dict[str, Any], context: ContextView) -> list[RuleFinding]: + """Apply every rule check to one schema, against its resolved context.""" + return [check(schema, context) for check in RULE_CHECKS] diff --git a/tests/test_validation/test_rule_checks.py b/tests/test_validation/test_rule_checks.py new file mode 100644 index 0000000..5d7643b --- /dev/null +++ b/tests/test_validation/test_rule_checks.py @@ -0,0 +1,220 @@ +"""The narrow checks that each enforce one normative rule. + +Every check gets both a violating and a conforming case. A check that only ever fires is as +useless as one that never does, and these run over real OO-LD schemas where a false positive +would be expensive. +""" + +from __future__ import annotations + +import pytest + +from oold.validation.rule_checks import RULE_CHECKS, ContextView, run_rule_checks + + +def outcome(check_id: str, schema: dict, context: ContextView | None = None) -> str: + findings = {f.check_id: f for f in run_rule_checks(schema, context or ContextView())} + return findings[check_id].status + + +def message(check_id: str, schema: dict, context: ContextView | None = None) -> str: + findings = {f.check_id: f for f in run_rule_checks(schema, context or ContextView())} + return findings[check_id].message + + +# ------------------------------------------------------------------ registry + + +def test_every_check_declares_a_rule_and_a_level(): + for check in RULE_CHECKS: + assert check.rule.startswith("OOLD-"), check.check_id + assert check.check_id.startswith("rule."), check.check_id + assert check.level in ("fail", "warn") + + +def test_a_must_fails_and_a_should_only_warns(): + """The level comes from the specification, not from taste.""" + levels = {c.rule: c.level for c in RULE_CHECKS} + assert levels["OOLD-VER-001"] == "fail", "a MUST" + assert levels["OOLD-VER-002"] == "warn", "a SHOULD" + + +# ------------------------------------------------------------------ OOLD-VER-001 / CMP-005 + + +def test_missing_id_is_reported(): + assert outcome("rule.id", {"type": "object"}) == "fail" + assert outcome("rule.id", {"$id": "Thing.schema.json"}) == "ok" + + +def test_id_fragment(): + assert outcome("rule.id-fragment", {"$id": "https://example.org/T.json#/$defs/X"}) == "fail" + assert outcome("rule.id-fragment", {"$id": "https://example.org/T.json"}) == "ok" + # An *empty* fragment is explicitly permitted by JSON Schema. + assert outcome("rule.id-fragment", {"$id": "https://example.org/T.json#"}) == "ok" + + +# ------------------------------------------------------------------ OOLD-EXT-005 + + +def test_range_must_use_x_oold_ref_not_ref(): + bad = {"properties": {"affiliation": {"x-oold-range": {"allOf": [{"$ref": "Organization.schema.json"}]}}}} + good = {"properties": {"affiliation": {"x-oold-range": {"allOf": [{"x-oold-ref": "Organization.schema.json"}]}}}} + assert outcome("rule.range-ref", bad) == "fail" + assert "x-oold-ref" in message("rule.range-ref", bad) + assert outcome("rule.range-ref", good) == "ok" + + +def test_a_plain_string_range_is_not_flagged(): + assert outcome("rule.range-ref", {"properties": {"a": {"x-oold-range": "schema:Person"}}}) == "ok" + + +def test_a_ref_outside_a_range_is_not_flagged(): + """Ordinary composition uses $ref and must stay untouched.""" + schema = {"allOf": [{"$ref": "Thing.schema.json"}], "properties": {"a": {"$ref": "X.json"}}} + assert outcome("rule.range-ref", schema) == "ok" + + +# ------------------------------------------------------------------ OOLD-INS-002 + + +def test_pinned_type_must_agree_with_the_declared_rdf_type(): + base = {"x-oold-instance-rdf-type": ["schema:Person"]} + assert outcome("rule.instance-type", {**base, "properties": {"type": {"const": "schema:Place"}}}) == "fail" + assert outcome("rule.instance-type", {**base, "properties": {"type": {"const": "schema:Person"}}}) == "ok" + assert outcome("rule.instance-type", {**base, "properties": {"type": {"default": ["schema:Person"]}}}) == "ok" + + +def test_an_unpinned_type_cannot_disagree(): + """`type: string` says nothing about what an instance will carry.""" + schema = {"x-oold-instance-rdf-type": ["schema:Person"], "properties": {"type": {"type": "string"}}} + assert outcome("rule.instance-type", schema) == "ok" + + +def test_no_declared_rdf_type_means_nothing_to_disagree_with(): + assert outcome("rule.instance-type", {"properties": {"type": {"const": "schema:Place"}}}) == "ok" + + +def test_inherited_rdf_type_is_used(): + """After dereferencing, a subclass carries its parent's declaration under allOf.""" + schema = { + "allOf": [{"x-oold-instance-rdf-type": ["schema:Person"]}], + "properties": {"type": {"const": "schema:Place"}}, + } + assert outcome("rule.instance-type", schema) == "fail" + + +# ------------------------------------------------------------------ OOLD-INS-009 + + +def test_free_text_range_must_not_be_coerced_to_iri(): + context = ContextView(terms={"address": {"@id": "schema:address", "@type": "@id"}}) + mixed = {"properties": {"address": {"anyOf": [{"type": "string"}, {"type": "object", "properties": {"id": {}}}]}}} + assert outcome("rule.free-text-iri", mixed, context) == "fail" + + +def test_a_pure_reference_property_is_not_flagged(): + """A string-only property under @type @id is the ordinary bare-IRI form.""" + context = ContextView(terms={"knows": {"@id": "schema:knows", "@type": "@id"}}) + schema = {"properties": {"knows": {"type": "string", "format": "iri-reference"}}} + assert outcome("rule.free-text-iri", schema, context) == "ok" + + +def test_a_mixed_range_without_id_coercion_is_fine(): + """The value-form pattern: a plain term, so the value shape disambiguates.""" + context = ContextView(terms={"address": {"@id": "schema:address"}}) + schema = {"properties": {"address": {"anyOf": [{"type": "string"}, {"type": "object"}]}}} + assert outcome("rule.free-text-iri", schema, context) == "ok" + + +def test_an_iri_branch_is_not_mistaken_for_free_text(): + """A string branch carrying a format or a range is a reference, not free text.""" + context = ContextView(terms={"a": {"@id": "ex:a", "@type": "@id"}}) + schema = {"properties": {"a": {"anyOf": [{"type": "string", "format": "iri-reference"}, {"type": "object"}]}}} + assert outcome("rule.free-text-iri", schema, context) == "ok" + + +# ------------------------------------------------------------------ OOLD-INS-005 + + +def test_a_closed_object_must_permit_schema_and_context(): + closed = {"additionalProperties": False, "properties": {"name": {}}} + assert outcome("rule.closed-object", closed) == "fail" + permitted = { + "additionalProperties": False, + "properties": {"name": {}, "$schema": {}, "@context": {}}, + } + assert outcome("rule.closed-object", permitted) == "ok" + + +def test_an_open_object_is_not_flagged(): + assert outcome("rule.closed-object", {"properties": {"name": {}}}) == "ok" + + +def test_unevaluated_properties_false_is_treated_as_closed(): + assert outcome("rule.closed-object", {"unevaluatedProperties": False, "properties": {}}) == "fail" + + +# ------------------------------------------------------------------ SHOULD-level + + +def test_version_and_alias_and_dialect_warn_rather_than_fail(): + assert outcome("rule.version", {}) == "warn" + assert outcome("rule.version", {"x-oold-version": "1.0.0"}) == "ok" + + assert outcome("rule.id-alias", {}, ContextView(terms={"name": "ex:name"})) == "warn" + assert outcome("rule.id-alias", {}, ContextView(terms={"id": "@id"})) == "ok" + assert outcome("rule.id-alias", {}, ContextView(terms={"identifier": {"@id": "@id"}})) == "ok" + + assert outcome("rule.dialect", {"$schema": "https://json-schema.org/draft/2020-12/schema"}) == "warn" + assert outcome("rule.dialect", {"$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json"}) == "ok" + + +def test_the_dialect_check_accepts_either_canonical_domain(): + """The $id domain has moved once and releases stamp a version, so match the file name.""" + for url in ( + "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "https://oo-ld.github.io/oold-schema/latest/meta/oold-meta-schema.json", + "https://oo-ld.org/0.8.0/meta/oold-meta-schema.json", + ): + assert outcome("rule.dialect", {"$schema": url}) == "ok", url + + +def test_processing_mode_uses_the_resolved_context(): + """A schema inheriting @version from a parent context must not be flagged. + + This was a real false positive: reading only the schema's own `@context` warned on every + subclass, because inheritance puts `@version` in the parent. + """ + assert outcome("rule.processing-mode", {}, ContextView(entries=[{"@version": 1.1}])) == "ok" + assert outcome("rule.processing-mode", {}, ContextView(entries=[{"ex": "https://x/"}])) == "warn" + # Inherited: the resolved form is a list whose first entry came from the parent schema. + inherited = ContextView(entries=[{"@version": 1.1, "id": "@id"}, {"ex": "https://x/"}]) + assert outcome("rule.processing-mode", {}, inherited) == "ok" + + +def test_processing_mode_rejects_the_string_form(): + """`"1.1"` is a string; JSON-LD requires the number.""" + assert outcome("rule.processing-mode", {}, ContextView(entries=[{"@version": "1.1"}])) == "warn" + + +# ------------------------------------------------------------------ against the real corpus + + +def test_no_must_level_rule_fires_on_the_upstream_examples(data_dir): + """The reference examples are conforming, so any MUST-level hit is a false positive.""" + from oold.validation import Options, validate_directory + + report = validate_directory(data_dir, Options(meta=("latest",), offline=True)) + hits = [c for c in report.checks if c.id.startswith("rule.") and c.status == "fail"] + assert not hits, [f"{c.id} {c.target}: {c.message}" for c in hits] + + +@pytest.mark.parametrize("check", RULE_CHECKS, ids=lambda c: c.check_id) +def test_every_check_runs_on_every_example(check, data_dir): + """No check may crash on a real schema; each must produce a verdict.""" + from oold.validation import Options, validate_directory + + report = validate_directory(data_dir, Options(meta=("latest",), offline=True)) + produced = [c for c in report.checks if c.id == check.check_id] + assert produced, f"{check.check_id} produced no finding at all" diff --git a/tests/test_validation/test_rules.py b/tests/test_validation/test_rules.py index 6fb7fc2..6e4cd84 100644 --- a/tests/test_validation/test_rules.py +++ b/tests/test_validation/test_rules.py @@ -56,6 +56,21 @@ "since": "0.8.0", "deprecated": False, }, + { + # Nothing enforces @propagate, so this is the sample's coverage gap. It must stay + # unenforced for the coverage tests to mean anything; if a check is ever written for + # it, swap in another unenforced rule rather than deleting the assertions. + "id": "OOLD-CMP-004", + "area": "CMP", + "level": "MUST", + "applies_to": "document", + "section": "merge-and-override-model", + "summary": "A scoped context that must apply only to the immediate node sets @propagate false.", + "text": "The schema MUST set @propagate false on that scoped context.", + "checkable": True, + "since": "0.8.0", + "deprecated": False, + }, { "id": "OOLD-RT-009", "area": "RT", @@ -121,7 +136,7 @@ def test_a_malformed_catalog_is_treated_as_absent(catalog_version, tmp_path): def test_checkable_rules_exclude_implementation_advisory_and_deprecated(catalog_version): ids = [r["id"] for r in load_tracked(catalog_version).checkable_rules()] - assert ids == ["OOLD-RT-002", "OOLD-VER-001"] + assert ids == ["OOLD-RT-002", "OOLD-VER-001", "OOLD-CMP-004"] assert "OOLD-INS-003" not in ids, "an implementation rule is not checkable by a validator" assert "OOLD-RT-009" not in ids, "a deprecated rule is not counted" @@ -188,7 +203,7 @@ def test_unenforced_rules_are_a_warning_not_a_failure(catalog_version, complianc report = run_compliance(compliance_dir, Options(meta=(catalog_version,), offline=True)) coverage = next(c for c in report.checks if c.id == "coverage.rules") assert coverage.status == "warn" - assert "OOLD-VER-001" in coverage.detail["unenforced"] + assert "OOLD-CMP-004" in coverage.detail["unenforced"] def test_a_mapped_rule_missing_from_an_older_catalog_is_not_a_failure(catalog_version, compliance_dir): @@ -230,8 +245,9 @@ def test_rules_list_filters_by_area(run, catalog_version): def test_rules_list_unchecked_shows_the_gap(run, catalog_version): out = run("rules", "list", "--meta", catalog_version, "--unchecked").output - assert "OOLD-VER-001" in out, "no check enforces it" + assert "OOLD-CMP-004" in out, "no check enforces @propagate" assert "OOLD-RT-002" not in out, "lint.container enforces it" + assert "OOLD-VER-001" not in out, "rule.id enforces it" def test_rules_explain(run, catalog_version): From 71c734c6fb1864a6d9274efaf017be6032b2b4c6 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 4 Aug 2026 13:59:28 +0200 Subject: [PATCH 05/29] feat(validation): drive rule checks from the specification catalogue - Vendors meta-schema 1.0.0-rc.1, the first version to ship oold-rules.json - Severity now comes from the catalogue; the FAIL/WARN column is removed from the check registry - A rule absent from the selected version, or marked deprecated, is skipped with the reason given - Fixes _version_key so pre-releases sort before their release (1.0.0-rc.1 before 1.0.0) - Fixtures refreshed from the v1.0.0-rc.1 tag --- .gitattributes | 2 +- docs/how-to/validation.md | 27 +- .../meta/1.0.0-rc.1/oold-meta-schema.json | 160 ++++++ .../1.0.0-rc.1/oold-pattern-lint.schema.json | 52 ++ .../meta/1.0.0-rc.1/oold-rules.json | 496 ++++++++++++++++++ .../meta/1.0.0-rc.1/oold-ui-meta-schema.json | 93 ++++ src/oold/validation/meta/index.json | 20 + src/oold/validation/meta_store.py | 21 +- src/oold/validation/pipeline.py | 44 +- src/oold/validation/rule_checks.py | 97 +++- tests/data/oold/OwlOrganization.schema.json | 2 +- tests/data/oold/RdfPerson.schema.json | 2 +- tests/data/oold/UiAnnotations.schema.json | 2 +- tests/data/oold/compliance/oold-vocab.json | 4 +- .../oold/compliance/roundtrip-patterns.json | 13 +- tests/test_validation/test_meta_store.py | 44 ++ tests/test_validation/test_pipeline.py | 15 +- tests/test_validation/test_rule_checks.py | 53 +- tests/test_validation/test_rules.py | 44 +- 19 files changed, 1121 insertions(+), 70 deletions(-) create mode 100644 src/oold/validation/meta/1.0.0-rc.1/oold-meta-schema.json create mode 100644 src/oold/validation/meta/1.0.0-rc.1/oold-pattern-lint.schema.json create mode 100644 src/oold/validation/meta/1.0.0-rc.1/oold-rules.json create mode 100644 src/oold/validation/meta/1.0.0-rc.1/oold-ui-meta-schema.json diff --git a/.gitattributes b/.gitattributes index e8d6472..f8975c9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,5 +3,5 @@ # the fixtures are refreshed by copying bytes straight out of a tag. With `core.autocrlf=true` - # the Windows default - git would rewrite their line endings on checkout, silently breaking those # checksums and making every refresh from upstream show a whole-file diff. -src/oold/validation/meta/0.*/** -text +src/oold/validation/meta/*/** -text tests/data/oold/** -text diff --git a/docs/how-to/validation.md b/docs/how-to/validation.md index 806955a..a584778 100644 --- a/docs/how-to/validation.md +++ b/docs/how-to/validation.md @@ -61,8 +61,8 @@ can be checked against several versions at once. oold validate ./schemas --meta 0.7.0 --meta 0.8.0 ``` -Only two checks depend on the version, `schema.meta` and `lint.pattern`, and only those are -repeated per version; everything else runs once. Results carry the version they came from, so a +Three families depend on the version - `schema.meta`, `lint.pattern` and the per-rule `rule.*` +checks - and only those are repeated per version; everything else runs once. Results carry the version they came from, so a difference between releases is visible rather than confusing. This is reproducible against the committed fixtures: @@ -104,10 +104,22 @@ oold rules list --unchecked # checkable rules no check enforces yet oold rules explain OOLD-RT-002 # level, binding, spec text and link ``` -The catalog was introduced upstream after 0.8.0, so no tracked version ships one yet; use -`--meta remote` until a release includes it. A version without a catalog is fully supported: it -validates exactly as before, findings simply carry no citation, and `coverage.rules` reports -`skip`. +The catalogue arrived in `1.0.0-rc.1`. Older tracked versions predate it and, being released +tags, can never gain one. That is fully supported, and has a deliberate consequence: + +| The selected version | What happens | +|---|---| +| ships a catalogue | Findings cite their rule; the `rule.*` checks run, with **severity taken from the catalogue** | +| ships none | Findings carry no citation; the `rule.*` checks are **skipped**, and `coverage.rules` reports `skip` | + +Skipping rather than guessing is the point. Each `rule.*` check enforces one statement, and a +version that never stated it must not be judged against it - the same class of false positive as +judging a schema on its literal rather than its resolved `@context`. + +The same gating applies within a catalogue: a rule absent from that version, or marked +`deprecated`, is skipped with the reason given. So upstream deprecating a rule stops the +corresponding check as soon as the new version is vendored, with no code change here. Severity +follows too - relaxing a MUST to a SHOULD upstream turns a failure into a warning by itself. Each rule records **who it binds**, which decides what can enforce it: @@ -129,7 +141,7 @@ which resolves every mapping against the current upstream catalog. |---|---| | `schema.meta` | The schema validates against the OO-LD meta-schema. | | `schema.refs` | Its `$ref` composition resolves. | -| `lint.pattern` | No term coerces a literal to a datatype JSON encodes natively (`xsd:string`, `xsd:boolean`, `xsd:integer`, `xsd:double`, `xsd:float`). None of those survive a round-trip. | +| `lint.pattern` | No term coerces a literal to a datatype JSON-LD produces by default from a native JSON value. Which datatypes those are is the meta-schema's business, not this package's: `1.0.0-rc.1` lists `xsd:string`, `xsd:boolean`, `xsd:integer` and `xsd:double`, having moved `xsd:float` out. | | `lint.container` | A strictly `type: array` property declares `@container: @set` or `@list`, or a single-element array returns as a scalar. | | `lint.iri-format` | *(warning)* A bare-IRI-string reference declares an `iri-reference` or stricter `uri*` format. | | `generate.satisfiable` | A generated instance validates against its own schema, catching unsatisfiable schemas. | @@ -141,6 +153,7 @@ which resolves every mapping against the current upstream catalog. | `roundtrip.instance` | It round-trips through RDF unchanged. | | `compliance.*`, `coverage.vocab` | Fixture suites with exact expected outcomes, plus a cross-check that every meta-schema keyword has a test. | | `coverage.rules` | *(warning)* Which checkable rules no check enforces yet. | +| `rule.checks` | *(skip)* Recorded when the selected meta version ships no catalogue, so the per-rule checks did not run. | ### Single-rule checks diff --git a/src/oold/validation/meta/1.0.0-rc.1/oold-meta-schema.json b/src/oold/validation/meta/1.0.0-rc.1/oold-meta-schema.json new file mode 100644 index 0000000..fec8290 --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.1/oold-meta-schema.json @@ -0,0 +1,160 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$dynamicAnchor": "meta", + "title": "OO-LD dialect meta-schema", + "$comment": "The $id uses the versioned hosting at oo-ld.org/ (the source keeps the /latest/ placeholder; each released copy is stamped per release). The OO-LD vocabulary is declared optional (false) so that generic JSON-Schema 2020-12 validators still process OO-LD schemas. The UI keyword definitions are included via the oold-ui-meta-schema #keywords anchor so a schema carrying x-oold-ui-* annotations validates in one pass. The @context below is the OO-LD meta-level prefix set against which x-oold-context / x-sssom CURIEs (synonym keys, predicate_id, mapping_set_id) are expanded by OO-LD processors; it is not an instance context.", + "@context": { + "skos": "http://www.w3.org/2004/02/skos/core#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "owl": "http://www.w3.org/2002/07/owl#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "sssom": "https://w3id.org/sssom/" + }, + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://oo-ld.org/latest/vocab/oold": false + }, + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + { "$ref": "https://oo-ld.org/latest/meta/oold-ui-meta-schema.json#keywords" } + ], + "properties": { + "@context": { + "description": "JSON-LD context for instances of this schema. The schema is consumed as a remote JSON-LD context; this entry is ignored by JSON-Schema validators." + }, + "x-oold-context": { + "description": "Extended term mappings (synonyms): an object keyed by term (a property, class, or value term), each holding a dict keyed by synonym IRI whose value is a JSON-LD term-definition fragment plus an optional strippable x-sssom block. OO-LD tooling reads only two x-sssom slots - predicate_id (a SKOS mapping predicate) and mapping_set_id (for profile-based selection); all other slots ride along and round-trip to SSSOM. Supports override under composition (most-derived-wins; null removes) and namespace/mapping-set selection. Promoted into @context by OO-LD-aware tooling; see the 'Term mappings and synonyms' section.", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": ["object", "null"], + "description": "A JSON-LD term-definition fragment (@id, @type, @container, ...), promotable verbatim into @context, plus an optional x-sssom block. null removes an inherited mapping under composition.", + "properties": { + "x-sssom": { + "type": "object", + "description": "SSSOM mapping metadata (https://w3id.org/sssom/). OO-LD interprets predicate_id and mapping_set_id; all other SSSOM slots are preserved verbatim and round-trip to a SSSOM mapping set.", + "properties": { + "predicate_id": { + "description": "SKOS mapping predicate from the term's primary IRI (subject) to this synonym IRI (object); default skos:exactMatch when absent. Compared by expansion to an absolute IRI. Only exactMatch entries are co-emitted by default.", + "type": "string", + "default": "skos:exactMatch", + "examples": ["skos:exactMatch", "skos:closeMatch", "skos:broadMatch", "skos:narrowMatch", "skos:relatedMatch"] + }, + "mapping_set_id": { + "description": "The SSSOM mapping set(s) this entry belongs to, for profile-based selection. SSSOM defines mapping_set_id at set level; OO-LD records it inline per entry and an entry MAY belong to several sets.", + "oneOf": [ + { "type": "string", "format": "iri-reference" }, + { "type": "array", "items": { "type": "string", "format": "iri-reference" } } + ] + } + } + } + } + } + }, + "examples": [ + { "name": { "skos:prefLabel": { "x-sssom": { "predicate_id": "skos:exactMatch", "confidence": 0.95 } } } } + ] + }, + "x-oold-uuid": { + "description": "Stable UUID identifying this schema across versions and locations.", + "type": "string", + "format": "uuid" + }, + "x-oold-version": { + "description": "Semantic version of this schema.", + "type": "string" + }, + "x-oold-prior-version": { + "description": "Identifier or version of the immediately preceding schema version.", + "type": "string" + }, + "x-oold-backward-compatible-with": { + "description": "URI of a prior schema version this schema is backward-compatible with.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-incompatible-with": { + "description": "URI of a prior schema version this schema is NOT compatible with.", + "type": "string", + "format": "uri-reference" + }, + "x-sssom": { + "description": "Schema-level ontology correspondences: an SSSOM mapping set whose subject is this schema, keyed by the object IRI of a resolvable resource, each value carrying SSSOM slots (predicate_id default skos:exactMatch, mapping_set_id, ...). The schema-level counterpart of the per-term x-sssom used inside x-oold-context; it describes the schema itself, not its instances. See the 'Ontology correspondence' section.", + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "predicate_id": { + "type": "string", + "default": "skos:exactMatch", + "examples": ["skos:exactMatch", "skos:closeMatch"] + }, + "mapping_set_id": { + "oneOf": [ + { "type": "string", "format": "iri-reference" }, + { "type": "array", "items": { "type": "string", "format": "iri-reference" } } + ] + } + } + }, + "examples": [ + { "https://schema.org/Person": { "predicate_id": "skos:exactMatch" } } + ] + }, + "x-oold-instance-rdf-type": { + "description": "The rdf:type(s) carried by instances of this schema, as a list of IRIs (e.g. [\"schema:Person\"]). OO-LD tooling materializes these as @type when exporting an instance to JSON-LD / RDF.", + "type": "array", + "items": { "type": "string" } + }, + "x-oold-ref": { + "description": "Reference to another OO-LD schema. Use x-oold-ref (not the standard $ref) for references that appear inside OO-LD custom keywords such as x-oold-range: there a plain $ref would be eagerly - and, for cyclic schema graphs, dangerously - dereferenced by generic JSON-Schema bundlers (the behaviour is undefined per Core section 9.4.2). Keep using the standard $ref for ordinary schema composition (allOf, properties, $defs), which bundlers are expected to resolve. x-oold-ref is resolved only by OO-LD-aware tools, lazily and with cycle handling.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-range": { + "description": "Type constraint on the target of an IRI-valued property: an IRI string, an array of IRIs, or an OO-LD subschema (using x-oold-ref for references). See the 'Range of properties' section.", + "anyOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "$comment": "OO-LD subschema form; references inside it use x-oold-ref. The reverse-property keywords (x-oold-reverse-*) are intentionally not validated within a range subschema for now." + } + ] + }, + "x-oold-multilang-title": { + "description": "Language map of translated `title` values keyed by BCP-47 language code.", + "type": "object", + "additionalProperties": { "type": "string" } + }, + "x-oold-multilang-description": { + "description": "Language map of translated `description` values keyed by BCP-47 language code.", + "type": "object", + "additionalProperties": { "type": "string" } + }, + "x-oold-reverse-properties": { + "description": "Properties stored on the related object but editable from this side, mapped via JSON-LD @reverse.", + "type": "object" + }, + "x-oold-reverse-required": { + "description": "Names of reverse properties that are required.", + "type": "array", + "items": { "type": "string" } + }, + "x-oold-reverse-default-properties": { + "description": "Deprecated. Names of reverse properties shown by default in generated user interfaces. Like the object-level defaultProperties array this is extend-only under composition; prefer a per-reverse-property x-oold-ui-default-property boolean, which is overridable.", + "deprecated": true, + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/src/oold/validation/meta/1.0.0-rc.1/oold-pattern-lint.schema.json b/src/oold/validation/meta/1.0.0-rc.1/oold-pattern-lint.schema.json new file mode 100644 index 0000000..2291694 --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.1/oold-pattern-lint.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-pattern-lint.schema.json", + "title": "OO-LD round-trip pattern lint", + "description": "SHOULD-level constraints on a schema's @context that keep instances round-trip-safe, checkable by JSON Schema alone. This is distinct from oold-meta-schema.json, which asserts MUST-level well-formedness. It currently enforces that no term coerces a literal to a datatype JSON-LD produces by default from a native JSON value (xsd:string, xsd:boolean, xsd:integer, xsd:double): xsd:string is RDF's default datatype and is elided from plain literals, and the round-trip contract reconstructs boolean/integer/double literals as native JSON values (fromRDF with native types), so in both cases the value carries no @type and a term declaring one is never selected when the value is compacted back from RDF - the property returns under its full IRI and the round-trip is lossy (see the specification, Property value forms). JSON-LD derives the correct RDF datatype from the native JSON type, so these coercions are also redundant. Datatypes JSON-LD does not produce by default (xsd:date, xsd:dateTime, xsd:float, ... - the value carried as a JSON string) keep their @type through the round-trip and coerce fine. CURIEs are matched in their conventional xsd: form and as the full XSD IRI; a term that coerces through a non-standard prefix mapping is beyond what a single JSON Schema can resolve and is left to tooling.", + "type": "object", + "properties": { + "@context": { "$ref": "#/$defs/context" } + }, + "$defs": { + "context": { + "oneOf": [ + { "type": "null" }, + { "type": "string" }, + { "type": "array", "items": { "$ref": "#/$defs/context" } }, + { "$ref": "#/$defs/contextObject" } + ] + }, + "contextObject": { + "type": "object", + "patternProperties": { + "^@": true, + "^[^@]": { "$ref": "#/$defs/termValue" } + }, + "additionalProperties": { "$ref": "#/$defs/termValue" } + }, + "termValue": { + "oneOf": [ + { "type": "null" }, + { "type": "string" }, + { "$ref": "#/$defs/termDefinition" } + ] + }, + "termDefinition": { + "type": "object", + "properties": { + "@type": { "$ref": "#/$defs/notNativeJsonDatatype" }, + "@context": { "$ref": "#/$defs/context" } + } + }, + "notNativeJsonDatatype": { + "not": { + "enum": [ + "xsd:string", "http://www.w3.org/2001/XMLSchema#string", + "xsd:boolean", "http://www.w3.org/2001/XMLSchema#boolean", + "xsd:integer", "http://www.w3.org/2001/XMLSchema#integer", + "xsd:double", "http://www.w3.org/2001/XMLSchema#double" + ] + } + } + } +} diff --git a/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json new file mode 100644 index 0000000..95dce9b --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json @@ -0,0 +1,496 @@ +{ + "$comment": "Catalog of the normative statements in the OO-LD specification, generated from the :rule[...] markers in spec/sections/*.md by scripts/extract_rules.py. Do not edit by hand. Ids are immutable and never reused; see meta/RULES.md.", + "spec_version": "1.0.0-rc.1", + "areas": { + "CNF": "Serialization and conformance", + "SCH": "Schema well-formedness and the meta-schema", + "CMP": "Composition, merge and override", + "INS": "Instances: $schema, identity, semantic type, value forms", + "RT": "Projection to RDF and round-trip safety", + "VER": "Identification and versioning", + "EXT": "Standard extensions (JSON-LD and JSON Schema)" + }, + "applies_to": { + "document": "Checkable by validating a schema or instance document", + "implementation": "Constrains an OO-LD implementation; needs a library conformance suite", + "advisory": "Guidance; nothing verifies it automatically" + }, + "rules": [ + { + "id": "OOLD-CMP-001", + "area": "CMP", + "level": "MUST NOT", + "applies_to": "document", + "section": "composition", + "summary": "A schema must be usable as a JSON-LD context with no further processing, so every $ref is reflected in the @context.", + "text": "It MUST NOT be required to further process an OO-LD schema document in order to interpret it as a JSON-LD context. This implies that all occurrences of `$ref` in the schema are reflected in the JSON-LD context. An embedded object reached under an object-valued property - one whose value is an object, directly (`type: object`) or as the `items` of an array (`type: array`), whether inlined or brought in by `$ref` - SHOULD be reflected as that property's scoped JSON-LD context, so its terms resolve only under that property and cannot conflict with a same-named term elsewhere. That scoped context MAY reference the embedded schema remotely (by URL) or carry its terms inline. Where the embed graph is cyclic - a value type whose scoped context transitively references itself through remote schema files - JSON-LD processors cannot resolve the recursive remote contexts (see [](#round-trip)); breaking the cycle requires migrating the remote reference to a local (inline) context - inlining the term definitions so there is no remote hop to recurse - which MAY be flattened onto the root context as a shared vocabulary. Moving the remote reference to the root does not break the cycle; only replacing it with local definitions does. A `$ref` at the root level of the OO-LD schema is listed at the root of the JSON-LD context. (A scalar reference - a property whose value is an IRI string, not an embedded object - carries its target type in [`x-oold-range`](#range-of-properties), not a `$ref`, and so contributes no scoped context.) In case of multiple `$ref` within `allOf` the corresponding remote contexts are merged into an array-valued `@context` (see [](#merging-remote-contexts)). For `oneOf` / `anyOf` this requires care to avoid conflicts. At any time the importing OO-LD schema MAY define its own or override the imported JSON-LD context.", + "text_sha256": "e9c7b033426df2b69444b5bc87fad3e95d9a0076c027c473854660d48e8b00a8", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:5" + }, + { + "id": "OOLD-CMP-002", + "area": "CMP", + "level": "MUST", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "A schema with multiple $refs must list their remote contexts as an array, in allOf order.", + "text": "Multiple `$ref` (e.g. in `allOf`) each correspond to a remote context. By the reflection rule above, the schema's own `@context` MUST list those remote contexts as an array, in the same order as the `allOf` members, so the schema stays usable as a context without further processing. A JSON-LD processor then resolves that array in order, later entries overriding earlier ones - duplicate context terms are overridden using a most-recently-defined-wins mechanism (JSONLD11-API, Context Processing Algorithm). The schema MAY append its own context object as the last array entry to override an inherited term. The single-context `@import` keyword is an alternative only when exactly one remote context is wrapped and locally modified (it cannot contain a nested `@import`), so the array form is used for the multi-`$ref` case.", + "text_sha256": "d15b85d655e00efb9b2dabd2ecd9af008279874dacc158d073fdc4042818a061", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:47" + }, + { + "id": "OOLD-CMP-003", + "area": "CMP", + "level": "MUST NOT", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "Reflected oneOf/anyOf branch contexts must not map the same keyword to different IRIs at the root.", + "text": "`oneOf` / `anyOf`. The remote contexts of `oneOf` / `anyOf` branches MAY also be reflected into the `@context`, but they MUST NOT conflict at the root - they MUST NOT map the same keyword to different IRIs there. A JSON-LD processor merges all listed contexts (most-recently-wins) and has no notion of which branch a given instance matched, so a root-level conflict would be decided by context order rather than by the branch the data conforms to.", + "text_sha256": "c89bc6e726c69bfe3a1e17705a43d68a0d5b9dc48320126c47301b773333e47b", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:49" + }, + { + "id": "OOLD-CMP-004", + "area": "CMP", + "level": "MUST", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "A scoped context that must apply only to the immediate node sets @propagate false; contexts in one array share it.", + "text": "Propagation (`@propagate`). A `$ref` inside a `type: object` property is reflected as a property-scoped context, which by default propagates into the whole subtree rooted at that property (\"By default ... contexts propagate across node objects, other than for type-scoped contexts, which default to false\"). Where a referenced context should apply only to the immediate node, the schema MUST set `\"@propagate\": false` on that scoped context.", + "text_sha256": "6dd7a2e03456e05cc665f8e0289244a8ebbd6a24f54fcbaa945974612cf56285", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:70" + }, + { + "id": "OOLD-CMP-005", + "area": "CMP", + "level": "MUST NOT", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "A schema $id must not contain a non-empty fragment.", + "text": "Independent references and base URIs. A JSON Schema `$ref` and a JSON-LD `@context` entry are independent references: they MAY point to the same document (the typical OO-LD case, where one document is both a schema and a context) or to different documents - for example a plain JSON Schema referenced via `$ref` together with a separate remote `@context` that supplies the semantics. Relative references resolve against the schema's `$id` (the JSON Schema base URI) and, on the JSON-LD side, against `@base` / the retrieval URL; these base URIs SHOULD be aligned so a relative reference resolves to the same absolute URL under both. `$id` MUST NOT contain a non-empty fragment (JSONSCHEMA §8.2.1).", + "text_sha256": "a1726b6c66d62e77f19ac7ec7ed40e58a289000ab6bffcc15dc484eeb6d1afd6", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:74" + }, + { + "id": "OOLD-CMP-006", + "area": "CMP", + "level": "MUST NOT", + "applies_to": "document", + "section": "merge-and-override-model", + "summary": "Composition is narrow-only: a derived schema may restrict a constraint but must not relax it.", + "text": "When such a merge is required, OO-LD resolves the `allOf` chain by applying JSON Merge Patch (RFC7396) semantics: keyed by object member, most-recently-defined (most-derived) wins, and a `null` value removes a key. For the `@context` this coincides with JSON-LD's own override rule. For assertion-bearing keywords the resolved view additionally honors narrow-only composition: a derived schema MAY restrict a constraint but MUST NOT relax it, matching how code generators let a subclass tighten - never loosen - a superclass property's validation.", + "text_sha256": "0577522e552c16a3888a866cad50e69af94c65dc0d9fbf54e6d759c000256c24", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:80" + }, + { + "id": "OOLD-CNF-001", + "area": "CNF", + "level": "MUST", + "applies_to": "document", + "section": "notation", + "summary": "A conforming schema or instance must be interchangeable as JSON, canonicalized per RFC 8785.", + "text": "The normative data model of OO-LD is the JSON data model shared by JSONSCHEMA and JSON-LD11. JSON (RFC8259) is the canonical serialization: a conforming OO-LD schema or instance MUST be interchangeable as JSON, and the canonical form used for identity and integrity (for example content-hashing a versioned schema) is its JSON Canonicalization Scheme (RFC8785) serialization.", + "text_sha256": "ebd7a19c376bcfd0285729b14755a9ef9e845891ea7f715cbcd0eae9c8ea056f", + "checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "03-conformance.md:9" + }, + { + "id": "OOLD-EXT-001", + "area": "EXT", + "level": "SHOULD", + "applies_to": "document", + "section": "processing-mode", + "summary": "A generated context should declare @version 1.1 as a JSON number.", + "text": "Generated OO-LD contexts SHOULD therefore declare `\"@version\": 1.1` (the JSON number `1.1`, not the string `\"1.1\"`). Modern processors default to the 1.1 processing mode, so this is a guard rather than a strict requirement: it prevents a JSON-LD 1.0 processor from silently mis-processing a 1.1 document (JSON-LD11 §4.1.1). Because the first encountered `@version` entry determines the processing mode, it is sufficient to declare `\"@version\": 1.1` once in the base context of a composition (for example a root `Thing` schema).", + "text_sha256": "c8215e2a9f1b2bf8f2272e7eebcf09768f413f25222b0fa2d249b1fb1cb5fa42", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:13" + }, + { + "id": "OOLD-EXT-002", + "area": "EXT", + "level": "SHOULD", + "applies_to": "document", + "section": "jsonschema-extensions", + "summary": "A schema should declare the OO-LD dialect meta-schema as its $schema.", + "text": "OO-LD targets JSONSCHEMA (2020-12) as its normative dialect. An OO-LD schema SHOULD declare the OO-LD dialect meta-schema (which extends 2020-12) as its `$schema`, e.g. `\"$schema\": \"https://oo-ld.org/latest/meta/oold-meta-schema.json\"` - pinning a specific version (e.g. `.../0.4.0/meta/oold-meta-schema.json`) for reproducibility. Declaring the plain 2020-12 meta-schema (`https://json-schema.org/draft/2020-12/schema`) remains valid for tools that only understand standard JSON Schema.", + "text_sha256": "6bd4e71c2522bb54cd27f845d954f8ed1398059918f641863dd62dbc1994ed8f", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:185" + }, + { + "id": "OOLD-EXT-003", + "area": "EXT", + "level": "REQUIRED", + "applies_to": "document", + "section": "jsonschema-extensions", + "summary": "JSON Schema 2020-12 is required as the dialect, because composition places $ref alongside sibling keywords.", + "text": "2020-12 is REQUIRED, not merely preferred: OO-LD's composition places `$ref` alongside sibling keywords (e.g. a property carrying `type`, `x-oold-range` and `@context`, or `allOf: [{$ref: ...}]` next to `properties`). Keywords adjacent to `$ref` are only evaluated from JSON Schema 2019-09 onward; in Draft 4 and Draft 7 they are ignored (JSONSCHEMA §8.2.3.1). Keywords such as `const` (used throughout this document) are likewise only available from draft-06 onward. Migration from the earlier Draft-4-style notation: rename `definitions` to `$defs`, `id` to `$id`, and use the numeric form of `exclusiveMinimum`/`exclusiveMaximum` instead of the boolean form.", + "text_sha256": "b1b8b6e6bc07d9cf85a6eed6493f55a0db0817c234da035caea43885e6428944", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:187" + }, + { + "id": "OOLD-EXT-004", + "area": "EXT", + "level": "MUST", + "applies_to": "document", + "section": "localizing-schema-annotations", + "summary": "x-oold-multilang-title/description must map BCP 47 language tags to translated strings.", + "text": "The JSON Schema annotation keywords `title` and `description` carry a single, default human-readable string used by tooling (for example for UI generation). To provide localized variants, OO-LD adds the keywords `x-oold-multilang-title` and `x-oold-multilang-description`. Their value MUST be an object whose keys are [BCP 47](https://www.rfc-editor.org/info/bcp47) language tags (e.g. `en`, `de`, `en-GB`) and whose values are the translated strings. A schema SHOULD still provide a default `title` / `description`; a consumer that has no entry for the requested language falls back to that default. These keywords localize the schema's own labels and are not interpreted as JSON-LD.", + "text_sha256": "a676fc85873bef5f2f8afc1fbba2b44ba202e0527e4884156365ff32ca79f0e5", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:195" + }, + { + "id": "OOLD-EXT-005", + "area": "EXT", + "level": "MUST", + "applies_to": "document", + "section": "range-of-properties", + "summary": "References inside x-oold-range must use x-oold-ref, never $ref.", + "text": "An OO-LD subschema, the most expressive form. Unions (`anyOf` / `oneOf`), intersections (`allOf`) and inline constraints can be combined to describe an anonymous subclass. References to other schemas inside `x-oold-range` MUST use `x-oold-ref`, never `$ref` (see below). The single-IRI form (1) is a shorthand for `{ \"allOf\": [ { \"x-oold-ref\": \"Organization.schema.json\" } ] }`:", + "text_sha256": "ec04833484058c216d9658ae580f0fe29929d3bce683e7fc739654707fc0c067", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:298" + }, + { + "id": "OOLD-EXT-006", + "area": "EXT", + "level": "SHOULD", + "applies_to": "document", + "section": "range-reference-form", + "summary": "An IRI-valued property should constrain its lexical form with an IRI/URI-family format.", + "text": "The value of an IRI-valued property is a JSON string. Its role as a reference comes from the `@context` (`\"@type\": \"@id\"`) and its class from `x-oold-range`. Its lexical form SHOULD be constrained with an IRI/URI-family `format` so that malformed values are rejected; the choices, from most to least permissive:", + "text_sha256": "881fe0ebc6467edebbc166318d25d826960380e619c22e5ea38a6f3352125ff0", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:322" + }, + { + "id": "OOLD-EXT-007", + "area": "EXT", + "level": "MUST", + "applies_to": "document", + "section": "range-reference-form", + "summary": "A compact-IRI prefix used by a property must be defined in the @context.", + "text": "Stricter, ASCII only - `\"format\": \"uri\"` or `\"uri-reference\"`, where values are known not to use internationalized (non-ASCII) IRIs. - Compact form specifically - a `\"pattern\"` such as `\"^[A-Za-z_][\\\\w.-]:(?!//)\\\\S$\"`, which accepts `ex:alice` and `schema:Person` while rejecting `http://…`; the prefix MUST be defined in the `@context`.", + "text_sha256": "69077b848bf1fe47f3dc600091208e6fe1285186bad60e9abef0d26f151f4a4d", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:327" + }, + { + "id": "OOLD-EXT-008", + "area": "EXT", + "level": "SHOULD", + "applies_to": "implementation", + "section": "semantic-delivery", + "summary": "A consumer accepting arbitrary JSON Schema keywords should receive the native form unchanged.", + "text": "A consumer that accepts arbitrary JSON Schema keywords SHOULD receive the native form unchanged. This covers plain JSON Schema 2020-12 validators, OpenAPI 3.1, and - because they place no restriction on `@context` - Model Context Protocol tool schemas (`inputSchema` / `outputSchema`) as well as LLM tool-use and structured-output APIs, which carry the context through and can use it as grounding.", + "text_sha256": "636b1004494749d5400cc0025a1bab6403cdb4404a0aea91d6665013dc25bbb4", + "checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:426" + }, + { + "id": "OOLD-EXT-009", + "area": "EXT", + "level": "SHOULD", + "applies_to": "implementation", + "section": "semantic-delivery", + "summary": "For OpenAPI 3.0, deliver the context and type per class as vendor extensions.", + "text": "For OpenAPI 3.0, which rejects unprefixed keywords in a Schema Object (and typically bundles several classes with no document root to host one `@context`), the context and type SHOULD be delivered per class as `x-jsonld-context` and `x-jsonld-type` following [REST API Linked Data Keywords](https://datatracker.ietf.org/doc/html/draft-polli-restapi-ld-keywords-08): `@context` maps to `x-jsonld-context` and `x-oold-instance-rdf-type` to `x-jsonld-type`. That draft requires references inside these keywords not to be dereferenced automatically, consistent with the `x-oold-ref` rule (see [](#why-x-oold-ref)). The mapping is reversible, so such an export can be read back into an OO-LD schema.", + "text_sha256": "2e7d6caba59b7eafe971ca8779f73cd897b6070b3dbcbd6890f1d8994efb461d", + "checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:427" + }, + { + "id": "OOLD-INS-001", + "area": "INS", + "level": "SHOULD", + "applies_to": "document", + "section": "schema-instances", + "summary": "Instances should reference a versioned schema URL.", + "text": "Instances SHOULD use a versioned schema URL so that it is unambiguous which schema version they conform to.", + "text_sha256": "e9929e4d9ba01bc251bf092b05c886ff703d9432859d20c4c7db8dcdc6e8244d", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:17" + }, + { + "id": "OOLD-INS-002", + "area": "INS", + "level": "MUST", + "applies_to": "document", + "section": "semantic-type", + "summary": "An inline type must be consistent with the schema's x-oold-instance-rdf-type.", + "text": "If an inline `type` is present it MUST be consistent with the schema's `x-oold-instance-rdf-type`. Note that `@type` alone lets a consumer locate the schema (case 3 above) only when one of the type IRIs resolves to an OO-LD schema.", + "text_sha256": "168de023827ee98f1e7ab446fa83320d5143e3c7fa0c201f59ff65a6dda3dcc6", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:107" + }, + { + "id": "OOLD-INS-003", + "area": "INS", + "level": "MUST", + "applies_to": "implementation", + "section": "identity", + "summary": "An exported identifiable entity must carry an IRI.", + "text": "An implementation MAY use a non-IRI identifier internally, but when it exports an identifiable entity (to JSON-LD / RDF) it MUST assign an `@id` (or the aliased `id`). The `@id` SHOULD be resolvable, and it is RECOMMENDED to mint it from an autogenerated UUID - mirroring the schema's `x-oold-uuid` - e.g. `https://example.org/a1b2c3d4-1234-...`.", + "text_sha256": "b63e120752aa4612586e20f828bc0c2a624e2e29241277c90d8c0f99119ea007", + "checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:62" + }, + { + "id": "OOLD-INS-004", + "area": "INS", + "level": "MUST NOT", + "applies_to": "implementation", + "section": "referencing-schema", + "summary": "A consuming side must not be assumed to hold an rdf:type-to-schema registry; exports are self-sufficient.", + "text": "An implementation MAY additionally maintain a registry mapping `rdf:type` IRIs to OO-LD schemas to resolve case 3, but such a registry MUST NOT be assumed to exist on the consuming side - so exports must be self-sufficient (see below).", + "text_sha256": "1f3874f3ae2f46ff72ae03f0f5b715296e7b637357bb3d8ac6eec656688fc17b", + "checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:35" + }, + { + "id": "OOLD-INS-005", + "area": "INS", + "level": "MUST", + "applies_to": "document", + "section": "referencing-schema", + "summary": "A schema closing its objects must still permit the $schema and @context members.", + "text": "Because an instance carries `$schema` and `@context` as ordinary members, an OO-LD schema that closes its objects with `additionalProperties: false` or `unevaluatedProperties: false` MUST permit these two members, or conforming instances would fail validation.", + "text_sha256": "abd2bf2e989b2e2aea4cd7af0f6f7a3d69f802e6722467da727bd915940f1b68", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:37" + }, + { + "id": "OOLD-INS-006", + "area": "INS", + "level": "SHOULD NOT", + "applies_to": "implementation", + "section": "referencing-schema", + "summary": "A consumer should not blindly trust the schema an instance declares for itself.", + "text": "`@context` already provides a JSON-LD-native link to the schema (resolution case 2 above), so `$schema` is kept primarily for compatibility with the widespread editor and CI convention, not as a second authoritative mechanism. JSON Schema deliberately does not standardize `$schema` on instances, partly over a self-validation concern: a consumer SHOULD NOT blindly trust the schema an instance declares for itself (a crafted instance could point at a permissive schema) and remains responsible for validating against a schema it trusts.", + "text_sha256": "7814a782bc6b257c15c4769bdf97b5503af73c2def9ee965d0e36fb6809cf527", + "checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:39" + }, + { + "id": "OOLD-INS-007", + "area": "INS", + "level": "SHOULD", + "applies_to": "document", + "section": "identity", + "summary": "Schemas should expose @id through an aliased id property.", + "text": "To keep instance keys variable-name-friendly, schemas SHOULD expose `@id` through an aliased `id` property (as with `type` -> `@type`):", + "text_sha256": "4f852db64a6532e49924b789cb0fdc636d349b6990b28a6b417d10ed49e564e0", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:45" + }, + { + "id": "OOLD-INS-008", + "area": "INS", + "level": "MUST", + "applies_to": "implementation", + "section": "semantic-type", + "summary": "Tooling exporting an instance must materialize the schema-declared rdf:type(s) as @type.", + "text": "These types live in the schema, not in the instance data, so a JSON-LD-only processor - which sees only the instance and its `@context` - cannot derive them. Therefore, when OO-LD tooling exports an instance (to JSON-LD / RDF), it MUST materialize the declared `rdf:type`(s) as an `@type` on the instance, so that the type reaches RDF without access to the schema or to a type registry.", + "text_sha256": "3261cc51ea1af8dc6d591e62a569a74944d5f1e8a878e1d51f67f1a33a2d02c6", + "checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:78" + }, + { + "id": "OOLD-INS-009", + "area": "INS", + "level": "MUST NOT", + "applies_to": "document", + "section": "value-forms", + "summary": "A property whose range includes free text must not use @type @id.", + "text": "A single `@context` term cannot interpret a bare string as both a literal and an IRI: `@type: \"@id\"` coerces every string value to an IRI (so free text becomes an - often invalid, then dropped - IRI), while a plain term keeps every string a literal. A property whose range is references only therefore uses `@type: \"@id\"` and MAY be written as a bare IRI string; a property whose range includes free text MUST NOT use `@type: \"@id\"`.", + "text_sha256": "26ba5dd6a3a0b998a0d191907742c08f82ece9ccc76278dd6ac08bf5b61c707d", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:134" + }, + { + "id": "OOLD-INS-010", + "area": "INS", + "level": "SHOULD", + "applies_to": "advisory", + "section": "value-forms", + "summary": "A model ecosystem should adopt one of the two ambiguous-range patterns consistently.", + "text": "For a property whose range mixes free text with references and/or embedded objects (for example `Text | PostalAddress | Place`), two patterns keep the instance round-trippable (see [](#round-trip)); a model ecosystem SHOULD adopt one of them consistently:", + "text_sha256": "fd5834cb52f2ba7a41d5919db177c636bb08de85e3d8484c246a8c70e88fe921", + "checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:136" + }, + { + "id": "OOLD-INS-011", + "area": "INS", + "level": "MUST NOT", + "applies_to": "document", + "section": "value-forms", + "summary": "Under the value-form pattern a reference is written as an object and its term must not carry @type.", + "text": "Value-form - a single plain term (no `@type: \"@id\"`); the value shape alone disambiguates: a bare scalar is a literal, `{ \"id\": ... }` is a reference, a typed object is embedded. References are written as objects, and the term MUST NOT carry `@type`.", + "text_sha256": "bb09381c42aa4a7cc0471a5ab4e57ee3fd2c06249d3c332d50a1dff8c638c315", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:138" + }, + { + "id": "OOLD-RT-001", + "area": "RT", + "level": "MUST NOT", + "applies_to": "document", + "section": "value-forms", + "summary": "A term must not coerce a literal to a datatype JSON-LD produces by default from a native JSON value (xsd:string, xsd:boolean, xsd:integer, xsd:double).", + "text": "A term MUST NOT declare `@type` with a datatype that JSON-LD produces by default from a native JSON value: `xsd:string` (from a string), `xsd:boolean` (from a boolean), `xsd:integer` (from an integer number), and `xsd:double` (from a fractional number). These are exactly the datatypes reconstruction converts back to native JSON values without an `@type` (JSONLD11-API, RDF to Object Conversion; see [](#round-trip)): the value arrives from RDF with no datatype, and a term is never selected against a conflicting or absent type mapping (JSONLD11-API, Term Selection), so the value reappears under the full predicate IRI instead. Coercing to one of these is redundant and lossy - a native JSON number already round-trips as `xsd:integer` or `xsd:double` with no coercion at all, and a boolean/string likewise. This is inherent to the compaction algorithm, not a tooling limitation; such terms are left plain (no `@type`), and the projection to RDF still yields the correct datatype from the native JSON type (JSONLD11-API, Data Round Tripping). The behaviour assumes reconstruction with native types (`useNativeTypes`), the mainstream default: a processor that instead keeps every literal as a typed value object would select the coerced term, but then plain native numbers and booleans no longer return as native JSON either (they come back as `{ \"@value\": ..., \"@type\": ... }` objects), which defeats the structural model - so native-type reconstruction is assumed throughout.", + "text_sha256": "954c75f41845b3c0ac622d8dbd710157319d250aedc19bce76eb0abc4332d1d1", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:164" + }, + { + "id": "OOLD-RT-002", + "area": "RT", + "level": "MUST", + "applies_to": "document", + "section": "round-trip", + "summary": "A strictly array-typed property must declare @container @set or @list.", + "text": "Multi-valued properties are set-valued in RDF: order is not preserved, duplicates are removed, and a single value compacts to a scalar. Because the reconstruction MUST re-validate, a property that is strictly an array (JSON Schema `type: \"array\"`) MUST declare `@container: \"@set\"` (or `\"@list\"`): without it a single-element array returns as a scalar and violates the `array` type. A property that also permits a scalar (an `anyOf`/`oneOf` of a literal and an array) MAY declare it for a stable array shape, but need not - the scalar form still validates, and a single value and a one-element array are JSON-LD-equivalent. Round-trip equality is set equality; use `@list` only where order is significant, at the cost of merge and query ergonomics.", + "text_sha256": "1f7adf73c70d7fb0ea1419d03bba2cac5dd99560fe2e4f9ec5511ce54fd8f28b", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:180" + }, + { + "id": "OOLD-RT-005", + "area": "RT", + "level": "SHOULD", + "applies_to": "document", + "section": "round-trip", + "summary": "The embed graph formed by scoped @contexts should be acyclic.", + "text": "An embedded object is mapped by a scoped `@context` on its property (referencing the embedded type's own context). These scoped contexts form an embed graph between schemas, and that graph SHOULD be acyclic: model a property whose value is an independent entity, or whose type would close an embed cycle (a type embedding itself, or two types embedding each other), as a reference - `@type: \"@id\"` plus `x-oold-range`, with no scoped `@context` - rather than an embed. This is the linked-data analog of using a pointer instead of inlining a recursive data structure. A self-reference through the top-level `@context` (a property that nests the same type but carries no scoped context, so the global context maps the nested keys - e.g. a `Process` with sub-`Process`es) is not part of this graph and round-trips normally, bounded by the instance's actual depth.", + "text_sha256": "abd98388126428c7de580af27d61a97dd38f8cce9680445e12a63467f779ddb4", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:187" + }, + { + "id": "OOLD-SCH-001", + "area": "SCH", + "level": "MUST NOT", + "applies_to": "implementation", + "section": "basic-concepts", + "summary": "An OO-LD schema document must not be interpreted as a JSON-LD document.", + "text": "An OO-LD schema is consumed as a JSON-LD remote context (referenced by its URL from an instance's `@context`), never as a JSON-LD document. OO-LD schema documents MUST NOT be interpreted as JSON-LD documents, because that would apply the schema's own `@context` to the schema itself and produce incorrect triples.", + "text_sha256": "1dc261ae9c95bcd88a08eef6376f64063c7f893cb1d06a4de5c110856f75cb11", + "checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "05-basic-concepts.md:11" + }, + { + "id": "OOLD-VER-001", + "area": "VER", + "level": "MUST", + "applies_to": "document", + "section": "identification", + "summary": "A schema must have a $id serving as its global unique identifier.", + "text": "OO-LD schemas MUST have a `$id` (JSONSCHEMA §8.2.1) which works as a global and unique identifier of the schema. The value of `$id` MAY be an absolute URI (details below). The schema SHOULD be resolvable via this URI. The schema SHOULD have an annotation `x-oold-uuid` with a UUID value.", + "text_sha256": "1f48402923618b3933db2579c4cc92891bc7bdf5e095e5b12487119fc7bf6705", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:5" + }, + { + "id": "OOLD-VER-002", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "versioning", + "summary": "A schema version should be stated with x-oold-version.", + "text": "The schema version SHOULD be indicated by `x-oold-version`; a prior version MAY be indicated with `x-oold-prior-version`:", + "text_sha256": "7d62b2cbfa7d78f02f91d1e08fa0ac97e07385088b1b5da426e9f46dea77961a", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:49" + }, + { + "id": "OOLD-VER-003", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "versioning", + "summary": "The schema version should be part of the schema location URL.", + "text": "The version SHOULD be part of the schema's location:", + "text_sha256": "9e4671a42c0df7845c72b1cb55c9723532573150183495259835ccfab7f4d6e2", + "checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:63" + } + ] +} diff --git a/src/oold/validation/meta/1.0.0-rc.1/oold-ui-meta-schema.json b/src/oold/validation/meta/1.0.0-rc.1/oold-ui-meta-schema.json new file mode 100644 index 0000000..1d5e981 --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.1/oold-ui-meta-schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-ui-meta-schema.json", + "$dynamicAnchor": "meta", + "title": "OO-LD UI dialect meta-schema", + "$comment": "The $id uses the versioned hosting at oo-ld.org/ (the source keeps the /latest/ placeholder; each released copy is stamped per release). The oold-ui vocabulary is declared optional (false) so that generic JSON-Schema 2020-12 validators still process the schema. The x-oold-ui-* keyword definitions live in $defs.keywords (plain anchor #keywords) so the main OO-LD meta-schema can include just them, without re-introducing the 2020-12 reference or a second dynamic anchor. Each keyword carries a description and an example so the vocabulary can be rendered into documentation. As with the core dialect, this meta-schema only validates that the keywords are well-formed; the behaviour is supplied by OO-LD-aware form generators (for example jedison).", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://oo-ld.org/latest/vocab/oold-ui": false + }, + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + { "$ref": "#keywords" } + ], + "$defs": { + "keywords": { + "$anchor": "keywords", + "properties": { + "x-oold-ui-widget": { + "description": "Widget hint for a value whose intended widget is not a registered JSON Schema format (for example table, tabs, grid, autocomplete, textarea, checkbox, markdown, color). Registered formats (date, uri, uuid, ...) stay in `format`. Maps to jedison `x-format`.", + "type": "string", + "examples": ["table", "autocomplete", "markdown"] + }, + "x-oold-ui-property-order": { + "description": "Display order of this property within its object or group; lower sorts first. Maps to jedison `x-categoryOrder`.", + "type": "integer", + "examples": [1] + }, + "x-oold-ui-property-group": { + "description": "Name of the group, tab or category this property belongs to. Maps to jedison `x-category` / `x-propGroup`.", + "type": "string", + "examples": ["General", "Contact"] + }, + "x-oold-ui-form-hidden": { + "description": "Hide this property in the editing form. Maps to jedison `x-hidden`.", + "type": "boolean", + "examples": [true] + }, + "x-oold-ui-render-hidden": { + "description": "Hide this property in the rendered (read) view.", + "type": "boolean", + "examples": [true] + }, + "x-oold-ui-enum-titles": { + "description": "Human display labels for the default language, aligned positionally with `enum`: the Nth label is the title of the Nth enum value. For `enum: [\"pi\", \"postdoc\", \"phd\"]` the value `[\"Principal investigator\", \"Postdoc\", \"PhD student\"]` labels each option. Localize with `x-oold-multilang-ui-enum-titles`. Distinct from the identifier-safe code names in `x-enum-varnames`. Maps to jedison `x-enumTitles`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["Principal investigator", "Postdoc", "PhD student"]] + }, + "x-oold-multilang-ui-enum-titles": { + "description": "BCP-47 language map of `x-oold-ui-enum-titles` arrays (mirrors `x-oold-multilang-title`); each array aligns positionally with `enum`. For `enum: [\"pi\", \"postdoc\", \"phd\"]`: {\"en\": [\"Principal investigator\", \"Postdoc\", \"PhD student\"], \"de\": [\"Projektleitung\", \"Postdoc\", \"Doktorand\"]}.", + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "string" } }, + "examples": [{ "en": ["Principal investigator", "Postdoc", "PhD student"], "de": ["Projektleitung", "Postdoc", "Doktorand"] }] + }, + "x-oold-ui-hint": { + "description": "Short help text shown with the field, in the default language. Localize with `x-oold-multilang-ui-hint`. Maps to jedison `x-info`.", + "type": "string", + "examples": ["Full name"] + }, + "x-oold-multilang-ui-hint": { + "description": "BCP-47 language map of the `x-oold-ui-hint` text (mirrors `x-oold-multilang-title`).", + "type": "object", + "additionalProperties": { "type": "string" }, + "examples": [{ "en": "Full name", "de": "Vollständiger Name" }] + }, + "x-oold-ui-default-property": { + "description": "Whether this optional property is shown by default in generated user interfaces. Replaces the object-level `defaultProperties` array: a per-property boolean is overridable under composition (most-derived-wins), so a derived schema can set it false, whereas the merged array form was extend-only.", + "type": "boolean", + "examples": [true] + }, + "x-enum-varnames": { + "description": "Identifier-safe code names aligned positionally with `enum`, for code generation. For `enum: [\"m\", \"s\"]` the value `[\"metre\", \"second\"]` names each option (so a generator can emit `Unit.metre` instead of `Unit.m`). An established vendor extension (OpenAPI Generator; NSwag uses the camelCase `x-enumNames`). Kept as-is; distinct from the human labels in `x-oold-ui-enum-titles`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["metre", "second"]] + }, + "x-enum-descriptions": { + "description": "Per-value descriptions aligned positionally with `enum`, the established companion of `x-enum-varnames`. For `enum: [\"m\", \"s\"]`: `[\"SI base unit of length\", \"SI base unit of time\"]`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["SI base unit of length", "SI base unit of time"]] + } + } + } + } +} diff --git a/src/oold/validation/meta/index.json b/src/oold/validation/meta/index.json index 780aeaf..2d29254 100644 --- a/src/oold/validation/meta/index.json +++ b/src/oold/validation/meta/index.json @@ -35,6 +35,26 @@ "oold-pattern-lint.schema.json": "0537e7f7604ce666f666deab30cde90ca09c69a30ca068ea5ed9f8c69a81c6c3", "oold-ui-meta-schema.json": "dd389d13a5e03268d4a4ff845dec7f4f28238f7edbd9fe0992399b37ac358212" } + }, + "1.0.0-rc.1": { + "tag": "v1.0.0-rc.1", + "commit": "4cb3abc5163e34abb1ca7a2aa46d85324731c107", + "committed": "2026-08-03T07:46:50+02:00", + "added": "2026-08-04", + "id_base": "https://oo-ld.org/latest/meta/", + "prerelease": true, + "notes": "First version to ship oold-rules.json, the catalogue of normative statements. The three meta-schemas are verbatim from the v1.0.0-rc.1 tag; the catalogue is not yet released and comes from the oold-schema branch feat/rule-catalog-rc1 (f56818f). Refresh it from the tag once that branch is merged and a release ships it.", + "rules_source": { + "branch": "feat/rule-catalog-rc1", + "commit": "f56818f", + "released": false + }, + "sha256": { + "oold-meta-schema.json": "cad3151c6bf0ac3e74acd46a4fee59b9287a551a9f62aa68aa7e2a718f360dbc", + "oold-pattern-lint.schema.json": "d89fce19cd2fd42fa740d92968fcf61a1764ea25e741ed5cd4e72040a45c9a86", + "oold-ui-meta-schema.json": "dd389d13a5e03268d4a4ff845dec7f4f28238f7edbd9fe0992399b37ac358212", + "oold-rules.json": "befaf98f6bb0e3e9f26c8961800a36ba996e7d669e97388b564137b390aa6da0" + } } } } diff --git a/src/oold/validation/meta_store.py b/src/oold/validation/meta_store.py index b3ebb67..6f5ff0f 100644 --- a/src/oold/validation/meta_store.py +++ b/src/oold/validation/meta_store.py @@ -94,12 +94,23 @@ def remote_base_url() -> str: return str(base) +def _chunks(text: str) -> tuple: + """Dotted parts, numeric where possible, so 0.10.0 orders after 0.9.0.""" + return tuple((0, int(c)) if c.isdigit() else (1, c) for c in text.split(".") if c) + + def _version_key(version: str) -> tuple: - """Sort key that orders 0.10.0 after 0.9.0 rather than before it.""" - parts: list[Any] = [] - for chunk in version.split("."): - parts.append((0, int(chunk)) if chunk.isdigit() else (1, chunk)) - return tuple(parts) + """Sort key over version directory names, deciding `latest` and the order of ``--meta all``. + + Two things it has to get right. Numeric ordering, so ``0.10.0`` follows ``0.9.0`` rather than + preceding it lexically. And pre-releases: ``1.0.0-rc.1`` sorts *before* ``1.0.0``, because a + release candidate is not the release. Splitting on ``.`` alone put the candidate after its own + release, so vendoring both would have made ``latest`` resolve to the candidate and every + default run validate against an RC. + """ + release, _, pre = version.partition("-") + # Absence of a pre-release sorts above any pre-release of the same release. + return (_chunks(release), (1,) if not pre else (0, _chunks(pre))) def tracked_versions() -> list[str]: diff --git a/src/oold/validation/pipeline.py b/src/oold/validation/pipeline.py index 7764f01..c15e44a 100644 --- a/src/oold/validation/pipeline.py +++ b/src/oold/validation/pipeline.py @@ -423,19 +423,41 @@ def _check_predicates(run: _Run, name: str, raw, schema, sample) -> None: def _run_rule_checks(run: _Run, name: str, raw: dict[str, Any], context: ContextView) -> None: - """Report the narrow, single-rule checks for one schema. + """Report the narrow, single-rule checks for one schema, per meta-schema version. - A passing check is still recorded, so `--verbose` shows which requirements were verified and - the counts line up with what `oold rules list` claims is enforced. + These are the only checks whose *applicability* depends on the version: each enforces one + statement, and a version that never stated it must not be judged against it. So they run once + per selected version, driven by that version's catalogue - which also supplies the severity, + so a MUST relaxed to a SHOULD upstream changes the outcome with no code change here. + + A version shipping no catalogue skips them entirely rather than guessing. Running blind would + assert requirements that version may never have stated, which is the same false-positive class + as judging a schema on its literal rather than resolved `@context`. + + A passing check is still recorded, so `--verbose` shows what was verified and the counts line + up with what `oold rules list` claims is enforced. """ - for finding in run_rule_checks(raw, context): - run.add( - finding.check_id, - name, - finding.status, - finding.message, - finding.detail, - ) + for bundle in run.bundles: + if not bundle.has_rules: + run.add( + "rule.checks", + name, + SKIP, + f"meta-schema {bundle.version} ships no rule catalogue, so per-rule checks " + "cannot be attributed to a stated requirement", + meta_version=bundle.version, + ) + continue + catalog = {r["id"]: r for r in bundle.rules} + for finding in run_rule_checks(raw, context, catalog): + run.add( + finding.check_id, + name, + finding.status, + finding.message, + finding.detail, + bundle.version, + ) def _check_instance_file(run: _Run, name: str, instance: Any = None) -> None: diff --git a/src/oold/validation/rule_checks.py b/src/oold/validation/rule_checks.py index a4d259f..c9fc14a 100644 --- a/src/oold/validation/rule_checks.py +++ b/src/oold/validation/rule_checks.py @@ -21,7 +21,7 @@ from typing import Any from .frame import collect_composed_properties, instance_rdf_types -from .report import FAIL, OK, WARN, Status +from .report import FAIL, OK, SKIP, WARN, Status #: A `$schema` naming the OO-LD dialect, on either canonical domain. The domain moved from #: oo-ld.github.io to oo-ld.org, and released copies stamp a version in place of `latest`, so the @@ -61,22 +61,45 @@ def keyword(self, name: str) -> Any: return None +#: RFC 2119 levels that make a violation a failure. Everything else is advice, so it warns. +_MUST_LEVELS = frozenset({"MUST", "MUST NOT", "SHALL", "SHALL NOT", "REQUIRED"}) + +#: Used only when the meta version in use ships no catalogue to read the level from. +DEFAULT_LEVEL: Status = FAIL + + +def severity(rule: dict[str, Any] | None, fallback: Status = DEFAULT_LEVEL) -> Status: + """How hard a violation of this rule should land, taken from the specification. + + The level is the specification's own, not a taste judgement made here, so relaxing a MUST to + a SHOULD upstream changes the validator's behaviour with no code change. Without a catalogue + there is nothing to read, and the caller's fallback applies. + """ + if not rule: + return fallback + return FAIL if rule.get("level") in _MUST_LEVELS else WARN + + @dataclass class RuleCheck: """A check that enforces exactly one rule.""" check_id: str rule: str - #: FAIL for a MUST, WARN for a SHOULD. The specification's level, not a taste judgement. - level: Status describe: str run: Callable[[dict[str, Any], ContextView], list[str]] - def __call__(self, schema: dict[str, Any], context: ContextView) -> RuleFinding: + def __call__( + self, + schema: dict[str, Any], + context: ContextView, + rule: dict[str, Any] | None = None, + ) -> RuleFinding: + """Apply the check, taking its severity from ``rule`` when a catalogue supplied one.""" problems = self.run(schema, context) if not problems: return RuleFinding(self.check_id, self.rule, OK) - return RuleFinding(self.check_id, self.rule, self.level, "; ".join(problems), {"problems": problems}) + return RuleFinding(self.check_id, self.rule, severity(rule), "; ".join(problems), {"problems": problems}) # ---------------------------------------------------------------------------- individual rules @@ -258,42 +281,76 @@ def _processing_mode_not_declared(schema: dict[str, Any], context: ContextView) #: Every rule this package enforces beyond the ported general-workflow checks. Order is the order #: findings are reported in. RULE_CHECKS: list[RuleCheck] = [ - RuleCheck("rule.id", "OOLD-VER-001", FAIL, "a schema has a $id", _missing_id), - RuleCheck("rule.id-fragment", "OOLD-CMP-005", FAIL, "a $id has no non-empty fragment", _id_has_fragment), - RuleCheck("rule.range-ref", "OOLD-EXT-005", FAIL, "x-oold-range references use x-oold-ref", _range_uses_ref), + RuleCheck("rule.id", "OOLD-VER-001", "a schema has a $id", _missing_id), + RuleCheck("rule.id-fragment", "OOLD-CMP-005", "a $id has no non-empty fragment", _id_has_fragment), + RuleCheck("rule.range-ref", "OOLD-EXT-005", "x-oold-range references use x-oold-ref", _range_uses_ref), RuleCheck( "rule.instance-type", "OOLD-INS-002", - FAIL, "a pinned type agrees with x-oold-instance-rdf-type", _inline_type_disagrees, ), RuleCheck( "rule.free-text-iri", "OOLD-INS-009", - FAIL, "a free-text range is not coerced to @id", _free_text_range_coerced_to_iri, ), RuleCheck( "rule.closed-object", "OOLD-INS-005", - FAIL, "a closed object still permits $schema and @context", _closed_object_rejects_metadata, ), - RuleCheck("rule.version", "OOLD-VER-002", WARN, "a schema states x-oold-version", _missing_version), - RuleCheck("rule.id-alias", "OOLD-INS-007", WARN, "@id is exposed through an alias", _id_not_aliased), - RuleCheck("rule.dialect", "OOLD-EXT-002", WARN, "a schema declares the OO-LD dialect", _dialect_not_declared), - RuleCheck( - "rule.processing-mode", "OOLD-EXT-001", WARN, "a context declares @version 1.1", _processing_mode_not_declared - ), + RuleCheck("rule.version", "OOLD-VER-002", "a schema states x-oold-version", _missing_version), + RuleCheck("rule.id-alias", "OOLD-INS-007", "@id is exposed through an alias", _id_not_aliased), + RuleCheck("rule.dialect", "OOLD-EXT-002", "a schema declares the OO-LD dialect", _dialect_not_declared), + RuleCheck("rule.processing-mode", "OOLD-EXT-001", "a context declares @version 1.1", _processing_mode_not_declared), ] #: check id -> rule id, for the pipeline's citation mapping and coverage figure. RULE_CHECK_MAP: dict[str, str] = {c.check_id: c.rule for c in RULE_CHECKS} -def run_rule_checks(schema: dict[str, Any], context: ContextView) -> list[RuleFinding]: - """Apply every rule check to one schema, against its resolved context.""" - return [check(schema, context) for check in RULE_CHECKS] +def run_rule_checks( + schema: dict[str, Any], + context: ContextView, + catalog: dict[str, dict[str, Any]] | None = None, +) -> list[RuleFinding]: + """Apply the rule checks that the selected specification version actually states. + + ``catalog`` maps rule id to its catalogue entry for the meta version in use. When given, a + check whose rule is absent from it is **skipped**: that version never stated the requirement, + and enforcing it would report a violation of something the target does not require. A + deprecated rule is skipped for the same reason from the other end. + + When ``catalog`` is None the version ships no catalogue at all, and the caller decides + whether to run the checks blind or skip them. + """ + findings: list[RuleFinding] = [] + for check in RULE_CHECKS: + rule = (catalog or {}).get(check.rule) + if catalog is not None: + if rule is None: + findings.append( + RuleFinding( + check.check_id, + check.rule, + SKIP, + f"{check.rule} is not stated by this meta-schema version", + ) + ) + continue + if rule.get("deprecated"): + superseded = ", ".join(rule.get("superseded_by") or []) or "nothing" + findings.append( + RuleFinding( + check.check_id, + check.rule, + SKIP, + f"{check.rule} is deprecated in this version (superseded by {superseded})", + ) + ) + continue + findings.append(check(schema, context, rule)) + return findings diff --git a/tests/data/oold/OwlOrganization.schema.json b/tests/data/oold/OwlOrganization.schema.json index ecb77ec..ead6bee 100644 --- a/tests/data/oold/OwlOrganization.schema.json +++ b/tests/data/oold/OwlOrganization.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "OwlOrganization.schema.json", - "x-oold-iri": "schema:Organization", + "x-sssom": { "schema:Organization": { "predicate_id": "skos:exactMatch" } }, "@context": { "id": "@id", "type": "@type", diff --git a/tests/data/oold/RdfPerson.schema.json b/tests/data/oold/RdfPerson.schema.json index fa4067f..1d4222e 100644 --- a/tests/data/oold/RdfPerson.schema.json +++ b/tests/data/oold/RdfPerson.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "RdfPerson.schema.json", - "x-oold-iri": "schema:Person", + "x-sssom": { "schema:Person": { "predicate_id": "skos:exactMatch" } }, "@context": { "id": "@id", "type": "@type", diff --git a/tests/data/oold/UiAnnotations.schema.json b/tests/data/oold/UiAnnotations.schema.json index a9c7ff0..9fa8952 100644 --- a/tests/data/oold/UiAnnotations.schema.json +++ b/tests/data/oold/UiAnnotations.schema.json @@ -15,7 +15,7 @@ "x-oold-uuid": "b1e7a0c2-2d4f-4a1e-9c3a-7f0e5d2b6a11", "title": "Researcher", "x-oold-multilang-title": { "en": "Researcher", "de": "Forschende Person" }, - "x-oold-iri": "schema:Person", + "x-sssom": { "schema:Person": { "predicate_id": "skos:exactMatch" } }, "type": "object", "properties": { "name": { diff --git a/tests/data/oold/compliance/oold-vocab.json b/tests/data/oold/compliance/oold-vocab.json index 61a4c1f..99c254a 100644 --- a/tests/data/oold/compliance/oold-vocab.json +++ b/tests/data/oold/compliance/oold-vocab.json @@ -13,7 +13,7 @@ "x-oold-prior-version": "0.9.0", "x-oold-backward-compatible-with": "0.9.0/Person.schema.json", "x-oold-incompatible-with": "0.8.0/Person.schema.json", - "x-oold-iri": "schema:Person", + "x-sssom": { "schema:Person": { "predicate_id": "skos:exactMatch" } }, "x-oold-instance-rdf-type": ["schema:Person"], "x-oold-multilang-title": { "en": "Person", "de": "Person" }, "x-oold-multilang-description": { "en": "A person", "de": "Eine Person" }, @@ -53,7 +53,7 @@ { "description": "x-oold-prior-version not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-prior-version": 1 } }, { "description": "x-oold-backward-compatible-with not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-backward-compatible-with": 1 } }, { "description": "x-oold-incompatible-with not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-incompatible-with": 1 } }, - { "description": "x-oold-iri not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-iri": 1 } }, + { "description": "x-sssom not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-sssom": 1 } }, { "description": "x-oold-instance-rdf-type not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-instance-rdf-type": "schema:Person" } }, { "description": "x-oold-ref not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ref": 1 } }, { "description": "x-oold-range as a number", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-range": 42 } }, diff --git a/tests/data/oold/compliance/roundtrip-patterns.json b/tests/data/oold/compliance/roundtrip-patterns.json index 0e15625..e1df254 100644 --- a/tests/data/oold/compliance/roundtrip-patterns.json +++ b/tests/data/oold/compliance/roundtrip-patterns.json @@ -1,7 +1,7 @@ [ { "$comment": "Round-trip-safe projection of an ambiguous property range (literal | reference | embedded object), using the address = Text | PostalAddress | Place example from the specification (Property value forms, Projection to RDF and round-trip). The `lintSchemas` group is checked against meta/oold-pattern-lint.schema.json; the `tests` group projects each value form to RDF (expectRdf, dataset isomorphism).", - "description": "the pattern lint rejects a literal term coerced to a natively-JSON-encoded datatype (xsd:string, xsd:boolean, xsd:integer, xsd:double, xsd:float), accepts a plain literal term", + "description": "the pattern lint rejects a literal term coerced to a default-projection datatype (xsd:string, xsd:boolean, xsd:integer, xsd:double), accepts a plain literal term and non-default datatypes (xsd:date, xsd:float)", "lintSchemas": [ { "description": "a plain literal term (no @type) round-trips - lint passes", @@ -24,6 +24,17 @@ } } }, + { + "description": "xsd:float is not a default projection (JSON numbers project to xsd:double), so it coerces and round-trips - lint passes", + "valid": true, + "schema": { + "@context": { + "schema": "http://schema.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "weight": { "@id": "schema:weight", "@type": "xsd:float" } + } + } + }, { "description": "@type: xsd:string is never selected on the way back from RDF - lint fails (CURIE form)", "valid": false, diff --git a/tests/test_validation/test_meta_store.py b/tests/test_validation/test_meta_store.py index ebcbe6d..b2fc1bc 100644 --- a/tests/test_validation/test_meta_store.py +++ b/tests/test_validation/test_meta_store.py @@ -33,6 +33,32 @@ def test_versions_sort_numerically_not_lexically(): assert meta_store._version_key("1.0.0") > meta_store._version_key("0.99.0") +def test_a_pre_release_sorts_before_its_own_release(): + """`latest` must never resolve to a release candidate over the release itself. + + Splitting on "." alone put `1.0.0-rc.1` *after* `1.0.0`, because the chunk "0-rc" is not a + digit and fell to the string branch. Vendoring both would then have made every default run + validate against the candidate. + """ + k = meta_store._version_key + assert k("1.0.0-rc.1") < k("1.0.0") + assert k("1.0.0-rc.1") < k("1.0.0-rc.2") + assert k("0.9.0") < k("1.0.0-rc.1") + assert k("1.0.0") < k("2.0.0-alpha.1") + + +def test_full_ordering_including_pre_releases(): + unsorted = ["1.0.0", "0.7.0", "1.0.0-rc.2", "0.10.0", "1.0.0-rc.1", "0.9.0"] + assert sorted(unsorted, key=meta_store._version_key) == [ + "0.7.0", + "0.9.0", + "0.10.0", + "1.0.0-rc.1", + "1.0.0-rc.2", + "1.0.0", + ] + + def test_index_records_provenance_for_every_tracked_version(): index = meta_store.load_index() for version in tracked_versions(): @@ -63,6 +89,24 @@ def test_recorded_checksums_match_the_shipped_files(): ) +def test_the_vendored_files_are_stored_with_unix_line_endings(): + """Recorded checksums are of LF bytes, so a CRLF copy passes here and fails on Linux. + + The checksum test above compares against the working tree. On Windows with `core.autocrlf` + that hides the very mistake it exists to catch: a file committed with CRLF hashes one way in + a Windows checkout and another in a Linux one, so the suite is green locally and red in CI. + Asserting the bytes directly is platform-independent - the file either has CRLF in it or it + does not - which makes this the check that actually travels. + """ + for version in tracked_versions(): + for path in sorted((meta_store.meta_dir() / version).glob("*.json")): + assert b"\r\n" not in path.read_bytes(), ( + f"{version}/{path.name} contains CRLF. Vendored files are copied verbatim from an " + "oold-schema tag and must stay LF, because meta/index.json records a sha256 of " + "their bytes. Convert it back to LF and re-check the recorded digest." + ) + + def test_bundle_self_check_is_clean(): bundle = load_tracked(latest_version()) assert bundle.self_check() == [] diff --git a/tests/test_validation/test_pipeline.py b/tests/test_validation/test_pipeline.py index e36b4ed..b9e56e6 100644 --- a/tests/test_validation/test_pipeline.py +++ b/tests/test_validation/test_pipeline.py @@ -108,10 +108,21 @@ def test_missing_context_term_names_the_orphan_property(broken_dir): def test_only_version_dependent_checks_are_tagged_with_a_version(data_dir): - """Fanning every check across versions would multiply the report for no information.""" + """Fanning every check across versions would multiply the report for no information. + + Three families legitimately depend on the version: the two driven by a meta-schema, and the + per-rule checks, whose applicability and severity come from that version's catalogue. + Everything else - $ref resolution, generation, round-trip - runs once. + """ report = validate_directory(data_dir, OFFLINE) tagged = {c.id for c in report.checks if c.meta_version} - assert tagged == {"schema.meta", "lint.pattern"} + untagged = {c.id for c in report.checks if not c.meta_version} + + assert {"schema.meta", "lint.pattern"} <= tagged + assert all(c in {"schema.meta", "lint.pattern"} or c.startswith("rule.") for c in tagged), tagged + assert not any(c.startswith("rule.") for c in untagged), untagged + for once in ("schema.refs", "generate.satisfiable", "roundtrip.generated"): + assert once in untagged def test_multiple_versions_only_repeat_the_dependent_checks(data_dir, monkeypatch): diff --git a/tests/test_validation/test_rule_checks.py b/tests/test_validation/test_rule_checks.py index 5d7643b..7f2c0a6 100644 --- a/tests/test_validation/test_rule_checks.py +++ b/tests/test_validation/test_rule_checks.py @@ -9,34 +9,63 @@ import pytest -from oold.validation.rule_checks import RULE_CHECKS, ContextView, run_rule_checks +from oold.validation.meta_store import latest_version, load_tracked +from oold.validation.rule_checks import RULE_CHECKS, ContextView, run_rule_checks, severity + +#: The catalogue actually shipped for the newest tracked version. Severity is read from it rather +#: than hardcoded here, so if upstream relaxes a MUST to a SHOULD these tests report the change +#: instead of silently disagreeing with the specification. +CATALOG = {r["id"]: r for r in load_tracked(latest_version()).rules} + + +def _findings(schema: dict, context: ContextView | None = None): + return {f.check_id: f for f in run_rule_checks(schema, context or ContextView(), CATALOG)} def outcome(check_id: str, schema: dict, context: ContextView | None = None) -> str: - findings = {f.check_id: f for f in run_rule_checks(schema, context or ContextView())} - return findings[check_id].status + return _findings(schema, context)[check_id].status def message(check_id: str, schema: dict, context: ContextView | None = None) -> str: - findings = {f.check_id: f for f in run_rule_checks(schema, context or ContextView())} - return findings[check_id].message + return _findings(schema, context)[check_id].message # ------------------------------------------------------------------ registry -def test_every_check_declares_a_rule_and_a_level(): +def test_every_check_names_a_rule_that_exists(): for check in RULE_CHECKS: assert check.rule.startswith("OOLD-"), check.check_id assert check.check_id.startswith("rule."), check.check_id - assert check.level in ("fail", "warn") + assert check.rule in CATALOG, f"{check.check_id} cites {check.rule}, absent from the catalogue" -def test_a_must_fails_and_a_should_only_warns(): - """The level comes from the specification, not from taste.""" - levels = {c.rule: c.level for c in RULE_CHECKS} - assert levels["OOLD-VER-001"] == "fail", "a MUST" - assert levels["OOLD-VER-002"] == "warn", "a SHOULD" +def test_severity_is_read_from_the_specification_not_hardcoded(): + """A MUST fails and a SHOULD warns because the catalogue says so. + + Nothing in this package repeats the level, so upstream relaxing a MUST changes the outcome + with no code change here. + """ + assert severity(CATALOG["OOLD-VER-001"]) == "fail", "OOLD-VER-001 is a MUST" + assert severity(CATALOG["OOLD-VER-002"]) == "warn", "OOLD-VER-002 is a SHOULD" + assert severity(CATALOG["OOLD-RT-001"]) == "fail", "MUST NOT is also a failure" + + +def test_a_rule_absent_from_the_catalogue_is_skipped(): + """A version that never stated a requirement must not be judged against it.""" + without = {k: v for k, v in CATALOG.items() if k != "OOLD-VER-001"} + findings = {f.check_id: f for f in run_rule_checks({}, ContextView(), without)} + assert findings["rule.id"].status == "skip" + assert "not stated" in findings["rule.id"].message + + +def test_a_deprecated_rule_is_skipped(): + retired = dict(CATALOG) + retired["OOLD-VER-001"] = {**retired["OOLD-VER-001"], "deprecated": True, "superseded_by": ["OOLD-VER-009"]} + findings = {f.check_id: f for f in run_rule_checks({}, ContextView(), retired)} + assert findings["rule.id"].status == "skip" + assert "deprecated" in findings["rule.id"].message + assert "OOLD-VER-009" in findings["rule.id"].message # ------------------------------------------------------------------ OOLD-VER-001 / CMP-005 diff --git a/tests/test_validation/test_rules.py b/tests/test_validation/test_rules.py index 6e4cd84..8cd4931 100644 --- a/tests/test_validation/test_rules.py +++ b/tests/test_validation/test_rules.py @@ -110,13 +110,25 @@ def catalog_version(tmp_path, monkeypatch): # ------------------------------------------------------------------ loading +#: Tracked versions predating the catalogue. Released tags are immutable, so these keep +#: exercising the no-catalogue path forever. +WITHOUT_CATALOG = [v for v in meta_store.tracked_versions() if not load_tracked(v).has_rules] + + +def test_some_tracked_version_ships_a_catalog(): + assert any(load_tracked(v).has_rules for v in meta_store.tracked_versions()), ( + "no tracked version ships oold-rules.json, so the catalogue paths are untested" + ) + + def test_a_version_without_a_catalog_still_loads(): - """The catalog postdates 0.8.0, so every tracked version must work without one.""" - for version in meta_store.tracked_versions(): + """The catalogue postdates 0.8.0, and those tags can never gain one.""" + assert WITHOUT_CATALOG, "expected at least one pre-catalogue version to remain tracked" + for version in WITHOUT_CATALOG: bundle = load_tracked(version) - assert bundle.has_rules is False assert bundle.rules == [] assert bundle.rule("OOLD-RT-002") is None + assert bundle.meta_validator().is_valid({"type": "object"}), "still usable" def test_catalog_is_loaded_when_present(catalog_version): @@ -154,11 +166,31 @@ def test_every_mapped_rule_id_is_well_formed(): def test_findings_carry_no_rule_when_the_version_has_no_catalog(data_dir): from oold.validation import Options, validate_schema - report = validate_schema(data_dir / "Thing.schema.json", Options(meta=("latest",), offline=True)) + report = validate_schema(data_dir / "Thing.schema.json", Options(meta=(WITHOUT_CATALOG[-1],), offline=True)) assert report.passed assert all(c.rule is None for c in report.checks) +def test_per_rule_checks_are_skipped_without_a_catalog(data_dir): + """Running them blind would assert requirements the version may never have stated.""" + from oold.validation import Options, validate_schema + + report = validate_schema(data_dir / "Thing.schema.json", Options(meta=(WITHOUT_CATALOG[-1],), offline=True)) + skipped = [c for c in report.checks if c.id == "rule.checks"] + assert skipped and skipped[0].status == "skip" + assert "no rule catalogue" in skipped[0].message + assert not [c for c in report.checks if c.id.startswith("rule.") and c.id != "rule.checks"] + + +def test_per_rule_checks_run_when_a_catalog_is_present(data_dir): + from oold.validation import Options, validate_schema + + report = validate_schema(data_dir / "Thing.schema.json", Options(meta=("latest",), offline=True)) + ran = [c for c in report.checks if c.id.startswith("rule.") and c.id != "rule.checks"] + assert ran, "a version with a catalogue should run the per-rule checks" + assert all(c.rule for c in ran), "each cites the rule it enforces" + + def test_findings_cite_a_rule_when_the_catalog_has_it(catalog_version, broken_dir): from oold.validation import Options, validate_schema @@ -191,7 +223,7 @@ def test_rule_appears_in_the_serialised_report(catalog_version, broken_dir): def test_coverage_is_skipped_when_the_version_ships_no_catalog(compliance_dir): from oold.validation import Options, run_compliance - report = run_compliance(compliance_dir, Options(meta=("latest",), offline=True)) + report = run_compliance(compliance_dir, Options(meta=(WITHOUT_CATALOG[-1],), offline=True)) coverage = [c for c in report.checks if c.id == "coverage.rules"] assert coverage and coverage[0].status == "skip" @@ -270,7 +302,7 @@ def test_rules_explain_unknown_id_suggests_listing(run, catalog_version): def test_rules_command_explains_a_missing_catalog(run): """The common case today: no released version ships one yet.""" - result = run("rules", "list", "--meta", meta_store.latest_version(), "--offline") + result = run("rules", "list", "--meta", WITHOUT_CATALOG[-1], "--offline") assert result.exit_code != 0 assert "no rule catalog" in result.output assert "remote" in result.output, "the message points at where a catalog can be found" From 8c965d6cd3aea4963e709fc05e304e789aa73995 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 4 Aug 2026 15:34:44 +0200 Subject: [PATCH 06/29] style(tests): store the hand-written fixtures with LF line endings - Seven hand-authored fixtures were CRLF while every fixture copied from an oold-schema tag is LF - `.gitattributes` marks the fixture directory -text, so mixed endings looked deliberate - Whitespace only; each file re-parsed and compared to its previous value after conversion - tests/data/oold/README.md refreshed by copying from upstream to avoid a line-ending diff --- .../array_without_container.schema.json | 40 ++++++++-------- .../data/oold/broken/invalid_meta.schema.json | 32 ++++++------- .../broken/missing_context_term.schema.json | 36 +++++++------- .../oold/broken/undefined_prefix.schema.json | 28 +++++------ .../unresolvable_context_ref.schema.json | 36 +++++++------- .../broken/xsd_string_coercion.schema.json | 38 +++++++-------- .../data/oold/remote_context/Leaf.schema.json | 48 +++++++++---------- 7 files changed, 129 insertions(+), 129 deletions(-) diff --git a/tests/data/oold/broken/array_without_container.schema.json b/tests/data/oold/broken/array_without_container.schema.json index 056f169..df0e58f 100644 --- a/tests/data/oold/broken/array_without_container.schema.json +++ b/tests/data/oold/broken/array_without_container.schema.json @@ -1,20 +1,20 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "array_without_container.schema.json", - "title": "ArrayWithoutContainer", - "@context": { - "ex": "https://example.org/", - "tags": { - "@id": "ex:tags" - } - }, - "type": "object", - "properties": { - "tags": { - "type": "array", - "items": { - "type": "string" - } - } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "array_without_container.schema.json", + "title": "ArrayWithoutContainer", + "@context": { + "ex": "https://example.org/", + "tags": { + "@id": "ex:tags" + } + }, + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/tests/data/oold/broken/invalid_meta.schema.json b/tests/data/oold/broken/invalid_meta.schema.json index 922a3cb..d2e00ff 100644 --- a/tests/data/oold/broken/invalid_meta.schema.json +++ b/tests/data/oold/broken/invalid_meta.schema.json @@ -1,16 +1,16 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "invalid_meta.schema.json", - "title": "InvalidMeta", - "x-oold-uuid": "not-a-uuid", - "@context": { - "ex": "https://example.org/", - "name": "ex:name" - }, - "type": "object", - "properties": { - "name": { - "type": "string" - } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "invalid_meta.schema.json", + "title": "InvalidMeta", + "x-oold-uuid": "not-a-uuid", + "@context": { + "ex": "https://example.org/", + "name": "ex:name" + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/tests/data/oold/broken/missing_context_term.schema.json b/tests/data/oold/broken/missing_context_term.schema.json index 9792fe6..694ad5b 100644 --- a/tests/data/oold/broken/missing_context_term.schema.json +++ b/tests/data/oold/broken/missing_context_term.schema.json @@ -1,18 +1,18 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "missing_context_term.schema.json", - "title": "MissingContextTerm", - "@context": { - "ex": "https://example.org/", - "name": "ex:name" - }, - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "orphan": { - "type": "string" - } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "missing_context_term.schema.json", + "title": "MissingContextTerm", + "@context": { + "ex": "https://example.org/", + "name": "ex:name" + }, + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "orphan": { + "type": "string" + } + } +} diff --git a/tests/data/oold/broken/undefined_prefix.schema.json b/tests/data/oold/broken/undefined_prefix.schema.json index 255ddbd..e9bd89d 100644 --- a/tests/data/oold/broken/undefined_prefix.schema.json +++ b/tests/data/oold/broken/undefined_prefix.schema.json @@ -1,14 +1,14 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "undefined_prefix.schema.json", - "title": "UndefinedPrefix", - "@context": { - "latitude": "schema:latitude" - }, - "type": "object", - "properties": { - "latitude": { - "type": "number" - } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "undefined_prefix.schema.json", + "title": "UndefinedPrefix", + "@context": { + "latitude": "schema:latitude" + }, + "type": "object", + "properties": { + "latitude": { + "type": "number" + } + } +} diff --git a/tests/data/oold/broken/unresolvable_context_ref.schema.json b/tests/data/oold/broken/unresolvable_context_ref.schema.json index 2944d3a..9225c6f 100644 --- a/tests/data/oold/broken/unresolvable_context_ref.schema.json +++ b/tests/data/oold/broken/unresolvable_context_ref.schema.json @@ -1,18 +1,18 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "unresolvable_context_ref.schema.json", - "title": "UnresolvableContextRef", - "@context": [ - "NoSuchSchema.schema.json", - { - "ex": "https://example.org/", - "name": "ex:name" - } - ], - "type": "object", - "properties": { - "name": { - "type": "string" - } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "unresolvable_context_ref.schema.json", + "title": "UnresolvableContextRef", + "@context": [ + "NoSuchSchema.schema.json", + { + "ex": "https://example.org/", + "name": "ex:name" + } + ], + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/tests/data/oold/broken/xsd_string_coercion.schema.json b/tests/data/oold/broken/xsd_string_coercion.schema.json index db0ba40..483bd18 100644 --- a/tests/data/oold/broken/xsd_string_coercion.schema.json +++ b/tests/data/oold/broken/xsd_string_coercion.schema.json @@ -1,19 +1,19 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "xsd_string_coercion.schema.json", - "title": "XsdStringCoercion", - "@context": { - "ex": "https://example.org/", - "xsd": "http://www.w3.org/2001/XMLSchema#", - "name": { - "@id": "ex:name", - "@type": "xsd:string" - } - }, - "type": "object", - "properties": { - "name": { - "type": "string" - } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "xsd_string_coercion.schema.json", + "title": "XsdStringCoercion", + "@context": { + "ex": "https://example.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "name": { + "@id": "ex:name", + "@type": "xsd:string" + } + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/tests/data/oold/remote_context/Leaf.schema.json b/tests/data/oold/remote_context/Leaf.schema.json index 55f93e9..1f7aa10 100644 --- a/tests/data/oold/remote_context/Leaf.schema.json +++ b/tests/data/oold/remote_context/Leaf.schema.json @@ -1,24 +1,24 @@ -{ - "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "$id": "Leaf.schema.json", - "title": "Leaf", - "x-oold-instance-rdf-type": [ - "schema:Thing" - ], - "@context": [ - "../Thing.schema.json", - { - "schema": "http://schema.org/", - "nickname": "schema:alternateName" - } - ], - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "nickname": { - "type": "string" - } - } -} +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "Leaf.schema.json", + "title": "Leaf", + "x-oold-instance-rdf-type": [ + "schema:Thing" + ], + "@context": [ + "../Thing.schema.json", + { + "schema": "http://schema.org/", + "nickname": "schema:alternateName" + } + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "nickname": { + "type": "string" + } + } +} From 988bc38de68724a027268721fad8818786bd5184 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 4 Aug 2026 15:39:00 +0200 Subject: [PATCH 07/29] docs: explain how to turn a specification rule into a check - Linked from oold-schema's `make check` whenever the catalogue gains a rule - Covers `applies_to`, severity coming from the catalogue rather than the check, and judging the resolved context instead of the literal @context --- CONTRIBUTING.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 427c9e0..45dcde9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,75 @@ uv run zensical serve uv run zensical build -s ``` +## Translating a specification rule + +The OO-LD specification numbers each of its normative statements (`OOLD-RT-002`, `OOLD-INS-004`, +...) and publishes them as `oold-rules.json`, which this repository vendors per meta-schema +version. When oold-schema adds a rule, its `make check` prints a pointer back to this section, +because a new rule is the moment the validator falls behind the specification. + +Not every rule becomes a check, so start by reading it: + +```bash +uv run oold rules explain OOLD-RT-002 +uv run oold rules list --unchecked # everything still waiting for a check +``` + +`applies_to` decides whether there is anything to do here: + +| `applies_to` | Meaning | Action | +| --- | --- | --- | +| `document` + `checkable: true` | Decidable by looking at a schema or instance | Add a check, as below | +| `document`, not `checkable` | Binds documents but needs human judgement | Nothing; it stays listed as unchecked | +| `implementation` | Constrains what the library *does*, which no validator can see | A test against the library, not a `RuleCheck` | +| `advisory` | Guidance only | Nothing | + +To add a check, write the predicate and append a `RuleCheck` to `RULE_CHECKS` in +`src/oold/validation/rule_checks.py`, alongside the existing entries: + +```python +def _missing_id(schema: dict[str, Any], context: ContextView) -> list[str]: + if not schema.get("$id"): + return ["schema declares no $id, so it has no global identifier"] + return [] + + +RULE_CHECKS = [ + RuleCheck("rule.id", "OOLD-VER-001", "a schema has a $id", _missing_id), + ... +] +``` + +The four fields are the check id, the rule it enforces, a short description, and the predicate. +Use a `rule.*` check id: `lint.*`, `schema.*` and `roundtrip.*` are the checks carried over from +the reference harness, and several of them already cite a rule. + +The predicate returns a list of problem strings, empty when the schema conforms. Three things +about it are easy to get wrong: + +- **Judge the resolved context, not the literal one.** `ContextView` is what the term definitions + mean after remote contexts and prefixes are applied. Reading `schema["@context"]` directly will + report violations for schemas that are perfectly correct. +- **Do not set a severity.** It comes from the rule's own `level` in the catalogue, so a `MUST` + fails and a `SHOULD` warns without the check deciding anything. That is what lets one code base + validate against several specification versions. +- **Prefer skipping to guessing.** A rule absent from the selected version's catalogue is skipped + automatically. If a rule is only partially decidable, check the part you are sure of; a false + positive costs far more than a missed finding, because it teaches people to ignore the output. + +Then add tests to `tests/test_validation/test_rule_checks.py` - one schema that conforms and one +that violates. A check that only ever sees valid input is not known to fire at all. + +Finally, confirm the gap actually closed: + +```bash +uv run oold rules list --unchecked # the rule should be gone from this list +make validate # coverage.rules reports one fewer unchecked rule +``` + +`coverage.rules` warns rather than fails, deliberately: the specification and this validator +release on separate schedules, and a spec that has moved ahead should not break this build. + ## Commit messages (Conventional Commits) This project uses [Conventional Commits](https://www.conventionalcommits.org/). From 4fdda5e796089c4950437918d212c802b4e2e8b4 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 4 Aug 2026 16:28:04 +0200 Subject: [PATCH 08/29] docs: add CLAUDE.md with the conventions agents keep getting wrong - Documents that verdicts are pinned by parity with the reference harness - Documents that severity is read from the specification catalogue, not decided by a check - Documents that vendored meta-schemas are checksummed bytes no formatter or line-ending conversion may touch - AGENTS.md stays ignored --- .gitignore | 1 - CLAUDE.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 7a68b30..944846d 100644 --- a/.gitignore +++ b/.gitignore @@ -224,6 +224,5 @@ benchmark_comparison.txt */osw_files/* # Local -CLAUDE.md AGENTS.md .ign diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..51013f7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# Working in this repository + +Guidance for AI agents. Human contributors want `CONTRIBUTING.md`, which this file does not repeat. + +## Commands + +```bash +make check # lint, type-check, dependency audit +make test # pytest with coverage +make validate # run the validator over the committed fixtures +make docs-test # strict docs build, fails on any warning + +OOLD_SCHEMA_DIR=../oold-schema uv run pytest -m parity # compare against the reference harness +``` + +The parity tests skip silently without `OOLD_SCHEMA_DIR`, so a green `make test` does not mean +parity holds. Run them explicitly when touching `src/oold/validation/`. + +## The validation subsystem + +`src/oold/validation/` is a native Python port of `oold-schema/scripts/validate.mjs`, deliberately +not a subprocess wrapper. The reference harness is still the oracle: the parity tests assert this +port reaches the same verdicts on the same fixtures, including check labels and triple counts. + +**Changes here must not change verdicts unless that is the point of the change.** Reporting, +wording and detail payloads are free to move; a schema that passed must still pass. If parity +drops, treat it as a defect in this port until proven otherwise. Upstream has been wrong before, +but that is the rarer case. + +### Rules come from the specification, not from this code + +The OO-LD spec numbers its normative statements (`OOLD-RT-002`) and publishes them as +`oold-rules.json`, vendored per version under `src/oold/validation/meta//`. Three +consequences that are easy to get wrong: + +- **Severity is read, never written.** A check reports a problem; whether that is a failure or a + warning comes from the rule's `level` in the catalogue. Do not reintroduce a hardcoded + FAIL/WARN column. This is what lets one code base validate against several spec versions. +- **Skip rather than guess.** A rule absent from the selected version's catalogue, or marked + deprecated there, is skipped with a message saying so. Older versions ship no catalogue at all + and skip the whole `rule.*` family. Never fall back to "check it anyway". +- **Judge the resolved context.** Checks receive a `ContextView`, which is what terms mean after + remote contexts and prefixes are applied. Reading `schema["@context"]` directly reports + violations against correct schemas. + +A false positive costs far more than a missed finding, because it teaches people to ignore the +output. When a rule is only partly decidable, check the part you are sure of. + +### Vendored meta-schemas are byte-exact + +`src/oold/validation/meta//` holds verbatim copies from oold-schema release tags, and +`index.json` records a sha256 of each. They are therefore not ordinary source files: + +- never reformat them, and never let a formatting hook touch them (`.pre-commit-config.yaml` + excludes these paths, `.gitattributes` marks them `-text`); +- they must be LF. A CRLF copy hashes differently, which passes on Windows and fails on Linux. + This has happened; `test_the_vendored_files_are_stored_with_unix_line_endings` now guards it; +- to add a version, follow `src/oold/validation/meta/README.md` and recompute the digests. + +## This repo and oold-schema are decoupled on purpose + +They release on separate schedules, so neither pipeline waits on the other: + +- `coverage.rules` **warns** when a rule has no check, rather than failing. A spec that has moved + ahead must not break this build. +- Adding a check for a rule is described in `CONTRIBUTING.md#translating-a-specification-rule`. + oold-schema's `make check` prints that link when the catalogue changes. + +Do not add a check for a rule that is not in any vendored catalogue. Vendor the version first. + +## Check ids are a public interface + +Check ids (`lint.container`, `roundtrip.instance`, `rule.id-fragment`) appear in reports, CI logs +and, before long, in suppression comments. Renaming one silently breaks whatever depended on it, +and unlike rule ids there is no guard. Treat a rename as a breaking change: say so in the commit, +and prefer adding a new id over repurposing an existing one. + +## Conventions + +- Conventional Commits; releases are automated by python-semantic-release, so the type prefix + decides the version bump. +- No AI attribution or co-author trailers in commits or PR descriptions. +- In prose and comments, use regular dashes rather than em or en dashes. +- Do not create scratch files inside this repository or in `../oold-schema`. To see what a file + looks like on a clean checkout, read git state (`git show :path`, `git check-attr`) instead of + deleting and restoring it. From 26ceba9c63a9170baaa376c63b039ebfa666a100 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Tue, 4 Aug 2026 17:41:14 +0200 Subject: [PATCH 09/29] docs(spec): design a check registry and an `oold checks` command - Rule ids answer which requirement was violated and are permanent; check ids answer which check found it and follow the implementation - Replaces an earlier "frozen inventory" proposal that hand-synced a second file of ids - Verified against what the validator actually emits, and holds function references rather than path strings - Kept out of docs/, since an unlisted page there is still built and published --- specs/2026-08-04-check-registry-design.md | 218 ++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 specs/2026-08-04-check-registry-design.md diff --git a/specs/2026-08-04-check-registry-design.md b/specs/2026-08-04-check-registry-design.md new file mode 100644 index 0000000..ee30379 --- /dev/null +++ b/specs/2026-08-04-check-registry-design.md @@ -0,0 +1,218 @@ +# A registry for check ids, and `oold checks` + +Status: proposed. Supersedes the "frozen inventory" idea discussed on 2026-08-04 and not built. + +## The problem + +A finding names the check that produced it: + +``` +FAIL OOLD-RT-002 lint.container array_without_container.schema.json: strict array property + without @container @set/@list: tags +``` + +Two questions have no good answer today. + +**Where is the code that decided this?** A check is implemented in two places. The detection lives +in a concern module, the id and the message live in `pipeline.py`: + +| Part of `lint.container` | Location | +| --- | --- | +| Detection | `pattern_lint.array_properties_missing_container` | +| Id, severity, message | `pipeline.py:224` | + +Grepping `lint.container` finds only the second. The function that actually decides whether the +schema is wrong has a different name, in a different module, and nothing links the two. + +**What checks exist at all?** The set of check ids is an emergent property of the code. Nothing +states it, so it cannot be listed, documented, or verified. Ten of the twenty-four ids enforce no +specification rule, so for those the check id is the only identifier a user has. + +## What was settled first + +This design follows a decision about identifiers that is worth recording, because it constrains +everything below. + +The validator emits two kinds of identifier, and they are **not** peers: + +* **Rule ids** (`OOLD-RT-002`) answer *which requirement was violated*. They are owned by the + specification, permanent, and already guarded in oold-schema by `rules_baseline.py`. This is + what belongs in a review comment or a changelog. +* **Check ids** (`lint.container`) answer *which check found it*. They are owned by this + repository and follow the implementation. + +Both are needed, for two reasons that are easy to miss: + +1. **Ten of the twenty-four checks have no rule and never will.** `schema.meta` is definitional: + validating against the meta-schema is what *being* an OO-LD schema means, not a numbered + requirement inside it. `generate.satisfiable`, `variants` and `roundtrip.*` are this + validator's methodology, which the specification does not mandate. `coverage.*` are self-tests + about the fixture suite. Minting rule ids for these would push one tool's implementation + strategy into the specification. +2. **Rule ids are not always available.** Validating the same schema against 0.7.0, which ships no + catalogue, produces `FAIL lint.container` with no rule id at all. The check id is the + identifier that survives every specification version. + +Consequently check ids are **implementation-defined**: durable citations should use the rule id. +Two things follow, and neither is built here: no append-only policy for check ids, and no +stability guard. An earlier proposal for a committed list of ids compared by a test was rejected, +correctly, as a second hand-synced bookkeeping file duplicating information already in the code. + +## Design + +A registry of check metadata, and a CLI to read it. + +### The record + +```python +@dataclass(frozen=True) +class CheckInfo: + id: str # "lint.container" + summary: str # one line: what it verifies + rule: str | None # the OO-LD rule it enforces, when there is one + default_status: Status # FAIL or WARN when it reports a problem + per_version: bool # emits once per selected meta-schema version + detects: Callable | None # the function implementing detection +``` + +Three fields deserve comment. + +`detects` holds **the function object, never a string path**. This is the difference between +metadata that rots and metadata that cannot. A hand-typed `"pattern_lint.array_properties_..."` +goes stale the moment anyone renames the function, silently. A reference either follows the rename +or fails to import. The displayed location is derived from it with `inspect`, so it is computed +rather than maintained. Where a check has no single detection site, `detects` is `None` and the +CLI says so rather than pointing somewhere misleading. + +`per_version` records the fan-out that already exists: `schema.meta` and `lint.pattern` run once +per selected meta-schema version, so one id can produce several report lines. + +`default_status` documents severity for checks that have no rule. For checks that do have one, +severity comes from the rule's `level` in the catalogue, and this field records only what happens +when no catalogue applies. + +### Where entries come from + +The registry has 24 entries and **10 of them are generated**, not authored: + +* the `rule.*` family already exists as `RULE_CHECKS` in `rule_checks.py`, whose entries carry + `check_id`, `rule`, `describe` and the predicate. `CheckInfo` records are derived from them + directly, so adding a rule check keeps requiring exactly one edit, in one place; +* the remaining 14 phase checks are authored, one line each, next to the existing `CHECK_RULES` + table in `pipeline.py` that this replaces. + +`CHECK_RULES` and `RULE_CHECK_MAP` become views over the registry rather than separate tables, so +the check-to-rule mapping stops existing in two places. + +### The command + +Mirrors `oold rules`, which already exists, so there is one idiom to learn: + +``` +oold checks list [--prefix lint.] [--unmapped] +oold checks explain lint.container +``` + +``` +lint.container FAIL by default + A strictly array-typed property must declare @container @set or @list. + + rule OOLD-RT-002 (stated by 1.0.0-rc.1; absent from 0.7.0, 0.8.0) + detected pattern_lint.array_properties_missing_container (pattern_lint.py:112) + reported pipeline.py, search for "lint.container" + per version no +``` + +`--unmapped` lists the checks that enforce no rule, which is the mirror image of +`oold rules list --unchecked` and makes the boundary between the two identifier systems visible. + +## Why this will not drift + +The registry is hand-*written*, which is unavoidable: a one-line description of what a check does +exists nowhere else, so writing it down creates no second copy. It is not hand-*synced*, which is +what rots. Three tests hold it to reality, all using the existing fixture corpus: + +1. **Every id emitted during the suite is in the registry.** Adding a check without registering it + fails, naming the id. +2. **Every registry entry is emitted at least once by the suite.** This catches a stale entry for + a check that was removed, and, usefully, a check that silently stopped running. +3. **Every non-null `rule` exists in at least one vendored catalogue.** Catches a typo'd or + retired rule id. + +Test 2 is the one that makes this different from the rejected inventory. That file would only ever +have been compared against itself; this is compared against what the validator actually does. + +## Explicitly out of scope + +* **No change to verdicts.** Parity with the reference harness must hold unchanged. This adds + metadata and a read-only command. +* **No change to the JSON report shape.** Consumers are unaffected. +* **No restructuring of `pipeline.py`.** See below. +* **No stability guard or append-only policy for check ids**, per the decision recorded above. + +## Relationship to per-check functions + +The tempting larger version, one function per check id named to match (`lint.container` in +`lint.py` as `container()`, the ESLint layout), is **not** attempted, and the reason is specific +rather than general caution. Three properties of the current pipeline resist a flat registry of +predicates: + +* **Checks short-circuit.** `pipeline.py:194` and `:198` return early when `$ref`s do not resolve, + because linting a schema that could not be assembled produces noise. ESLint rules are mutually + independent; these are stages in a dependency chain. +* **One id fans out.** `schema.meta` and `lint.pattern` emit per meta version, `variants` per + composition variant. +* **One id emits several statuses from several sites.** `roundtrip.generated` reports SKIP for a + cyclic context, FAIL for a processing error, FAIL for a shape mismatch, and OK, from five + places. A predicate returning a list of problems cannot express a skip. + +`rule_checks.py` works as a flat registry precisely because its ten checks are independent +predicates over an already-resolved `ContextView`; the phase checks are the work that produces it. +Adopting that shape everywhere therefore means designing an explicit dependency graph and a +fan-out mechanism, which is a redesign of execution semantics on code pinned by parity. + +This design does not foreclose it. The registry is where such a migration would happen: entries +gain a `run=` callable one at a time, and phase functions shrink as checks move out. The ten +`rule.*` checks are already in that end state, which is evidence the target shape works here. + +## Files + +| File | Change | +| --- | --- | +| `src/oold/validation/registry.py` | New. `CheckInfo`, the 14 authored entries, derivation of the 10 from `RULE_CHECKS`, lookup helpers | +| `src/oold/validation/pipeline.py` | `CHECK_RULES` becomes a view over the registry | +| `src/oold/validation/cli.py` | `oold checks list` / `oold checks explain` | +| `tests/test_validation/test_registry.py` | New. The three drift tests | +| `docs/how-to/validation.md` | Document the two identifier systems and the new command | +| `CONTRIBUTING.md` | Registering a check, in the existing rule-translation section | +| `CLAUDE.md` | Correct the "check ids are a public interface" section, which overstates the case and predates this decision | + +## Verification + +```bash +uv run pytest tests/test_validation -q +uv run oold checks list +uv run oold checks explain lint.container +uv run oold checks list --unmapped # expect the 10 rule-less checks + +make validate # verdicts unchanged +OOLD_SCHEMA_DIR=../oold-schema uv run pytest -m parity # must stay 6/6 +``` + +The parity run is the load-bearing one: this change must be invisible to verdicts. + +Then confirm the drift tests actually fail, rather than trusting them: add a check id without +registering it, and delete a registry entry for a live check. Both must fail naming the id. + +## Risks + +**Descriptions decay quietly.** The tests check that ids and rules line up; no test can tell +whether a `summary` still describes what the code does. Mitigated only by keeping summaries short +and reviewing them when the check changes. + +**`detects` is a judgement call for multi-site checks.** Pointing `roundtrip.generated` at one of +its five emission sites would mislead. The design allows `None` for exactly this, and the CLI must +say "no single detection site" rather than guess. + +**Scope creep toward per-check functions.** The registry makes that refactor look easy. It is not, +for the reasons above, and it should be a separate decision with its own spec. From 264d71e300095719cdfe52c86726fb5bb64888e1 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 5 Aug 2026 11:05:09 +0200 Subject: [PATCH 10/29] docs(spec): collapse the check mappings into one registry structure - Deletes RULE_CHECKS, RULE_CHECK_MAP and CHECK_RULES in favor of one registry - CheckInfo absorbs RuleCheck by gaining an optional `run` predicate - rule_checks.py folds into check_registry.py - Drops the emitting site from `oold checks explain` --- specs/2026-08-04-check-registry-design.md | 80 +++++++++++++++++------ 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/specs/2026-08-04-check-registry-design.md b/specs/2026-08-04-check-registry-design.md index ee30379..1bf5ef2 100644 --- a/specs/2026-08-04-check-registry-design.md +++ b/specs/2026-08-04-check-registry-design.md @@ -69,13 +69,14 @@ A registry of check metadata, and a CLI to read it. class CheckInfo: id: str # "lint.container" summary: str # one line: what it verifies - rule: str | None # the OO-LD rule it enforces, when there is one - default_status: Status # FAIL or WARN when it reports a problem - per_version: bool # emits once per selected meta-schema version - detects: Callable | None # the function implementing detection + rule: str | None = None # the OO-LD rule it enforces, when there is one + default_status: Status = FAIL # status when it reports a problem and no rule applies + per_version: bool = False # emits once per selected meta-schema version + detects: Callable | None = None # the function implementing detection + run: Predicate | None = None # executable predicate, for self-contained checks ``` -Three fields deserve comment. +Four fields deserve comment. `detects` holds **the function object, never a string path**. This is the difference between metadata that rots and metadata that cannot. A hand-typed `"pattern_lint.array_properties_..."` @@ -91,18 +92,45 @@ per selected meta-schema version, so one id can produce several report lines. severity comes from the rule's `level` in the catalogue, and this field records only what happens when no catalogue applies. -### Where entries come from +`run` is what lets this be **one** structure rather than two. The ten `rule.*` checks are +self-contained predicates over an already-resolved context, so the registry can execute them +directly. The other fourteen are driven by the pipeline phases and leave `run` empty. Where `run` +is set it is also the detection site, so `detects` defaults to it and is never written twice. -The registry has 24 entries and **10 of them are generated**, not authored: +### One structure, not four -* the `rule.*` family already exists as `RULE_CHECKS` in `rule_checks.py`, whose entries carry - `check_id`, `rule`, `describe` and the predicate. `CheckInfo` records are derived from them - directly, so adding a rule check keeps requiring exactly one edit, in one place; -* the remaining 14 phase checks are authored, one line each, next to the existing `CHECK_RULES` - table in `pipeline.py` that this replaces. +Today the same information is spread across four places: -`CHECK_RULES` and `RULE_CHECK_MAP` become views over the registry rather than separate tables, so -the check-to-rule mapping stops existing in two places. +| Today | Holds | Fate | +| --- | --- | --- | +| `RuleCheck` (`rule_checks.py`) | id, rule, description, predicate for 10 checks | Absorbed into `CheckInfo` | +| `RULE_CHECKS` | the 10 entries | Becomes `[c for c in CHECKS if c.run]` | +| `RULE_CHECK_MAP` | `{check_id: rule}` for those 10 | Deleted; it is `c.rule` | +| `CHECK_RULES` (`pipeline.py`) | 4 hand-written pairs plus the above | Deleted; it is `c.rule` | + +All four collapse into a single `CHECKS: tuple[CheckInfo, ...]` in `check_registry.py`. There are +no derived mappings to keep in step, because there is nothing to derive from: the rule id is a +field on the check, looked up directly. + +This answers the maintenance objection that motivated the design. Adding a rule check is still one +edit in one place, as it is today, and it now also registers the check for `oold checks` instead +of requiring a second entry somewhere else. + +### Where the code lives + +`check_registry.py` holds `CheckInfo`, `ContextView`, the ten predicates, `CHECKS`, `severity()` +and the driver that executes the runnable entries. `rule_checks.py` is **deleted**; its contents +move here, which is what makes this a single file rather than a registry plus a satellite. + +The fourteen phase checks keep their detection where it already is, in `pattern_lint.py`, +`roundtrip.py`, `context_graph.py` and friends, because those are substantial algorithms rather +than five-line predicates. The registry references them for `detects`. + +The import direction is one-way and verified acyclic: `check_registry` imports the detection +modules, none of which import it or each other in a cycle, and `pipeline` imports +`check_registry`. Expected size is roughly 430 lines, most of it the predicates that already +exist. If that ever feels too large, the predicates can move back out without changing the +structure, since the registry holds references either way. ### The command @@ -119,10 +147,13 @@ lint.container FAIL by default rule OOLD-RT-002 (stated by 1.0.0-rc.1; absent from 0.7.0, 0.8.0) detected pattern_lint.array_properties_missing_container (pattern_lint.py:112) - reported pipeline.py, search for "lint.container" per version no ``` +The emitting site is deliberately not shown. It is findable by searching for the check id, which +already works, and printing it would mean either a second maintained field or a stack walk at +report time. + `--unmapped` lists the checks that enforce no rule, which is the mirror image of `oold rules list --unchecked` and makes the boundary between the two identifier systems visible. @@ -147,7 +178,9 @@ have been compared against itself; this is compared against what the validator a * **No change to verdicts.** Parity with the reference harness must hold unchanged. This adds metadata and a read-only command. * **No change to the JSON report shape.** Consumers are unaffected. -* **No restructuring of `pipeline.py`.** See below. +* **No restructuring of the pipeline phases.** `pipeline.py` loses the `CHECK_RULES` table and + reads the registry instead, but its control flow, phase functions and short-circuits are + untouched. See below for why the tempting larger version is not attempted. * **No stability guard or append-only policy for check ids**, per the decision recorded above. ## Relationship to per-check functions @@ -166,8 +199,8 @@ predicates: cyclic context, FAIL for a processing error, FAIL for a shape mismatch, and OK, from five places. A predicate returning a list of problems cannot express a skip. -`rule_checks.py` works as a flat registry precisely because its ten checks are independent -predicates over an already-resolved `ContextView`; the phase checks are the work that produces it. +The ten `rule.*` checks work as a flat registry precisely because they are independent predicates +over an already-resolved `ContextView`; the phase checks are the work that produces it. Adopting that shape everywhere therefore means designing an explicit dependency graph and a fan-out mechanism, which is a redesign of execution semantics on code pinned by parity. @@ -179,10 +212,12 @@ gain a `run=` callable one at a time, and phase functions shrink as checks move | File | Change | | --- | --- | -| `src/oold/validation/registry.py` | New. `CheckInfo`, the 14 authored entries, derivation of the 10 from `RULE_CHECKS`, lookup helpers | -| `src/oold/validation/pipeline.py` | `CHECK_RULES` becomes a view over the registry | +| `src/oold/validation/check_registry.py` | New. `CheckInfo`, `ContextView`, the ten predicates, all 24 `CHECKS` entries, `severity()`, the driver, lookup helpers | +| `src/oold/validation/rule_checks.py` | **Deleted.** Contents move into `check_registry.py` | +| `src/oold/validation/pipeline.py` | `CHECK_RULES` deleted; reads `CheckInfo.rule` directly | | `src/oold/validation/cli.py` | `oold checks list` / `oold checks explain` | -| `tests/test_validation/test_registry.py` | New. The three drift tests | +| `tests/test_validation/test_rule_checks.py` | Imports move to `check_registry`; otherwise unchanged, so the ten predicates keep their existing coverage | +| `tests/test_validation/test_check_registry.py` | New. The three drift tests | | `docs/how-to/validation.md` | Document the two identifier systems and the new command | | `CONTRIBUTING.md` | Registering a check, in the existing rule-translation section | | `CLAUDE.md` | Correct the "check ids are a public interface" section, which overstates the case and predates this decision | @@ -204,6 +239,9 @@ The parity run is the load-bearing one: this change must be invisible to verdict Then confirm the drift tests actually fail, rather than trusting them: add a check id without registering it, and delete a registry entry for a live check. Both must fail naming the id. +Because `rule_checks.py` disappears, `git grep -n 'rule_checks\|RULE_CHECKS\|RULE_CHECK_MAP\|CHECK_RULES'` +must come back empty when the change is done. Any survivor is a mapping that was meant to die. + ## Risks **Descriptions decay quietly.** The tests check that ids and rules line up; no test can tell From 3ba00a6ddff0aa3c739f6739ae7b76b1b11ffda2 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 5 Aug 2026 11:14:13 +0200 Subject: [PATCH 11/29] docs(spec): pin where compatibility for a new rule's check lives - Gating is by presence in the vendored catalogue; a new rule's check needs no backward-compatibility code - Four phase checks carry a rule id but were not gated by anything - New `requires_rule` flag extends catalogue gating to any check, defaulting to off - `since` cannot serve this purpose: all 34 rules carry since=1.0.0-rc.1, when the catalogue was minted - A fourth drift test pins the promise --- specs/2026-08-04-check-registry-design.md | 48 ++++++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/specs/2026-08-04-check-registry-design.md b/specs/2026-08-04-check-registry-design.md index 1bf5ef2..f1e4698 100644 --- a/specs/2026-08-04-check-registry-design.md +++ b/specs/2026-08-04-check-registry-design.md @@ -74,6 +74,7 @@ class CheckInfo: per_version: bool = False # emits once per selected meta-schema version detects: Callable | None = None # the function implementing detection run: Predicate | None = None # executable predicate, for self-contained checks + requires_rule: bool = False # skip where the version's catalogue omits `rule` ``` Four fields deserve comment. @@ -132,6 +133,46 @@ modules, none of which import it or each other in a cycle, and `pipeline` import exist. If that ever feels too large, the predicates can move back out without changing the structure, since the registry holds references either way. +### Versions: what runs against which specification + +A new specification version states a new rule, the rule needs a check, and that check must not +fire when validating against an older version that never required it. The information that decides +this lives in the **vendored catalogue**, not in the code, and not in a version number written +into a check. + +Gating is by presence: if the selected version's `oold-rules.json` does not list the rule, the +check is skipped with a message saying so. This is already how the ten `rule.*` checks behave, and +it means adding a check for a new rule needs **no backward-compatibility code at all**. Older +versions skip it because their catalogue does not mention it. + +One field extends that to every check: + +```python +requires_rule: bool = False +``` + +| Value | Meaning | Used by | +| --- | --- | --- | +| `True` | Run only where the catalogue states `rule` and has not deprecated it. Skip otherwise, including when the version ships no catalogue | Every check written for a catalogued rule, which is every new one | +| `False` | Run against all versions; `rule` is attribution only | The checks that predate the catalogue and encode long-standing requirements | + +`False` is the default because it preserves today's behaviour exactly: `lint.container` currently +runs against 0.7.0 and 0.8.0, which ship no catalogue, and must keep doing so. Without the flag, +uniform gating would silently stop checking those versions for requirements they do have. + +That asymmetry is not arbitrary. It exists because **`since` cannot answer this question.** All 34 +rules in the 1.0.0-rc.1 catalogue carry `since: 1.0.0-rc.1`, since that is when the catalogue was +minted rather than when the requirements appeared. So there is no machine-readable record of what +0.7.0 or 0.8.0 required, and the only safe reading for a pre-catalogue version is that +long-standing checks apply and newly-catalogued ones cannot be attributed. Any design that gated +on `since` would be wrong for exactly the two versions currently shipped. + +The lifecycle follows from the same mechanism, with no special handling. When a requirement +changes meaning, oold-schema deprecates the old rule id and mints a new one. The existing check +then skips automatically wherever the catalogue marks its rule deprecated, and a new check is +added with `requires_rule=True` for the new id. Both live in the registry at once, and validating +against an older version keeps using the old one. Nothing needs to know which version is "current". + ### The command Mirrors `oold rules`, which already exists, so there is one idiom to learn: @@ -161,7 +202,7 @@ report time. The registry is hand-*written*, which is unavoidable: a one-line description of what a check does exists nowhere else, so writing it down creates no second copy. It is not hand-*synced*, which is -what rots. Three tests hold it to reality, all using the existing fixture corpus: +what rots. Four tests hold it to reality, all using the existing fixture corpus: 1. **Every id emitted during the suite is in the registry.** Adding a check without registering it fails, naming the id. @@ -169,6 +210,9 @@ what rots. Three tests hold it to reality, all using the existing fixture corpus a check that was removed, and, usefully, a check that silently stopped running. 3. **Every non-null `rule` exists in at least one vendored catalogue.** Catches a typo'd or retired rule id. +4. **Every `requires_rule=True` check is skipped under `--meta 0.7.0`.** Pins the backward + compatibility promise to a test rather than to reviewer memory. 0.7.0 ships no catalogue, so a + gated check must skip there; if one runs, its gating is wrong. Test 2 is the one that makes this different from the rejected inventory. That file would only ever have been compared against itself; this is compared against what the validator actually does. @@ -217,7 +261,7 @@ gain a `run=` callable one at a time, and phase functions shrink as checks move | `src/oold/validation/pipeline.py` | `CHECK_RULES` deleted; reads `CheckInfo.rule` directly | | `src/oold/validation/cli.py` | `oold checks list` / `oold checks explain` | | `tests/test_validation/test_rule_checks.py` | Imports move to `check_registry`; otherwise unchanged, so the ten predicates keep their existing coverage | -| `tests/test_validation/test_check_registry.py` | New. The three drift tests | +| `tests/test_validation/test_check_registry.py` | New. The four drift tests | | `docs/how-to/validation.md` | Document the two identifier systems and the new command | | `CONTRIBUTING.md` | Registering a check, in the existing rule-translation section | | `CLAUDE.md` | Correct the "check ids are a public interface" section, which overstates the case and predates this decision | From 08b9945ea338c3a261eb08d28e82b2d2b7a062ea Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 5 Aug 2026 11:26:33 +0200 Subject: [PATCH 12/29] docs(spec): correct the version gate, and cost out a changed rule - `requires_rule` is insufficient for a legacy check whose rule is later superseded - Replaced with `predates_catalog`, which asks what a pre-catalogue version cannot answer - A rewording changes nothing, a data-driven change (0.8.0's no-coercion rule) is free, only bespoke detection needs a new predicate - Marginal cost of a changed rule is one registry line --- specs/2026-08-04-check-registry-design.md | 64 +++++++++++++++++------ 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/specs/2026-08-04-check-registry-design.md b/specs/2026-08-04-check-registry-design.md index f1e4698..08bcf95 100644 --- a/specs/2026-08-04-check-registry-design.md +++ b/specs/2026-08-04-check-registry-design.md @@ -74,7 +74,7 @@ class CheckInfo: per_version: bool = False # emits once per selected meta-schema version detects: Callable | None = None # the function implementing detection run: Predicate | None = None # executable predicate, for self-contained checks - requires_rule: bool = False # skip where the version's catalogue omits `rule` + predates_catalog: bool = False # the requirement is older than the catalogue ``` Four fields deserve comment. @@ -145,20 +145,29 @@ check is skipped with a message saying so. This is already how the ten `rule.*` it means adding a check for a new rule needs **no backward-compatibility code at all**. Older versions skip it because their catalogue does not mention it. -One field extends that to every check: +One field extends that to every check that names a rule: ```python -requires_rule: bool = False +predates_catalog: bool = False ``` -| Value | Meaning | Used by | -| --- | --- | --- | -| `True` | Run only where the catalogue states `rule` and has not deprecated it. Skip otherwise, including when the version ships no catalogue | Every check written for a catalogued rule, which is every new one | -| `False` | Run against all versions; `rule` is attribution only | The checks that predate the catalogue and encode long-standing requirements | +| Value | No catalogue (0.7.0, 0.8.0) | Catalogue present | Used by | +| --- | --- | --- | --- | +| `False` (default) | Skip | Run only if stated and not deprecated | Every new check | +| `True` | Run | Run only if stated and not deprecated | The four checks older than the catalogue | + +The question the flag answers is "did this requirement exist before the catalogue did?", which is +the only thing a pre-catalogue version cannot tell us. Both values gate identically **wherever a +catalogue exists**; they differ only in what to assume where none does. -`False` is the default because it preserves today's behaviour exactly: `lint.container` currently -runs against 0.7.0 and 0.8.0, which ship no catalogue, and must keep doing so. Without the flag, -uniform gating would silently stop checking those versions for requirements they do have. +That matters more than it first appears. A naive "always run" flag for the legacy checks would be +wrong the moment one of their rules is superseded: the old check would keep firing against a new +version that no longer states its requirement. Gating on the catalogue whenever one is present +avoids that, while `True` preserves coverage on the two shipped versions that have none. + +Today's four legacy checks are `lint.pattern`, `lint.container`, `lint.iri-format` and +`context.predicates`. All four rules are present and undeprecated in 1.0.0-rc.1, so `True` +reproduces current behaviour exactly on every tracked version. That asymmetry is not arbitrary. It exists because **`since` cannot answer this question.** All 34 rules in the 1.0.0-rc.1 catalogue carry `since: 1.0.0-rc.1`, since that is when the catalogue was @@ -167,11 +176,31 @@ minted rather than when the requirements appeared. So there is no machine-readab long-standing checks apply and newly-catalogued ones cannot be attributed. Any design that gated on `since` would be wrong for exactly the two versions currently shipped. -The lifecycle follows from the same mechanism, with no special handling. When a requirement -changes meaning, oold-schema deprecates the old rule id and mints a new one. The existing check -then skips automatically wherever the catalogue marks its rule deprecated, and a new check is -added with `requires_rule=True` for the new id. Both live in the registry at once, and validating -against an older version keeps using the old one. Nothing needs to know which version is "current". +### What a changed rule actually costs + +Not every specification change reaches this repository, and the three cases differ a lot. + +**Reworded, same requirement.** The rule id is unchanged by upstream policy, so nothing here +changes: same check, same registry entry, no new id. The work is one `make rules-accept` in +oold-schema, and the vendored catalogue's `text_sha256` moves when the version is next vendored. + +**Changed requirement, detection driven by vendored data.** Often free. The precedent is 0.8.0, +where the no-coercion rule widened from `xsd:string` to every natively-encoded datatype. That +shipped entirely as a new `oold-pattern-lint.schema.json`, and `lint.pattern` picked it up by +vendoring the version. No Python changed, because the check executes the meta-schema rather than +reimplementing it. Anything expressible in the pattern-lint schema lands here. + +**Changed requirement, bespoke detection.** This is the case in the question, and yes: upstream +mints a new rule id, and this repository gains a new predicate and a new registry entry. Both are +necessary rather than ceremonial, because both behaviours have to be available **at the same +time**: validating against the old version must apply the old requirement and the new version the +new one. Editing the check in place would silently change what older versions are judged by, which +is the failure this whole design exists to prevent. + +The marginal cost over simply writing the new logic is **one registry line**. The old entry needs +no edit at all: its rule is now deprecated in the new catalogue, so it self-gates, keeps working +for older versions, and is reported as skipped with the id that superseded it. Nothing needs to +know which version is "current". ### The command @@ -210,9 +239,10 @@ what rots. Four tests hold it to reality, all using the existing fixture corpus: a check that was removed, and, usefully, a check that silently stopped running. 3. **Every non-null `rule` exists in at least one vendored catalogue.** Catches a typo'd or retired rule id. -4. **Every `requires_rule=True` check is skipped under `--meta 0.7.0`.** Pins the backward +4. **Under `--meta 0.7.0`, exactly the `predates_catalog` checks run.** Pins the backward compatibility promise to a test rather than to reviewer memory. 0.7.0 ships no catalogue, so a - gated check must skip there; if one runs, its gating is wrong. + check written for a catalogued rule must skip there, and a legacy one must not. Both directions + fail loudly, which is what stops a new check from quietly judging an old specification. Test 2 is the one that makes this different from the rejected inventory. That file would only ever have been compared against itself; this is compared against what the validator actually does. From 3b497b586ed1e8b13699263b492a27e1acdb1141 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 5 Aug 2026 13:48:00 +0200 Subject: [PATCH 13/29] docs(spec): state which ids the registry covers, and fix the grep guard - Lists the full set of check ids; the previous registry missed six ids the validator actually emits - Fixes the verification command: grepping `rule_checks` also matched `run_rule_checks()` - Records the `compliance.` decision as a single normalised `compliance.*` family entry - Notes `lint.iri-format` has never been observed to fire; drift test 2 should fail on it --- specs/2026-08-04-check-registry-design.md | 42 ++- src/oold/validation/rule_checks.py | 356 ------------------ ..._rule_checks.py => test_check_registry.py} | 0 3 files changed, 40 insertions(+), 358 deletions(-) delete mode 100644 src/oold/validation/rule_checks.py rename tests/test_validation/{test_rule_checks.py => test_check_registry.py} (100%) diff --git a/specs/2026-08-04-check-registry-design.md b/specs/2026-08-04-check-registry-design.md index 08bcf95..8ea270d 100644 --- a/specs/2026-08-04-check-registry-design.md +++ b/specs/2026-08-04-check-registry-design.md @@ -202,6 +202,38 @@ no edit at all: its rule is now deprecated in the new catalogue, so it self-gate for older versions, and is reported as skipped with the id that superseded it. Nothing needs to know which version is "current". +### Which ids the registry must cover + +Every id the validator can emit, with one documented exception. The first draft of this spec left +the set implicit, which was a real defect: it forced the count to be reverse-engineered, and the +resulting registry missed six live ids. + +Two families need explicit treatment: + +**`compliance.` is one family entry, not one entry per kind.** The id is built as +`f"compliance.{case.kind}"`, so it derives from *fixture data* rather than from code: a new case +kind in `examples/compliance/*.json` mints a new id without any change here. Enumerating them +statically would reintroduce exactly the hand-syncing this design exists to remove, and the +registry would go stale silently the next time upstream adds a kind. + +So the registry carries a single entry with id `compliance.*`, and the drift tests normalise any +`compliance.` to it before comparing. This is the only special case, and it is justified by +the id being data-derived. `compliance.suite` is a literal id and gets an ordinary entry. + +**`rule.checks` and `meta.self-check` are ordinary entries.** Both are literal ids that reach +users. `rule.checks` in particular is what a user sees under `--meta 0.7.0`, reporting that the +whole per-rule family was skipped for want of a catalogue. + +### A known gap this surfaces + +`lint.iri-format` is emitted only when it finds a problem, with no `else` branch +(`pipeline.py:222`), and **no fixture triggers it**. It has therefore never been observed to fire. +Drift test 2 will fail on it, correctly, the first time it runs. + +Fixing that means adding a fixture that violates the rule, which is worth doing on its own merits: +an untriggered check is not known to work. Confirm first whether the addition affects the parity +corpus, since the reference harness runs over the same directory. + ### The command Mirrors `oold rules`, which already exists, so there is one idiom to learn: @@ -313,8 +345,14 @@ The parity run is the load-bearing one: this change must be invisible to verdict Then confirm the drift tests actually fail, rather than trusting them: add a check id without registering it, and delete a registry entry for a live check. Both must fail naming the id. -Because `rule_checks.py` disappears, `git grep -n 'rule_checks\|RULE_CHECKS\|RULE_CHECK_MAP\|CHECK_RULES'` -must come back empty when the change is done. Any survivor is a mapping that was meant to die. +The deleted mappings must leave no trace. Match on the symbols themselves, not on the substring +`rule_checks`, which survives legitimately inside the `run_rule_checks()` driver: + +```bash +git grep -nE '\b(RuleCheck|RULE_CHECKS|RULE_CHECK_MAP|CHECK_RULES)\b|from \.rule_checks' +``` + +That must come back empty. Any survivor is a mapping that was meant to die. ## Risks diff --git a/src/oold/validation/rule_checks.py b/src/oold/validation/rule_checks.py deleted file mode 100644 index c9fc14a..0000000 --- a/src/oold/validation/rule_checks.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Checks implementing individual normative rules from the specification catalog. - -The general-workflow checks ported from the reference harness each assert a broad property - -"the schema is well formed", "the instance round-trips". This module holds the narrower checks, -each enforcing exactly one statement in the specification and citing its rule id. - -Keeping them together, declared rather than hand-wired, means the mapping from check to rule is -visible in one place and `coverage.rules` can be trusted: a rule appears as enforced only when a -check here actually implements it. - -Every check is written to avoid false positives in preference to catching every violation. A -validator that cries wolf on valid schemas gets switched off, and an unenforced rule is already -reported honestly by `coverage.rules`. -""" - -from __future__ import annotations - -import re -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import Any - -from .frame import collect_composed_properties, instance_rdf_types -from .report import FAIL, OK, SKIP, WARN, Status - -#: A `$schema` naming the OO-LD dialect, on either canonical domain. The domain moved from -#: oo-ld.github.io to oo-ld.org, and released copies stamp a version in place of `latest`, so the -#: check matches the file name rather than any single URL. -_OOLD_META = re.compile(r"oold-meta-schema\.json$") - - -@dataclass -class RuleFinding: - """One rule's outcome for one schema.""" - - check_id: str - rule: str - status: Status - message: str = "" - detail: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class ContextView: - """A schema's *resolved* context, as the rules need to see it. - - Rules are about what an instance actually experiences, and OO-LD contexts inherit: a schema - whose `@context` is `["Thing.schema.json", {...}]` gets `@version` and the `id` alias from - Thing. Judging such a schema on its own literal `@context` reports violations that are not - real, so every rule here is given the resolved form. - """ - - terms: dict[str, Any] = field(default_factory=dict) - entries: list[Any] = field(default_factory=list) - - def keyword(self, name: str) -> Any: - """The effective value of a context keyword such as ``@version``, or None.""" - for entry in self.entries: - if isinstance(entry, dict) and name in entry: - return entry[name] - return None - - -#: RFC 2119 levels that make a violation a failure. Everything else is advice, so it warns. -_MUST_LEVELS = frozenset({"MUST", "MUST NOT", "SHALL", "SHALL NOT", "REQUIRED"}) - -#: Used only when the meta version in use ships no catalogue to read the level from. -DEFAULT_LEVEL: Status = FAIL - - -def severity(rule: dict[str, Any] | None, fallback: Status = DEFAULT_LEVEL) -> Status: - """How hard a violation of this rule should land, taken from the specification. - - The level is the specification's own, not a taste judgement made here, so relaxing a MUST to - a SHOULD upstream changes the validator's behaviour with no code change. Without a catalogue - there is nothing to read, and the caller's fallback applies. - """ - if not rule: - return fallback - return FAIL if rule.get("level") in _MUST_LEVELS else WARN - - -@dataclass -class RuleCheck: - """A check that enforces exactly one rule.""" - - check_id: str - rule: str - describe: str - run: Callable[[dict[str, Any], ContextView], list[str]] - - def __call__( - self, - schema: dict[str, Any], - context: ContextView, - rule: dict[str, Any] | None = None, - ) -> RuleFinding: - """Apply the check, taking its severity from ``rule`` when a catalogue supplied one.""" - problems = self.run(schema, context) - if not problems: - return RuleFinding(self.check_id, self.rule, OK) - return RuleFinding(self.check_id, self.rule, severity(rule), "; ".join(problems), {"problems": problems}) - - -# ---------------------------------------------------------------------------- individual rules - - -def _missing_id(schema: dict[str, Any], context: ContextView) -> list[str]: - if not schema.get("$id"): - return ["schema declares no $id, so it has no global identifier"] - return [] - - -def _id_has_fragment(schema: dict[str, Any], context: ContextView) -> list[str]: - identifier = schema.get("$id") - if not isinstance(identifier, str) or "#" not in identifier: - return [] - fragment = identifier.split("#", 1)[1] - # An empty fragment is explicitly allowed; only a non-empty one is forbidden. - return [f"$id carries a non-empty fragment: {identifier!r}"] if fragment else [] - - -def _range_uses_ref(schema: dict[str, Any], context: ContextView) -> list[str]: - """`x-oold-range` must reference with `x-oold-ref`, never `$ref`. - - A plain `$ref` inside a range would be eagerly dereferenced by a generic bundler, which for a - cyclic schema graph is exactly the unbounded recursion OO-LD avoids by keeping range - references lazy. - """ - found: list[str] = [] - - def walk(node: Any, path: str) -> None: - if isinstance(node, list): - for index, item in enumerate(node): - walk(item, f"{path}[{index}]") - return - if not isinstance(node, dict): - return - if "$ref" in node: - found.append(f"{path} uses $ref; x-oold-range must use x-oold-ref") - for key, value in node.items(): - if key != "$ref": - walk(value, f"{path}/{key}") - - for name, prop in collect_composed_properties(schema).items(): - if isinstance(prop, dict) and isinstance(prop.get("x-oold-range"), (dict, list)): - walk(prop["x-oold-range"], f"properties/{name}/x-oold-range") - return found - - -def _inline_type_disagrees(schema: dict[str, Any], context: ContextView) -> list[str]: - """An inline `type` must agree with the schema's declared `x-oold-instance-rdf-type`. - - Only a *pinned* type is checked - `const`, `default`, or a single-entry `enum`. An open - `type: string` says nothing about what instances will carry, so it cannot disagree. - """ - declared = instance_rdf_types(schema) - if not declared: - return [] - prop = collect_composed_properties(schema).get("type") - if not isinstance(prop, dict): - return [] - - pinned: list[Any] = [] - for key in ("const", "default"): - if key in prop: - pinned = prop[key] if isinstance(prop[key], list) else [prop[key]] - break - else: - enum = prop.get("enum") - if isinstance(enum, list) and len(enum) == 1: - pinned = enum if not isinstance(enum[0], list) else enum[0] - - if not pinned: - return [] - stray = [t for t in pinned if t not in declared] - if stray: - return [f"the type property pins {stray!r}, which is absent from x-oold-instance-rdf-type {declared!r}"] - return [] - - -def _free_text_range_coerced_to_iri(schema: dict[str, Any], context: ContextView) -> list[str]: - """A property whose range includes free text must not use ``@type: "@id"``. - - ``@type: "@id"`` coerces *every* string to an IRI, so free text becomes an often invalid IRI - and is dropped. The property is only flagged when its own schema clearly admits a bare string - alongside a non-string form, which is what "the range includes free text" means. - """ - problems = [] - for name, prop in collect_composed_properties(schema).items(): - definition = context.terms.get(name) - if not isinstance(definition, dict) or definition.get("@type") != "@id": - continue - if _admits_free_text(prop): - problems.append( - f"{name!r} admits a bare string but its term coerces every value with " - '@type: "@id", so free text becomes an invalid IRI' - ) - return problems - - -def _admits_free_text(prop: Any) -> bool: - """True when a property permits a plain string *and* some other shape. - - A reference typed only as a string is the ordinary bare-IRI form and is correct; the - violation is a property that mixes free text with references or embedded objects. - """ - if not isinstance(prop, dict): - return False - for keyword in ("anyOf", "oneOf"): - branches = prop.get(keyword) - if not isinstance(branches, list) or len(branches) < 2: - continue - kinds = {b.get("type") for b in branches if isinstance(b, dict)} - # A string branch with no `format` and no `x-oold-range` is free text rather than an IRI. - text = any( - isinstance(b, dict) and b.get("type") == "string" and not b.get("format") and "x-oold-range" not in b - for b in branches - ) - if text and kinds - {"string"}: - return True - return False - - -def _closed_object_rejects_metadata(schema: dict[str, Any], context: ContextView) -> list[str]: - """A schema closing its objects must still permit `$schema` and `@context`. - - An instance carries both as ordinary members, so a schema with - ``additionalProperties: false`` that does not declare them rejects its own conforming - instances. - """ - closed = schema.get("additionalProperties") is False or schema.get("unevaluatedProperties") is False - if not closed: - return [] - declared = set(collect_composed_properties(schema)) - missing = [key for key in ("$schema", "@context") if key not in declared] - if missing: - return [ - "the schema closes its objects but does not declare " - + ", ".join(missing) - + ", so a conforming instance carrying them would be rejected" - ] - return [] - - -def _missing_version(schema: dict[str, Any], context: ContextView) -> list[str]: - if not schema.get("x-oold-version"): - return ["schema declares no x-oold-version"] - return [] - - -def _id_not_aliased(schema: dict[str, Any], context: ContextView) -> list[str]: - """`@id` should be reachable through a variable-name-friendly alias.""" - if not context.terms: - return [] - aliases = [t for t, d in context.terms.items() if (d.get("@id") if isinstance(d, dict) else d) == "@id"] - if not aliases: - return ["no @context term aliases @id, so instances must use the @id key directly"] - return [] - - -def _dialect_not_declared(schema: dict[str, Any], context: ContextView) -> list[str]: - declared = schema.get("$schema") - if not isinstance(declared, str) or not _OOLD_META.search(declared): - return [f"$schema is {declared!r}, not the OO-LD dialect meta-schema"] - return [] - - -def _processing_mode_not_declared(schema: dict[str, Any], context: ContextView) -> list[str]: - """`@version` must be the JSON number 1.1, not the string "1.1".""" - value = context.keyword("@version") - if value is None: - return ['the resolved @context declares no "@version": 1.1'] - if value == 1.1 and not isinstance(value, str): - return [] - return [f"@version is {value!r}; it must be the JSON number 1.1, not a string"] - - -# ---------------------------------------------------------------------------- registry - -#: Every rule this package enforces beyond the ported general-workflow checks. Order is the order -#: findings are reported in. -RULE_CHECKS: list[RuleCheck] = [ - RuleCheck("rule.id", "OOLD-VER-001", "a schema has a $id", _missing_id), - RuleCheck("rule.id-fragment", "OOLD-CMP-005", "a $id has no non-empty fragment", _id_has_fragment), - RuleCheck("rule.range-ref", "OOLD-EXT-005", "x-oold-range references use x-oold-ref", _range_uses_ref), - RuleCheck( - "rule.instance-type", - "OOLD-INS-002", - "a pinned type agrees with x-oold-instance-rdf-type", - _inline_type_disagrees, - ), - RuleCheck( - "rule.free-text-iri", - "OOLD-INS-009", - "a free-text range is not coerced to @id", - _free_text_range_coerced_to_iri, - ), - RuleCheck( - "rule.closed-object", - "OOLD-INS-005", - "a closed object still permits $schema and @context", - _closed_object_rejects_metadata, - ), - RuleCheck("rule.version", "OOLD-VER-002", "a schema states x-oold-version", _missing_version), - RuleCheck("rule.id-alias", "OOLD-INS-007", "@id is exposed through an alias", _id_not_aliased), - RuleCheck("rule.dialect", "OOLD-EXT-002", "a schema declares the OO-LD dialect", _dialect_not_declared), - RuleCheck("rule.processing-mode", "OOLD-EXT-001", "a context declares @version 1.1", _processing_mode_not_declared), -] - -#: check id -> rule id, for the pipeline's citation mapping and coverage figure. -RULE_CHECK_MAP: dict[str, str] = {c.check_id: c.rule for c in RULE_CHECKS} - - -def run_rule_checks( - schema: dict[str, Any], - context: ContextView, - catalog: dict[str, dict[str, Any]] | None = None, -) -> list[RuleFinding]: - """Apply the rule checks that the selected specification version actually states. - - ``catalog`` maps rule id to its catalogue entry for the meta version in use. When given, a - check whose rule is absent from it is **skipped**: that version never stated the requirement, - and enforcing it would report a violation of something the target does not require. A - deprecated rule is skipped for the same reason from the other end. - - When ``catalog`` is None the version ships no catalogue at all, and the caller decides - whether to run the checks blind or skip them. - """ - findings: list[RuleFinding] = [] - for check in RULE_CHECKS: - rule = (catalog or {}).get(check.rule) - if catalog is not None: - if rule is None: - findings.append( - RuleFinding( - check.check_id, - check.rule, - SKIP, - f"{check.rule} is not stated by this meta-schema version", - ) - ) - continue - if rule.get("deprecated"): - superseded = ", ".join(rule.get("superseded_by") or []) or "nothing" - findings.append( - RuleFinding( - check.check_id, - check.rule, - SKIP, - f"{check.rule} is deprecated in this version (superseded by {superseded})", - ) - ) - continue - findings.append(check(schema, context, rule)) - return findings diff --git a/tests/test_validation/test_rule_checks.py b/tests/test_validation/test_check_registry.py similarity index 100% rename from tests/test_validation/test_rule_checks.py rename to tests/test_validation/test_check_registry.py From 57c1945c7819361d715ef10c6f541b3723fd485c Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 5 Aug 2026 13:48:18 +0200 Subject: [PATCH 14/29] refactor(validation): fold the check mappings into a single registry - RuleCheck, RULE_CHECKS, RULE_CHECK_MAP and CHECK_RULES collapse into `CheckInfo` - rule_checks.py folds into check_registry.py - Purely structural: verdicts, messages and report shape unchanged, confirmed byte-for-byte and by the parity suite - cli.py, mcp_server.py and two test modules move to importing from the registry --- CONTRIBUTING.md | 16 +- src/oold/validation/check_registry.py | 543 +++++++++++++++++++ src/oold/validation/cli.py | 10 +- src/oold/validation/mcp_server.py | 4 +- src/oold/validation/pipeline.py | 28 +- tests/test_validation/test_check_registry.py | 20 +- tests/test_validation/test_parity_live.py | 8 +- tests/test_validation/test_rules.py | 6 +- 8 files changed, 584 insertions(+), 51 deletions(-) create mode 100644 src/oold/validation/check_registry.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45dcde9..53f20a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,11 +90,11 @@ uv run oold rules list --unchecked # everything still waiting for a check | --- | --- | --- | | `document` + `checkable: true` | Decidable by looking at a schema or instance | Add a check, as below | | `document`, not `checkable` | Binds documents but needs human judgement | Nothing; it stays listed as unchecked | -| `implementation` | Constrains what the library *does*, which no validator can see | A test against the library, not a `RuleCheck` | +| `implementation` | Constrains what the library *does*, which no validator can see | A test against the library, not a `CheckInfo` | | `advisory` | Guidance only | Nothing | -To add a check, write the predicate and append a `RuleCheck` to `RULE_CHECKS` in -`src/oold/validation/rule_checks.py`, alongside the existing entries: +To add a check, write the predicate and append a `CheckInfo` to `CHECKS` in +`src/oold/validation/check_registry.py`, alongside the existing entries: ```python def _missing_id(schema: dict[str, Any], context: ContextView) -> list[str]: @@ -103,13 +103,13 @@ def _missing_id(schema: dict[str, Any], context: ContextView) -> list[str]: return [] -RULE_CHECKS = [ - RuleCheck("rule.id", "OOLD-VER-001", "a schema has a $id", _missing_id), +CHECKS = ( + CheckInfo("rule.id", "a schema has a $id", rule="OOLD-VER-001", per_version=True, run=_missing_id), ... -] +) ``` -The four fields are the check id, the rule it enforces, a short description, and the predicate. +The check id, a short description, the rule it enforces, and the predicate are what matter here. Use a `rule.*` check id: `lint.*`, `schema.*` and `roundtrip.*` are the checks carried over from the reference harness, and several of them already cite a rule. @@ -126,7 +126,7 @@ about it are easy to get wrong: automatically. If a rule is only partially decidable, check the part you are sure of; a false positive costs far more than a missed finding, because it teaches people to ignore the output. -Then add tests to `tests/test_validation/test_rule_checks.py` - one schema that conforms and one +Then add tests to `tests/test_validation/test_check_registry.py` - one schema that conforms and one that violates. A check that only ever sees valid input is not known to fire at all. Finally, confirm the gap actually closed: diff --git a/src/oold/validation/check_registry.py b/src/oold/validation/check_registry.py new file mode 100644 index 0000000..6fa04d6 --- /dev/null +++ b/src/oold/validation/check_registry.py @@ -0,0 +1,543 @@ +"""The registry of every check id the validator can emit. + +Two identifier systems appear in a finding: the check id (``lint.container``) names which check +in this package produced it, and the rule id (``OOLD-RT-002``) names the normative statement it +enforces, when there is one. Rule ids come from the specification and are permanent; check ids +are implementation-defined and follow this package's structure. A finding cites both, because ten +of the twenty-four checks enforce no rule at all - `schema.meta` is definitional, +`generate.satisfiable`, `variants` and the `roundtrip.*` checks are this validator's methodology, +and `coverage.*` are self-tests about the fixture suite - and for those the check id is the only +identifier a user has. + +This module holds two things that used to live apart. The ten ``rule.*`` checks each enforce +exactly one normative statement and are narrow enough to be self-contained predicates over an +already-resolved :class:`ContextView`, so they are declared here and executed by +:func:`run_rule_checks`. The other fourteen checks are driven by the phases in ``pipeline.py`` and +leave :attr:`CheckInfo.run` empty; this module only records their metadata; ``detects`` points at +the function that actually decides the verdict, where one function is clearly responsible. + +Every check is written to avoid false positives in preference to catching every violation. A +validator that cries wolf on valid schemas gets switched off, and an unenforced rule is already +reported honestly by ``coverage.rules``. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from .compliance import vocabulary_coverage +from .frame import collect_composed_properties, instance_rdf_types +from .generate import generate +from .instance_checks import roundtrip_instance, validate_instance +from .pattern_lint import array_properties_missing_container, iri_references_missing_format +from .pattern_lint import lint as _lint_pattern +from .predicates import check_predicates +from .report import FAIL, OK, SKIP, WARN, Status +from .schema_checks import check_refs_resolve + +#: A `$schema` naming the OO-LD dialect, on either canonical domain. The domain moved from +#: oo-ld.github.io to oo-ld.org, and released copies stamp a version in place of `latest`, so the +#: check matches the file name rather than any single URL. +_OOLD_META = re.compile(r"oold-meta-schema\.json$") + + +@dataclass +class RuleFinding: + """One rule's outcome for one schema.""" + + check_id: str + rule: str + status: Status + message: str = "" + detail: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ContextView: + """A schema's *resolved* context, as the rules need to see it. + + Rules are about what an instance actually experiences, and OO-LD contexts inherit: a schema + whose `@context` is `["Thing.schema.json", {...}]` gets `@version` and the `id` alias from + Thing. Judging such a schema on its own literal `@context` reports violations that are not + real, so every rule here is given the resolved form. + """ + + terms: dict[str, Any] = field(default_factory=dict) + entries: list[Any] = field(default_factory=list) + + def keyword(self, name: str) -> Any: + """The effective value of a context keyword such as ``@version``, or None.""" + for entry in self.entries: + if isinstance(entry, dict) and name in entry: + return entry[name] + return None + + +#: A self-contained rule check: given a schema and its resolved context, the problems it found. +Predicate = Callable[[dict[str, Any], ContextView], list[str]] + +#: RFC 2119 levels that make a violation a failure. Everything else is advice, so it warns. +_MUST_LEVELS = frozenset({"MUST", "MUST NOT", "SHALL", "SHALL NOT", "REQUIRED"}) + +#: Used only when the meta version in use ships no catalogue to read the level from. +DEFAULT_LEVEL: Status = FAIL + + +def severity(rule: dict[str, Any] | None, fallback: Status = DEFAULT_LEVEL) -> Status: + """How hard a violation of this rule should land, taken from the specification. + + The level is the specification's own, not a taste judgement made here, so relaxing a MUST to + a SHOULD upstream changes the validator's behaviour with no code change. Without a catalogue + there is nothing to read, and the caller's fallback applies. + """ + if not rule: + return fallback + return FAIL if rule.get("level") in _MUST_LEVELS else WARN + + +# ---------------------------------------------------------------------------- individual rules + + +def _missing_id(schema: dict[str, Any], context: ContextView) -> list[str]: + if not schema.get("$id"): + return ["schema declares no $id, so it has no global identifier"] + return [] + + +def _id_has_fragment(schema: dict[str, Any], context: ContextView) -> list[str]: + identifier = schema.get("$id") + if not isinstance(identifier, str) or "#" not in identifier: + return [] + fragment = identifier.split("#", 1)[1] + # An empty fragment is explicitly allowed; only a non-empty one is forbidden. + return [f"$id carries a non-empty fragment: {identifier!r}"] if fragment else [] + + +def _range_uses_ref(schema: dict[str, Any], context: ContextView) -> list[str]: + """`x-oold-range` must reference with `x-oold-ref`, never `$ref`. + + A plain `$ref` inside a range would be eagerly dereferenced by a generic bundler, which for a + cyclic schema graph is exactly the unbounded recursion OO-LD avoids by keeping range + references lazy. + """ + found: list[str] = [] + + def walk(node: Any, path: str) -> None: + if isinstance(node, list): + for index, item in enumerate(node): + walk(item, f"{path}[{index}]") + return + if not isinstance(node, dict): + return + if "$ref" in node: + found.append(f"{path} uses $ref; x-oold-range must use x-oold-ref") + for key, value in node.items(): + if key != "$ref": + walk(value, f"{path}/{key}") + + for name, prop in collect_composed_properties(schema).items(): + if isinstance(prop, dict) and isinstance(prop.get("x-oold-range"), (dict, list)): + walk(prop["x-oold-range"], f"properties/{name}/x-oold-range") + return found + + +def _inline_type_disagrees(schema: dict[str, Any], context: ContextView) -> list[str]: + """An inline `type` must agree with the schema's declared `x-oold-instance-rdf-type`. + + Only a *pinned* type is checked - `const`, `default`, or a single-entry `enum`. An open + `type: string` says nothing about what instances will carry, so it cannot disagree. + """ + declared = instance_rdf_types(schema) + if not declared: + return [] + prop = collect_composed_properties(schema).get("type") + if not isinstance(prop, dict): + return [] + + pinned: list[Any] = [] + for key in ("const", "default"): + if key in prop: + pinned = prop[key] if isinstance(prop[key], list) else [prop[key]] + break + else: + enum = prop.get("enum") + if isinstance(enum, list) and len(enum) == 1: + pinned = enum if not isinstance(enum[0], list) else enum[0] + + if not pinned: + return [] + stray = [t for t in pinned if t not in declared] + if stray: + return [f"the type property pins {stray!r}, which is absent from x-oold-instance-rdf-type {declared!r}"] + return [] + + +def _free_text_range_coerced_to_iri(schema: dict[str, Any], context: ContextView) -> list[str]: + """A property whose range includes free text must not use ``@type: "@id"``. + + ``@type: "@id"`` coerces *every* string to an IRI, so free text becomes an often invalid IRI + and is dropped. The property is only flagged when its own schema clearly admits a bare string + alongside a non-string form, which is what "the range includes free text" means. + """ + problems = [] + for name, prop in collect_composed_properties(schema).items(): + definition = context.terms.get(name) + if not isinstance(definition, dict) or definition.get("@type") != "@id": + continue + if _admits_free_text(prop): + problems.append( + f"{name!r} admits a bare string but its term coerces every value with " + '@type: "@id", so free text becomes an invalid IRI' + ) + return problems + + +def _admits_free_text(prop: Any) -> bool: + """True when a property permits a plain string *and* some other shape. + + A reference typed only as a string is the ordinary bare-IRI form and is correct; the + violation is a property that mixes free text with references or embedded objects. + """ + if not isinstance(prop, dict): + return False + for keyword in ("anyOf", "oneOf"): + branches = prop.get(keyword) + if not isinstance(branches, list) or len(branches) < 2: + continue + kinds = {b.get("type") for b in branches if isinstance(b, dict)} + # A string branch with no `format` and no `x-oold-range` is free text rather than an IRI. + text = any( + isinstance(b, dict) and b.get("type") == "string" and not b.get("format") and "x-oold-range" not in b + for b in branches + ) + if text and kinds - {"string"}: + return True + return False + + +def _closed_object_rejects_metadata(schema: dict[str, Any], context: ContextView) -> list[str]: + """A schema closing its objects must still permit `$schema` and `@context`. + + An instance carries both as ordinary members, so a schema with + ``additionalProperties: false`` that does not declare them rejects its own conforming + instances. + """ + closed = schema.get("additionalProperties") is False or schema.get("unevaluatedProperties") is False + if not closed: + return [] + declared = set(collect_composed_properties(schema)) + missing = [key for key in ("$schema", "@context") if key not in declared] + if missing: + return [ + "the schema closes its objects but does not declare " + + ", ".join(missing) + + ", so a conforming instance carrying them would be rejected" + ] + return [] + + +def _missing_version(schema: dict[str, Any], context: ContextView) -> list[str]: + if not schema.get("x-oold-version"): + return ["schema declares no x-oold-version"] + return [] + + +def _id_not_aliased(schema: dict[str, Any], context: ContextView) -> list[str]: + """`@id` should be reachable through a variable-name-friendly alias.""" + if not context.terms: + return [] + aliases = [t for t, d in context.terms.items() if (d.get("@id") if isinstance(d, dict) else d) == "@id"] + if not aliases: + return ["no @context term aliases @id, so instances must use the @id key directly"] + return [] + + +def _dialect_not_declared(schema: dict[str, Any], context: ContextView) -> list[str]: + declared = schema.get("$schema") + if not isinstance(declared, str) or not _OOLD_META.search(declared): + return [f"$schema is {declared!r}, not the OO-LD dialect meta-schema"] + return [] + + +def _processing_mode_not_declared(schema: dict[str, Any], context: ContextView) -> list[str]: + """`@version` must be the JSON number 1.1, not the string "1.1".""" + value = context.keyword("@version") + if value is None: + return ['the resolved @context declares no "@version": 1.1'] + if value == 1.1 and not isinstance(value, str): + return [] + return [f"@version is {value!r}; it must be the JSON number 1.1, not a string"] + + +# ---------------------------------------------------------------------------- the registry + + +@dataclass(frozen=True) +class CheckInfo: + """Metadata for one check id the validator can emit. + + ``detects`` holds the function object, never a string path, so a rename is followed rather + than silently going stale. It is left ``None`` where a check has no single detection site - + several report lines from different branches, or two functions that jointly decide one + verdict - rather than pointing at one of several candidates and misleading a reader. + + ``default_status`` is what a violation reports when no rule applies: either the check enforces + none, or the meta version in use ships no catalogue to read a level from. Where a rule and a + catalogue are both available, severity comes from the rule's level instead (:func:`severity`). + """ + + id: str + summary: str + rule: str | None = None + default_status: Status = FAIL + per_version: bool = False + detects: Callable[..., Any] | None = None + run: Predicate | None = None + + +CHECKS: tuple[CheckInfo, ...] = ( + # -------------------------------------------------------------- schema well-formedness + CheckInfo( + "schema.meta", + "a schema validates against the OO-LD meta-schema and can be compiled as a validator", + per_version=True, + # Two functions jointly decide this verdict (meta-schema validity, then compilability), + # so there is no single detection site to point at. + detects=None, + ), + CheckInfo( + "schema.refs", + "a schema's $ref composition resolves", + detects=check_refs_resolve, + ), + # -------------------------------------------------------------- round-trip-safe pattern lint + CheckInfo( + "lint.pattern", + "no @context term coerces a literal to a datatype JSON encodes natively", + rule="OOLD-RT-001", + per_version=True, + detects=_lint_pattern, + ), + CheckInfo( + "lint.container", + "a strictly array-typed property declares @container @set or @list", + rule="OOLD-RT-002", + detects=array_properties_missing_container, + ), + CheckInfo( + "lint.iri-format", + "a bare-IRI-string reference declares an iri-reference or stricter uri* format", + rule="OOLD-EXT-006", + default_status=WARN, + detects=iri_references_missing_format, + ), + # -------------------------------------------------------------- generation and round-trip + CheckInfo( + "generate.satisfiable", + "a generated instance validates against its own schema", + detects=generate, + ), + CheckInfo( + "roundtrip.generated", + "a generated instance survives instance to RDF to instance with no property lost", + # Reports SKIP for a cyclic context, FAIL for a processing error, FAIL for a shape + # mismatch, and OK, from five separate call sites; none of them is *the* detection site. + detects=None, + ), + CheckInfo( + "context.remote", + "a schema works as a remote @context", + # Decided inline by a direct jsonld.expand() call, not by a function in a detection + # module. + detects=None, + ), + CheckInfo( + "context.predicates", + "every declared property produces a grounded predicate", + rule="OOLD-EXT-007", + detects=check_predicates, + ), + CheckInfo( + "variants", + "each oneOf/anyOf branch is generated and round-tripped in turn", + # Multiple emission sites inline in the pipeline's per-variant loop, mirroring + # roundtrip.generated. + detects=None, + ), + # -------------------------------------------------------------- instances + CheckInfo( + "instance.schema", + "a committed instance validates against its schema", + detects=validate_instance, + ), + CheckInfo( + "roundtrip.instance", + "an instance round-trips through RDF unchanged", + detects=roundtrip_instance, + ), + # -------------------------------------------------------------- compliance-suite self-checks + CheckInfo( + "coverage.vocab", + "every keyword the meta-schemas define has a well-formedness test", + per_version=True, + detects=vocabulary_coverage, + ), + CheckInfo( + "coverage.rules", + "every checkable rule in the catalog is enforced by some check", + default_status=WARN, + per_version=True, + # Compares the catalogue against this very registry; there is no external function to + # point at. + detects=None, + ), + # -------------------------------------------------------------- single-rule checks + CheckInfo( + "rule.id", + "a schema has a $id", + rule="OOLD-VER-001", + per_version=True, + run=_missing_id, + ), + CheckInfo( + "rule.id-fragment", + "a $id has no non-empty fragment", + rule="OOLD-CMP-005", + per_version=True, + run=_id_has_fragment, + ), + CheckInfo( + "rule.range-ref", + "x-oold-range references use x-oold-ref", + rule="OOLD-EXT-005", + per_version=True, + run=_range_uses_ref, + ), + CheckInfo( + "rule.instance-type", + "a pinned type agrees with x-oold-instance-rdf-type", + rule="OOLD-INS-002", + per_version=True, + run=_inline_type_disagrees, + ), + CheckInfo( + "rule.free-text-iri", + "a free-text range is not coerced to @id", + rule="OOLD-INS-009", + per_version=True, + run=_free_text_range_coerced_to_iri, + ), + CheckInfo( + "rule.closed-object", + "a closed object still permits $schema and @context", + rule="OOLD-INS-005", + per_version=True, + run=_closed_object_rejects_metadata, + ), + CheckInfo( + "rule.version", + "a schema states x-oold-version", + rule="OOLD-VER-002", + per_version=True, + run=_missing_version, + ), + CheckInfo( + "rule.id-alias", + "@id is exposed through an alias", + rule="OOLD-INS-007", + per_version=True, + run=_id_not_aliased, + ), + CheckInfo( + "rule.dialect", + "a schema declares the OO-LD dialect", + rule="OOLD-EXT-002", + per_version=True, + run=_dialect_not_declared, + ), + CheckInfo( + "rule.processing-mode", + "a context declares @version 1.1", + rule="OOLD-EXT-001", + per_version=True, + run=_processing_mode_not_declared, + ), +) + + +def info(check_id: str) -> CheckInfo | None: + """The registry entry for a check id, or None when it is not registered.""" + return _BY_ID.get(check_id) + + +def rule_for(check_id: str) -> str | None: + """The rule a check enforces, or None when it enforces no single rule.""" + entry = _BY_ID.get(check_id) + return entry.rule if entry else None + + +def rule_map() -> dict[str, str]: + """check id -> rule id, for every check that enforces exactly one rule.""" + return {c.id: c.rule for c in CHECKS if c.rule} + + +_BY_ID: dict[str, CheckInfo] = {c.id: c for c in CHECKS} + + +def run_rule_checks( + schema: dict[str, Any], + context: ContextView, + catalog: dict[str, dict[str, Any]] | None = None, +) -> list[RuleFinding]: + """Apply the rule checks that the selected specification version actually states. + + ``catalog`` maps rule id to its catalogue entry for the meta version in use. When given, a + check whose rule is absent from it is **skipped**: that version never stated the requirement, + and enforcing it would report a violation of something the target does not require. A + deprecated rule is skipped for the same reason from the other end. + + When ``catalog`` is None the version ships no catalogue at all, and the caller decides + whether to run the checks blind or skip them. + """ + findings: list[RuleFinding] = [] + for check in (c for c in CHECKS if c.run): + rule = (catalog or {}).get(check.rule) + if catalog is not None: + if rule is None: + findings.append( + RuleFinding( + check.id, + check.rule, + SKIP, + f"{check.rule} is not stated by this meta-schema version", + ) + ) + continue + if rule.get("deprecated"): + superseded = ", ".join(rule.get("superseded_by") or []) or "nothing" + findings.append( + RuleFinding( + check.id, + check.rule, + SKIP, + f"{check.rule} is deprecated in this version (superseded by {superseded})", + ) + ) + continue + problems = check.run(schema, context) + if not problems: + findings.append(RuleFinding(check.id, check.rule, OK)) + else: + findings.append( + RuleFinding( + check.id, + check.rule, + severity(rule, check.default_status), + "; ".join(problems), + {"problems": problems}, + ) + ) + return findings diff --git a/src/oold/validation/cli.py b/src/oold/validation/cli.py index 8cfaa2d..11a37d0 100644 --- a/src/oold/validation/cli.py +++ b/src/oold/validation/cli.py @@ -239,14 +239,14 @@ def rules_group() -> None: @_json_option def rules_list(meta, offline: bool, area: str | None, unchecked: bool, as_json: bool) -> None: """List the rules in the specification's catalog.""" - from .pipeline import CHECK_RULES + from .check_registry import rule_map bundle = _rules_bundle(meta, offline) rules = bundle.rules if area: rules = [r for r in rules if r["area"].upper() == area.upper()] if unchecked: - enforced = set(CHECK_RULES.values()) + enforced = set(rule_map().values()) rules = [r for r in bundle.checkable_rules() if r["id"] not in enforced] if as_json: @@ -256,7 +256,7 @@ def rules_list(meta, offline: bool, area: str | None, unchecked: bool, as_json: click.echo("no rules match") return - enforced_by = {v: k for k, v in CHECK_RULES.items()} + enforced_by = {v: k for k, v in rule_map().items()} for rule in rules: flag = "!" if rule.get("deprecated") else " " check = enforced_by.get(rule["id"], "-") @@ -275,7 +275,7 @@ def rules_list(meta, offline: bool, area: str | None, unchecked: bool, as_json: @_json_option def rules_explain(rule_id: str, meta, offline: bool, as_json: bool) -> None: """Show one rule in full: its level, what it binds, and the specification text.""" - from .pipeline import CHECK_RULES + from .check_registry import rule_map bundle = _rules_bundle(meta, offline) rule = bundle.rule(rule_id.upper()) @@ -288,7 +288,7 @@ def rules_explain(rule_id: str, meta, offline: bool, as_json: bool) -> None: click.echo(json.dumps(rule, indent=2)) return - enforced_by = {v: k for k, v in CHECK_RULES.items()} + enforced_by = {v: k for k, v in rule_map().items()} click.echo(click.style(rule["id"], fg="blue", bold=True) + f" {rule['level']}") click.echo(f" {rule['summary']}") click.echo() diff --git a/src/oold/validation/mcp_server.py b/src/oold/validation/mcp_server.py index 75b7714..ea21732 100644 --- a/src/oold/validation/mcp_server.py +++ b/src/oold/validation/mcp_server.py @@ -271,7 +271,7 @@ def list_oold_rules( unenforced_only: Only checkable rules that no check enforces yet. offline: Never fetch over the network. """ - from .pipeline import CHECK_RULES + from .check_registry import rule_map try: bundles = resolve_selection(tuple(meta) if meta else ("latest",), offline=offline) @@ -288,7 +288,7 @@ def list_oold_rules( ), } - enforced_by = {v: k for k, v in CHECK_RULES.items()} + enforced_by = {v: k for k, v in rule_map().items()} rules = bundle.checkable_rules() if unenforced_only else bundle.rules if area: rules = [r for r in rules if r["area"].upper() == area.upper()] diff --git a/src/oold/validation/pipeline.py b/src/oold/validation/pipeline.py index c15e44a..9bc71a9 100644 --- a/src/oold/validation/pipeline.py +++ b/src/oold/validation/pipeline.py @@ -17,6 +17,8 @@ from pathlib import Path from typing import Any +from .check_registry import ContextView, rule_map, run_rule_checks +from .check_registry import rule_for as _rule_for_check from .compliance import run_suite, vocabulary_coverage from .context_graph import cyclic_scoped_contexts from .context_resolution import find_alias_keys, resolve_context @@ -32,28 +34,11 @@ from .report import FAIL, OK, SKIP, WARN, Report from .resolve import Resolver, SchemaResolutionError, bound_schema from .roundtrip import roundtrip -from .rule_checks import RULE_CHECK_MAP, ContextView, run_rule_checks from .schema_checks import check_usable_as_validator, validate_against_meta SCHEMA_SUFFIX = ".schema.json" INSTANCE_SUFFIX = ".instance.json" -#: Which normative rule each check enforces, so a finding can cite the requirement rather than -#: only this package's internal check name. Deliberately partial: a check is mapped only where it -#: enforces one identifiable requirement. `schema.meta` for instance asserts the whole meta-schema -#: rather than any single statement, and `roundtrip.*` asserts a contract the specification states -#: across several bullets. Leaving those unmapped is what makes `coverage.rules` meaningful - an -#: invented mapping would report coverage the validator does not actually have. -CHECK_RULES: dict[str, str] = { - "lint.pattern": "OOLD-RT-001", # no coercion to a natively-JSON-encoded datatype - "lint.container": "OOLD-RT-002", # a strict array declares @container @set/@list - "lint.iri-format": "OOLD-EXT-006", # an IRI-valued property constrains its lexical form - "context.predicates": "OOLD-EXT-007", # a compact-IRI prefix is defined in the @context - # Checks that each enforce exactly one rule live in rule_checks.py and declare their own - # mapping, so adding a rule check cannot forget to register it here. - **RULE_CHECK_MAP, -} - #: Why a schema's JSON-LD checks were skipped. Shared so the message is identical everywhere. CYCLIC_NOTE = ( "reaches a cyclic scoped @context, which neither PyLD nor jsonld.js can process " @@ -114,7 +99,7 @@ def add(self, check_id: str, *args: Any, **kwargs: Any) -> None: self.report.add(check_id, *args, **kwargs) def rule_for(self, check_id: str) -> str | None: - rule_id = CHECK_RULES.get(check_id) + rule_id = _rule_for_check(check_id) if not rule_id: return None return rule_id if any(b.rule(rule_id) for b in self.bundles) else None @@ -696,7 +681,7 @@ def _check_rule_coverage(run: _Run, target: str, bundle: MetaBundle) -> None: A mapped id the catalog does not contain looks like a dangling reference, but it is ambiguous: it is equally what a *older* meta version looks like, one minted before that rule existed. Failing would make validating against an older version break for no reason. A - genuine typo in `CHECK_RULES` is caught instead by the shape test and by the live parity test + genuine typo in the registry is caught instead by the shape test and by the live parity test that resolves every mapping against the current upstream catalog. """ if not bundle.has_rules: @@ -709,9 +694,10 @@ def _check_rule_coverage(run: _Run, target: str, bundle: MetaBundle) -> None: ) return - unknown = sorted({r for r in CHECK_RULES.values() if not bundle.rule(r)}) + mapped = set(rule_map().values()) + unknown = sorted({r for r in mapped if not bundle.rule(r)}) checkable = bundle.checkable_rules() - missing = sorted(r["id"] for r in checkable if r["id"] not in set(CHECK_RULES.values())) + missing = sorted(r["id"] for r in checkable if r["id"] not in mapped) notes: list[str] = [] if missing: diff --git a/tests/test_validation/test_check_registry.py b/tests/test_validation/test_check_registry.py index 7f2c0a6..05cbf72 100644 --- a/tests/test_validation/test_check_registry.py +++ b/tests/test_validation/test_check_registry.py @@ -9,14 +9,18 @@ import pytest +from oold.validation.check_registry import CHECKS, ContextView, run_rule_checks, severity from oold.validation.meta_store import latest_version, load_tracked -from oold.validation.rule_checks import RULE_CHECKS, ContextView, run_rule_checks, severity #: The catalogue actually shipped for the newest tracked version. Severity is read from it rather #: than hardcoded here, so if upstream relaxes a MUST to a SHOULD these tests report the change #: instead of silently disagreeing with the specification. CATALOG = {r["id"]: r for r in load_tracked(latest_version()).rules} +#: The ten self-contained rule checks, in the order they are declared - the same slice +#: `run_rule_checks` executes. +SELF_CONTAINED_CHECKS = tuple(c for c in CHECKS if c.run) + def _findings(schema: dict, context: ContextView | None = None): return {f.check_id: f for f in run_rule_checks(schema, context or ContextView(), CATALOG)} @@ -34,10 +38,10 @@ def message(check_id: str, schema: dict, context: ContextView | None = None) -> def test_every_check_names_a_rule_that_exists(): - for check in RULE_CHECKS: - assert check.rule.startswith("OOLD-"), check.check_id - assert check.check_id.startswith("rule."), check.check_id - assert check.rule in CATALOG, f"{check.check_id} cites {check.rule}, absent from the catalogue" + for check in SELF_CONTAINED_CHECKS: + assert check.rule.startswith("OOLD-"), check.id + assert check.id.startswith("rule."), check.id + assert check.rule in CATALOG, f"{check.id} cites {check.rule}, absent from the catalogue" def test_severity_is_read_from_the_specification_not_hardcoded(): @@ -239,11 +243,11 @@ def test_no_must_level_rule_fires_on_the_upstream_examples(data_dir): assert not hits, [f"{c.id} {c.target}: {c.message}" for c in hits] -@pytest.mark.parametrize("check", RULE_CHECKS, ids=lambda c: c.check_id) +@pytest.mark.parametrize("check", SELF_CONTAINED_CHECKS, ids=lambda c: c.id) def test_every_check_runs_on_every_example(check, data_dir): """No check may crash on a real schema; each must produce a verdict.""" from oold.validation import Options, validate_directory report = validate_directory(data_dir, Options(meta=("latest",), offline=True)) - produced = [c for c in report.checks if c.id == check.check_id] - assert produced, f"{check.check_id} produced no finding at all" + produced = [c for c in report.checks if c.id == check.id] + assert produced, f"{check.id} produced no finding at all" diff --git a/tests/test_validation/test_parity_live.py b/tests/test_validation/test_parity_live.py index 892448e..dfff0bb 100644 --- a/tests/test_validation/test_parity_live.py +++ b/tests/test_validation/test_parity_live.py @@ -120,7 +120,7 @@ def test_the_reference_cannot_resolve_a_context_leaving_the_directory(upstream, def test_every_mapped_rule_resolves_against_the_upstream_catalog(upstream): - """The authoritative guard against a typo in CHECK_RULES. + """The authoritative guard against a typo in the check registry. Per-version coverage only warns about an unknown id, because a catalog predating a mapped rule is indistinguishable from a mistake. Against the *current* upstream catalog there is no @@ -129,12 +129,12 @@ def test_every_mapped_rule_resolves_against_the_upstream_catalog(upstream): """ import json - from oold.validation.pipeline import CHECK_RULES + from oold.validation.check_registry import rule_map catalog = upstream / "meta" / "oold-rules.json" if not catalog.is_file(): pytest.skip("upstream has not published a rule catalog yet") known = {r["id"] for r in json.loads(catalog.read_text(encoding="utf-8"))["rules"]} - unknown = {check: rule for check, rule in CHECK_RULES.items() if rule not in known} - assert not unknown, f"CHECK_RULES cites ids absent from the upstream catalog: {unknown}" + unknown = {check: rule for check, rule in rule_map().items() if rule not in known} + assert not unknown, f"the registry cites ids absent from the upstream catalog: {unknown}" diff --git a/tests/test_validation/test_rules.py b/tests/test_validation/test_rules.py index 8cd4931..afb4720 100644 --- a/tests/test_validation/test_rules.py +++ b/tests/test_validation/test_rules.py @@ -13,9 +13,9 @@ from click.testing import CliRunner from oold.validation import meta_store +from oold.validation.check_registry import rule_map from oold.validation.cli import main from oold.validation.meta_store import RULES_FILE, load_tracked -from oold.validation.pipeline import CHECK_RULES SAMPLE_RULES = { "spec_version": "0.9.0", @@ -158,7 +158,7 @@ def test_checkable_rules_exclude_implementation_advisory_and_deprecated(catalog_ def test_every_mapped_rule_id_is_well_formed(): """A typo here would make findings cite a code that resolves to nothing.""" - for check_id, rule_id in CHECK_RULES.items(): + for check_id, rule_id in rule_map().items(): assert rule_id.startswith("OOLD-"), f"{check_id} maps to {rule_id!r}" assert len(rule_id.split("-")) == 3 @@ -241,7 +241,7 @@ def test_unenforced_rules_are_a_warning_not_a_failure(catalog_version, complianc def test_a_mapped_rule_missing_from_an_older_catalog_is_not_a_failure(catalog_version, compliance_dir): """A catalog predating a mapped rule is indistinguishable from a typo, so it only warns. - The sample catalog omits OOLD-RT-001, which CHECK_RULES maps to. Failing there would break + The sample catalog omits OOLD-RT-001, which the registry maps `lint.pattern` to. Failing there would break validation against any meta version older than the newest rule this package enforces. """ from oold.validation import Options, run_compliance From f6fcbbbdf9eaedfa755ac169b30a7cc5360c9ac6 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 5 Aug 2026 14:28:04 +0200 Subject: [PATCH 15/29] feat(validation): gate checks on the catalogue, and add `oold checks` - Every emittable id now has a registry entry, including rule.checks, meta.self-check, compliance.suite and a single compliance.* family entry - `predates_catalog` decides check behaviour against versions that ship no rule catalogue - Adds the missing lint.iri-format fixture so the check is actually exercised - Fixes drift test 4, which previously counted a gated SKIP as a run - Verdicts unchanged on all three tracked versions, confirmed by diff and the parity suite --- src/oold/validation/check_registry.py | 138 ++++++++++++++---- src/oold/validation/cli.py | 131 +++++++++++++++++ src/oold/validation/mcp_server.py | 41 ++++++ src/oold/validation/pipeline.py | 42 +++++- tests/data/oold/README.md | 1 + .../iri_reference_without_format.schema.json | 20 +++ .../test_check_registry_drift.py | 137 +++++++++++++++++ tests/test_validation/test_mcp_server.py | 1 + tests/test_validation/test_pipeline.py | 25 ++-- 9 files changed, 489 insertions(+), 47 deletions(-) create mode 100644 tests/data/oold/broken/iri_reference_without_format.schema.json create mode 100644 tests/test_validation/test_check_registry_drift.py diff --git a/src/oold/validation/check_registry.py b/src/oold/validation/check_registry.py index 6fa04d6..442ea91 100644 --- a/src/oold/validation/check_registry.py +++ b/src/oold/validation/check_registry.py @@ -3,16 +3,17 @@ Two identifier systems appear in a finding: the check id (``lint.container``) names which check in this package produced it, and the rule id (``OOLD-RT-002``) names the normative statement it enforces, when there is one. Rule ids come from the specification and are permanent; check ids -are implementation-defined and follow this package's structure. A finding cites both, because ten -of the twenty-four checks enforce no rule at all - `schema.meta` is definitional, +are implementation-defined and follow this package's structure. A finding cites both, because +fourteen of the twenty-eight checks enforce no rule at all - `schema.meta` is definitional, `generate.satisfiable`, `variants` and the `roundtrip.*` checks are this validator's methodology, -and `coverage.*` are self-tests about the fixture suite - and for those the check id is the only -identifier a user has. +`coverage.*` are self-tests about the fixture suite, `meta.self-check` and `rule.checks` report on +the run itself rather than on a schema, and `compliance.suite`/`compliance.*` are the deterministic +fixture suite's own outcomes - and for those the check id is the only identifier a user has. This module holds two things that used to live apart. The ten ``rule.*`` checks each enforce exactly one normative statement and are narrow enough to be self-contained predicates over an already-resolved :class:`ContextView`, so they are declared here and executed by -:func:`run_rule_checks`. The other fourteen checks are driven by the phases in ``pipeline.py`` and +:func:`run_rule_checks`. The other eighteen checks are driven by the phases in ``pipeline.py`` and leave :attr:`CheckInfo.run` empty; this module only records their metadata; ``detects`` points at the function that actually decides the verdict, where one function is clearly responsible. @@ -28,10 +29,11 @@ from dataclasses import dataclass, field from typing import Any -from .compliance import vocabulary_coverage +from .compliance import run_suite, vocabulary_coverage from .frame import collect_composed_properties, instance_rdf_types from .generate import generate from .instance_checks import roundtrip_instance, validate_instance +from .meta_store import MetaBundle from .pattern_lint import array_properties_missing_container, iri_references_missing_format from .pattern_lint import lint as _lint_pattern from .predicates import check_predicates @@ -287,6 +289,14 @@ class CheckInfo: ``default_status`` is what a violation reports when no rule applies: either the check enforces none, or the meta version in use ships no catalogue to read a level from. Where a rule and a catalogue are both available, severity comes from the rule's level instead (:func:`severity`). + + ``predates_catalog`` matters only when ``rule`` is set. Wherever a catalogue is available for + the selected version, both values behave identically: the check runs only if the catalogue + states the rule and has not deprecated it (see :func:`catalog_gate`). They differ only when a + version ships no catalogue at all (0.7.0, 0.8.0): ``False``, the default, skips the check, + because a rule minted after the catalogue cannot be attributed to a version that predates it. + ``True`` runs it anyway, for the four checks whose requirement is older than the catalogue + itself and would otherwise silently stop being enforced on those versions. """ id: str @@ -296,9 +306,17 @@ class CheckInfo: per_version: bool = False detects: Callable[..., Any] | None = None run: Predicate | None = None + predates_catalog: bool = False CHECKS: tuple[CheckInfo, ...] = ( + # -------------------------------------------------------------- run setup + CheckInfo( + "meta.self-check", + "the vendored meta-schema documents for one version are themselves well-formed", + per_version=True, + detects=MetaBundle.self_check, + ), # -------------------------------------------------------------- schema well-formedness CheckInfo( "schema.meta", @@ -320,12 +338,14 @@ class CheckInfo: rule="OOLD-RT-001", per_version=True, detects=_lint_pattern, + predates_catalog=True, ), CheckInfo( "lint.container", "a strictly array-typed property declares @container @set or @list", rule="OOLD-RT-002", detects=array_properties_missing_container, + predates_catalog=True, ), CheckInfo( "lint.iri-format", @@ -333,6 +353,7 @@ class CheckInfo: rule="OOLD-EXT-006", default_status=WARN, detects=iri_references_missing_format, + predates_catalog=True, ), # -------------------------------------------------------------- generation and round-trip CheckInfo( @@ -359,6 +380,7 @@ class CheckInfo: "every declared property produces a grounded predicate", rule="OOLD-EXT-007", detects=check_predicates, + predates_catalog=True, ), CheckInfo( "variants", @@ -379,6 +401,22 @@ class CheckInfo: detects=roundtrip_instance, ), # -------------------------------------------------------------- compliance-suite self-checks + CheckInfo( + "compliance.suite", + "the compliance suite's own fixture files are readable and well-shaped", + per_version=True, + detects=run_suite, + ), + CheckInfo( + "compliance.*", + "one compliance-suite case produced the outcome its fixture expects", + per_version=True, + # One id per fixture "kind" (vocab, lint, validate, rdf, roundtrip, error, ...), built as + # f"compliance.{case.kind}" from data in the fixture files rather than from code, so there + # is no single detection site and no fixed set of kinds to enumerate. This entry stands + # for the whole family; see the module docstring in `compliance.py`. + detects=None, + ), CheckInfo( "coverage.vocab", "every keyword the meta-schemas define has a well-formedness test", @@ -395,6 +433,16 @@ class CheckInfo: detects=None, ), # -------------------------------------------------------------- single-rule checks + CheckInfo( + "rule.checks", + "the rule.* family as a whole, reported once when the selected meta-schema version ships " + "no rule catalogue to attribute individual findings to", + per_version=True, + default_status=SKIP, + # Decided inline in pipeline.py's `_run_rule_checks`, which substitutes this one finding + # for the whole family rather than calling any of the ten predicates below. + detects=None, + ), CheckInfo( "rule.id", "a schema has a $id", @@ -487,6 +535,50 @@ def rule_map() -> dict[str, str]: _BY_ID: dict[str, CheckInfo] = {c.id: c for c in CHECKS} +def catalog_gate(check: CheckInfo, catalog: dict[str, dict[str, Any]] | None) -> RuleFinding | None: + """Whether ``check`` must be skipped against ``catalog``, or None to mean "run it". + + This is the one place the presence/deprecation gating lives, shared by the ten self-contained + ``rule.*`` predicates (via :func:`run_rule_checks`) and the four checks that predate the + catalogue, applied directly in ``pipeline.py``. A check with no ``rule`` is never gated: the + question only makes sense for a check that names a normative statement. + + ``catalog`` maps rule id to its catalogue entry for the meta version in use, or is None when + that version ships no catalogue at all. Wherever a catalogue *is* present the two outcomes are + identical regardless of :attr:`CheckInfo.predates_catalog`: skip if the rule is absent or + deprecated, run otherwise. The field only changes what happens with no catalogue at all, which + is the one thing a pre-catalogue version cannot state either way. + """ + if check.rule is None: + return None + if catalog is None: + if check.predates_catalog: + return None + return RuleFinding( + check.id, + check.rule, + SKIP, + f"{check.rule} cannot be attributed to this meta-schema version, which ships no rule catalogue", + ) + rule = catalog.get(check.rule) + if rule is None: + return RuleFinding( + check.id, + check.rule, + SKIP, + f"{check.rule} is not stated by this meta-schema version", + ) + if rule.get("deprecated"): + superseded = ", ".join(rule.get("superseded_by") or []) or "nothing" + return RuleFinding( + check.id, + check.rule, + SKIP, + f"{check.rule} is deprecated in this version (superseded by {superseded})", + ) + return None + + def run_rule_checks( schema: dict[str, Any], context: ContextView, @@ -497,36 +589,20 @@ def run_rule_checks( ``catalog`` maps rule id to its catalogue entry for the meta version in use. When given, a check whose rule is absent from it is **skipped**: that version never stated the requirement, and enforcing it would report a violation of something the target does not require. A - deprecated rule is skipped for the same reason from the other end. + deprecated rule is skipped for the same reason from the other end. See :func:`catalog_gate`. - When ``catalog`` is None the version ships no catalogue at all, and the caller decides - whether to run the checks blind or skip them. + When ``catalog`` is None the version ships no catalogue at all, and every one of these ten + checks skips: none of them predates the catalogue (:attr:`CheckInfo.predates_catalog` is + False for all of them), so there is nothing pre-catalogue evidence could attribute the rule + to. """ findings: list[RuleFinding] = [] for check in (c for c in CHECKS if c.run): + gate = catalog_gate(check, catalog) + if gate is not None: + findings.append(gate) + continue rule = (catalog or {}).get(check.rule) - if catalog is not None: - if rule is None: - findings.append( - RuleFinding( - check.id, - check.rule, - SKIP, - f"{check.rule} is not stated by this meta-schema version", - ) - ) - continue - if rule.get("deprecated"): - superseded = ", ".join(rule.get("superseded_by") or []) or "nothing" - findings.append( - RuleFinding( - check.id, - check.rule, - SKIP, - f"{check.rule} is deprecated in this version (superseded by {superseded})", - ) - ) - continue problems = check.run(schema, context) if not problems: findings.append(RuleFinding(check.id, check.rule, OK)) diff --git a/src/oold/validation/cli.py b/src/oold/validation/cli.py index 11a37d0..5bc349d 100644 --- a/src/oold/validation/cli.py +++ b/src/oold/validation/cli.py @@ -10,6 +10,7 @@ from __future__ import annotations +import inspect import json import sys from pathlib import Path @@ -325,6 +326,135 @@ def _rules_bundle(meta, offline: bool): ) +@click.group("checks") +def checks_group() -> None: + """Look up the checks this validator can run. + + Check ids (``lint.container``, ``rule.id-fragment``) name which check produced a finding; + rule ids (``OOLD-RT-002``, see ``oold rules``) name the specification requirement it + enforces, when it enforces one at all. The two are not peers: see + ``specs/2026-08-04-check-registry-design.md`` for why. + """ + + +@checks_group.command("list") +@click.option("--prefix", help="Only checks whose id starts with this, e.g. lint.") +@click.option( + "--unmapped", + is_flag=True, + help="Only checks that enforce no specification rule, the mirror of `oold rules list --unchecked`.", +) +@_json_option +def checks_list(prefix: str | None, unmapped: bool, as_json: bool) -> None: + """List the checks this validator can run.""" + from .check_registry import CHECKS + + checks = CHECKS + if prefix: + checks = [c for c in checks if c.id.startswith(prefix)] + if unmapped: + checks = [c for c in checks if not c.rule] + + if as_json: + click.echo(json.dumps([_check_summary(c) for c in checks], indent=2)) + return + if not checks: + click.echo("no checks match") + return + + for check in checks: + rule = check.rule or "-" + # The id is padded before styling: ANSI escape codes count towards an f-string field + # width, so padding a styled string misaligns the columns that follow it. + click.echo( + f"{click.style(check.id.ljust(24), fg='blue')} {check.default_status.upper():<6} {rule:<16} {check.summary}" + ) + click.echo() + click.echo(f" {len(checks)} check(s); the column before the summary is the rule each enforces, if any") + + +@checks_group.command("explain") +@click.argument("check_id") +@_json_option +def checks_explain(check_id: str, as_json: bool) -> None: + """Show one check in full: what it verifies, the rule it enforces, and where it is detected.""" + from .check_registry import info + + check = info(check_id) + if check is None: + raise click.ClickException( + f"{check_id} is not a registered check. Try `oold checks list` to see what is available." + ) + + if as_json: + click.echo(json.dumps(_check_summary(check, full=True), indent=2)) + return + + click.echo(click.style(check.id, fg="blue", bold=True) + f" {check.default_status.upper()} by default") + click.echo(f" {check.summary}") + click.echo() + if check.rule: + click.echo(f" rule {check.rule} ({_rule_version_summary(check.rule)})") + else: + click.echo(" rule none; this check enforces no single specification requirement") + click.echo(f" detected {_detection_site(check)}") + click.echo(f" per version {'yes' if check.per_version else 'no'}") + + +def _check_summary(check, full: bool = False) -> dict: + payload = { + "id": check.id, + "summary": check.summary, + "rule": check.rule, + "default_status": check.default_status, + "per_version": check.per_version, + "predates_catalog": check.predates_catalog, + } + if full: + payload["detection_site"] = _detection_site(check) + if check.rule: + payload["rule_versions"] = _rule_version_summary(check.rule) + return payload + + +def _detection_site(check) -> str: + """Where ``check``'s verdict is decided, derived from ``detects``/``run`` with `inspect`. + + Never the emitting site: that is what names the check id and reports the finding, which is + findable by grepping the id and would otherwise be a second, unmaintained field to keep in + step (see ``specs/2026-08-04-check-registry-design.md``, "The command"). + """ + fn = check.detects or check.run + if fn is None: + return "no single detection site; see the check's own module for its implementation" + module = (getattr(fn, "__module__", "") or "").rsplit(".", 1)[-1] + qualname = getattr(fn, "__qualname__", getattr(fn, "__name__", repr(fn))) + label = f"{module}.{qualname}" if module else qualname + try: + source_file = Path(inspect.getsourcefile(fn) or inspect.getfile(fn)).name + _, lineno = inspect.getsourcelines(fn) + except (OSError, TypeError): + return label + return f"{label} ({source_file}:{lineno})" + + +def _rule_version_summary(rule_id: str) -> str: + """Which tracked meta-schema versions state ``rule_id``, and which do not.""" + from .meta_store import load_tracked, tracked_versions + + stated, absent = [], [] + for version in tracked_versions(): + bundle = load_tracked(version) + (stated if bundle.rule(rule_id) else absent).append(version) + + parts = [] + if stated: + parts.append(f"stated by {', '.join(stated)}") + if absent: + parts.append(f"absent from {', '.join(absent)}") + return "; ".join(parts) if parts else "not found in any tracked version" + + @click.group() @click.version_option(package_name="oold") def main() -> None: @@ -336,6 +466,7 @@ def main() -> None: main.add_command(compliance_command) main.add_command(meta_group) main.add_command(rules_group) +main.add_command(checks_group) if __name__ == "__main__": diff --git a/src/oold/validation/mcp_server.py b/src/oold/validation/mcp_server.py index ea21732..e7d0436 100644 --- a/src/oold/validation/mcp_server.py +++ b/src/oold/validation/mcp_server.py @@ -302,6 +302,47 @@ def list_oold_rules( } +@mcp.tool() +def list_oold_checks( + prefix: str | None = None, + unmapped_only: bool = False, +) -> dict[str, Any]: + """List the checks this validator can run, mirroring list_oold_rules for check ids. + + A finding cites two identifiers: the check id (e.g. lint.container) names which check in this + validator produced it, the rule id (e.g. OOLD-RT-002, see list_oold_rules) names the + specification requirement it enforces, when it enforces one at all. Use unmapped_only to see + the checks that enforce no rule - these are this validator's own methodology (satisfiability, + round-trip, self-tests about the fixture suite) rather than a numbered requirement. + + Args: + prefix: Only checks whose id starts with this, e.g. "lint.". + unmapped_only: Only checks that enforce no specification rule. + """ + from .check_registry import CHECKS + + checks = CHECKS + if prefix: + checks = [c for c in checks if c.id.startswith(prefix)] + if unmapped_only: + checks = [c for c in checks if not c.rule] + + return { + "count": len(checks), + "checks": [ + { + "id": c.id, + "summary": c.summary, + "rule": c.rule, + "default_status": c.default_status, + "per_version": c.per_version, + "predates_catalog": c.predates_catalog, + } + for c in checks + ], + } + + @mcp.tool() def list_meta_versions() -> dict[str, Any]: """List the tracked meta-schema versions, which one is `latest`, and the remote cache state. diff --git a/src/oold/validation/pipeline.py b/src/oold/validation/pipeline.py index 9bc71a9..ef3276b 100644 --- a/src/oold/validation/pipeline.py +++ b/src/oold/validation/pipeline.py @@ -17,7 +17,8 @@ from pathlib import Path from typing import Any -from .check_registry import ContextView, rule_map, run_rule_checks +from .check_registry import ContextView, RuleFinding, catalog_gate, rule_map, run_rule_checks +from .check_registry import info as _check_info from .check_registry import rule_for as _rule_for_check from .compliance import run_suite, vocabulary_coverage from .context_graph import cyclic_scoped_contexts @@ -104,6 +105,22 @@ def rule_for(self, check_id: str) -> str | None: return None return rule_id if any(b.rule(rule_id) for b in self.bundles) else None + def catalog_gate(self, check_id: str, bundle: MetaBundle) -> RuleFinding | None: + """The skip verdict for `check_id` against one meta-schema version, or None to run it. + + Delegates to `check_registry.catalog_gate`, the single place the presence/deprecation + gating lives - `run_rule_checks` applies the same function to the ten self-contained + `rule.*` checks. This is what lets `lint.pattern`, `lint.container`, `lint.iri-format` + and `context.predicates` (the four checks older than the catalogue, + `CheckInfo.predates_catalog`) keep running against a version that ships no catalogue at + all, while still standing down once a later catalogue deprecates or drops their rule. + """ + check = _check_info(check_id) + if check is None: + return None + catalog = {r["id"]: r for r in bundle.rules} if bundle.has_rules else None + return catalog_gate(check, catalog) + # ---------------------------------------------------------------------------- setup @@ -188,7 +205,10 @@ def _check_schema(run: _Run, name: str) -> None: for bundle in run.bundles: result = lint(raw, bundle) first = first or result - if result.schema_errors: + gate = run.catalog_gate("lint.pattern", bundle) + if gate is not None: + run.add("lint.pattern", name, gate.status, gate.message, meta_version=bundle.version) + elif result.schema_errors: run.add( "lint.pattern", name, @@ -202,8 +222,12 @@ def _check_schema(run: _Run, name: str) -> None: if first is not None: # These two correlate `properties` with `@context`, so no meta-schema version can - # express them and they are reported once rather than per version. - if first.missing_container: + # express them and they are reported once rather than per version, gated against the + # first selected bundle - the same one `first` was computed from. + gate = run.catalog_gate("lint.container", run.bundles[0]) + if gate is not None: + run.add("lint.container", name, gate.status, gate.message) + elif first.missing_container: joined = ", ".join(first.missing_container) plural = "ies" if len(first.missing_container) > 1 else "y" run.add( @@ -216,7 +240,10 @@ def _check_schema(run: _Run, name: str) -> None: else: run.add("lint.container", name, OK) - if first.missing_iri_format: + gate = run.catalog_gate("lint.iri-format", run.bundles[0]) + if gate is not None: + run.add("lint.iri-format", name, gate.status, gate.message) + elif first.missing_iri_format: joined = ", ".join(first.missing_iri_format) plural = "ies" if len(first.missing_iri_format) > 1 else "y" run.add( @@ -372,6 +399,11 @@ def _check_predicates(run: _Run, name: str, raw, schema, sample) -> None: _run_rule_checks(run, name, raw, ContextView(terms=context.terms(), entries=list(context.context))) + gate = run.catalog_gate("context.predicates", run.bundles[0]) + if gate is not None: + run.add("context.predicates", name, gate.status, gate.message) + return + id_key, type_key = find_alias_keys(context.terms()) declared = set(collect_composed_properties(schema)) | {id_key, type_key} result = check_predicates(sample, context.as_jsonld(), declared_properties=declared) diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md index 15ccee3..b013732 100644 --- a/tests/data/oold/README.md +++ b/tests/data/oold/README.md @@ -45,3 +45,4 @@ Each one exists to prove a specific check fires, rather than only that valid inp | `unresolvable_context_ref` | `context.predicates` - the `@context` chain points at a missing schema | | `xsd_string_coercion` | `lint.pattern` - a term coercing a literal to `xsd:string` never round-trips | | `array_without_container` | `lint.container` - a strict array without `@container: @set` | +| `iri_reference_without_format` | `lint.iri-format` (warns, does not fail) - a bare-IRI-string reference with no `iri-reference`/`uri*` format | diff --git a/tests/data/oold/broken/iri_reference_without_format.schema.json b/tests/data/oold/broken/iri_reference_without_format.schema.json new file mode 100644 index 0000000..b4108f4 --- /dev/null +++ b/tests/data/oold/broken/iri_reference_without_format.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "iri_reference_without_format.schema.json", + "title": "IriReferenceWithoutFormat", + "@context": { + "ex": "https://example.org/", + "works_for": { + "@id": "ex:worksFor", + "@type": "@id" + } + }, + "type": "object", + "properties": { + "works_for": { + "type": "string", + "description": "Organization the entity works for (IRI reference), missing its format", + "x-oold-range": "ex:Organization" + } + } +} diff --git a/tests/test_validation/test_check_registry_drift.py b/tests/test_validation/test_check_registry_drift.py new file mode 100644 index 0000000..40dbc0b --- /dev/null +++ b/tests/test_validation/test_check_registry_drift.py @@ -0,0 +1,137 @@ +"""The four tests that hold the check registry to what the validator actually does. + +`test_check_registry.py` exercises the ten self-contained `rule.*` predicates in isolation. These +run the fixture corpus end to end instead, and compare what came out against what +`check_registry.CHECKS` claims exists. See `specs/2026-08-04-check-registry-design.md`, "Why this +will not drift", for the design these four tests implement. +""" + +from __future__ import annotations + +from pathlib import Path + +from oold.validation import Options, run_compliance, validate_directory, validate_instance +from oold.validation.check_registry import CHECKS +from oold.validation.meta_store import load_tracked, tracked_versions +from oold.validation.report import SKIP + +#: One version with no rule catalogue and the newest tracked one, which has one. That is enough +#: to surface every id the pipeline can emit; adding more tracked versions would only slow the +#: suite down for no extra coverage. +_CORPUS_META = ("0.7.0", "1.0.0-rc.1") + +#: These two fire only on an infrastructure failure path the corpus cannot exercise without +#: corrupting something every other test relies on: a broken vendored meta-schema +#: (`meta.self-check`, and those files must stay byte-exact - see CLAUDE.md) or an unreadable +#: compliance fixture file (`compliance.suite`, and corrupting one would also break every test +#: that expects the compliance suite to run cleanly). Exempted from test 2 rather than faked with +#: a fixture that would compromise something else. +_ONLY_ON_FAILURE_PATHS = frozenset({"meta.self-check", "compliance.suite"}) + + +def _emitted_ids(data_dir: Path, broken_dir: Path, compliance_dir: Path) -> set[str]: + """Every check id the validator emits across the whole fixture corpus.""" + opts = Options(meta=_CORPUS_META, offline=True) + ids: set[str] = set() + for report in ( + validate_directory(data_dir, opts), + run_compliance(compliance_dir, opts), + validate_directory(broken_dir, opts), + validate_instance(data_dir / "PersonWithPet.instance.json", options=opts), + ): + ids |= {c.id for c in report.checks} + return ids + + +def _normalize(check_id: str) -> str: + """Fold `compliance.` down to the one family entry the registry carries. + + The id is built as ``f"compliance.{case.kind}"`` from fixture data rather than from code, so + a new case kind in a compliance fixture must not need a new registry entry. `compliance.suite` + is a literal id, not data-derived, and is left alone. + """ + if check_id.startswith("compliance.") and check_id not in {"compliance.suite", "compliance.*"}: + return "compliance.*" + return check_id + + +# ------------------------------------------------------------------ 1. every emitted id is registered + + +def test_every_emitted_id_is_registered(data_dir, broken_dir, compliance_dir): + """Adding a check without registering it fails, naming the id.""" + emitted = {_normalize(c) for c in _emitted_ids(data_dir, broken_dir, compliance_dir)} + registered = {c.id for c in CHECKS} + unregistered = sorted(emitted - registered) + assert not unregistered, ( + f"the validator emitted check id(s) with no entry in check_registry.CHECKS: {unregistered} " + "- add a CheckInfo for each id, or fix the emission site if it was a typo" + ) + + +# ------------------------------------------------------------------ 2. every registered id is emitted + + +def test_every_registered_id_is_emitted(data_dir, broken_dir, compliance_dir): + """Catches a stale entry, and a check that silently stopped running.""" + emitted = {_normalize(c) for c in _emitted_ids(data_dir, broken_dir, compliance_dir)} + missing = sorted( + check.id for check in CHECKS if check.id not in emitted and check.id not in _ONLY_ON_FAILURE_PATHS + ) + assert not missing, ( + f"check_registry.CHECKS has entries the fixture corpus never produced: {missing} - either " + "the check silently stopped running, or the entry is stale and should be removed" + ) + + +# ------------------------------------------------------------------ 3. every named rule exists somewhere + + +def test_every_named_rule_exists_in_some_vendored_catalogue(): + """Catches a typo'd or retired rule id, against every tracked version at once.""" + known: set[str] = set() + for version in tracked_versions(): + known |= {rule["id"] for rule in load_tracked(version).rules} + + unknown = sorted(f"{check.id} cites {check.rule}" for check in CHECKS if check.rule and check.rule not in known) + assert not unknown, ( + f"check(s) name a rule absent from every vendored catalogue: {unknown} - fix the typo, or " + "vendor the meta-schema version that introduces the rule" + ) + + +# ------------------------------------------------------------------ 4. predates_catalog gating + + +def test_predates_catalog_is_exactly_what_runs_under_a_pre_catalogue_version(data_dir, broken_dir): + """0.7.0 ships no rule catalogue. A check must run there if, and only if, it predates one. + + Pins the backward-compatibility promise to a test rather than to reviewer memory. Both + directions fail loudly, which is what stops a new rule-carrying check from quietly judging a + specification version it was never written against. + """ + opts = Options(meta=("0.7.0",), offline=True) + # A gated check is not absent from the report: it emits a SKIP saying why. So "did it run" + # has to mean "reached a verdict", not "appears somewhere". Counting SKIP as having run makes + # the silently_skipped direction below vacuous, since a check that wrongly stands down still + # shows up - which is the exact regression this test exists to catch. + ran = {c.id for c in validate_directory(data_dir, opts).checks if c.status != SKIP} + ran |= {c.id for c in validate_directory(broken_dir, opts).checks if c.status != SKIP} + + rule_carrying = [check for check in CHECKS if check.rule] + should_run = {check.id for check in rule_carrying if check.predates_catalog} + should_skip = {check.id for check in rule_carrying if not check.predates_catalog} + + silently_skipped = sorted(should_run - ran) + assert not silently_skipped, ( + f"check(s) marked predates_catalog=True reached no verdict under --meta 0.7.0, which " + f"ships no rule catalogue - they encode requirements older than the catalogue and must " + f"still be enforced there, but they only skipped: {silently_skipped}" + ) + + wrongly_run = sorted(should_skip & ran) + assert not wrongly_run, ( + f"check(s) with predates_catalog=False (the default) produced a finding under --meta " + f"0.7.0, which ships no rule catalogue - they must be skipped as part of the rule.* " + f"family instead: {wrongly_run}" + ) diff --git a/tests/test_validation/test_mcp_server.py b/tests/test_validation/test_mcp_server.py index 88c3d65..38a473a 100644 --- a/tests/test_validation/test_mcp_server.py +++ b/tests/test_validation/test_mcp_server.py @@ -24,6 +24,7 @@ def list_tools(): "check_context_mapping", "list_meta_versions", "list_oold_rules", + "list_oold_checks", } diff --git a/tests/test_validation/test_pipeline.py b/tests/test_validation/test_pipeline.py index b9e56e6..452c0a7 100644 --- a/tests/test_validation/test_pipeline.py +++ b/tests/test_validation/test_pipeline.py @@ -71,22 +71,25 @@ def test_a_context_chain_leaving_the_directory_resolves(remote_context_dir): @pytest.mark.parametrize( - "fixture,check_id", + "fixture,check_id,status", [ - ("invalid_meta.schema.json", "schema.meta"), - ("missing_context_term.schema.json", "roundtrip.generated"), - ("undefined_prefix.schema.json", "context.predicates"), - ("unresolvable_context_ref.schema.json", "context.predicates"), - ("xsd_string_coercion.schema.json", "lint.pattern"), - ("array_without_container.schema.json", "lint.container"), + ("invalid_meta.schema.json", "schema.meta", FAIL), + ("missing_context_term.schema.json", "roundtrip.generated", FAIL), + ("undefined_prefix.schema.json", "context.predicates", FAIL), + ("unresolvable_context_ref.schema.json", "context.predicates", FAIL), + ("xsd_string_coercion.schema.json", "lint.pattern", FAIL), + ("array_without_container.schema.json", "lint.container", FAIL), + # lint.iri-format only ever warns, so this one does not make the report fail overall. + ("iri_reference_without_format.schema.json", "lint.iri-format", WARN), ], ) -def test_each_broken_fixture_fails_the_check_it_targets(broken_dir, fixture, check_id): +def test_each_broken_fixture_fails_the_check_it_targets(broken_dir, fixture, check_id, status): """Proves the checks fire, rather than only that valid input passes.""" report = validate_schema(broken_dir / fixture, OFFLINE) - assert not report.passed, f"{fixture} was expected to fail" - assert check_id in _ids(report, FAIL), ( - f"{fixture} failed, but not on {check_id}: {[(c.id, c.message) for c in report.failures()]}" + if status == FAIL: + assert not report.passed, f"{fixture} was expected to fail" + assert check_id in _ids(report, status), ( + f"{fixture}: expected {check_id} at {status}, got: {[(c.id, c.status, c.message) for c in report.checks]}" ) From bd34c1b32de5b65ddc6e943b146388438d17aedc Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 5 Aug 2026 17:55:45 +0200 Subject: [PATCH 16/29] test(validation): arm remote-context fixture against literal @context - Leaf.schema.json now requires `name`, defined only by the remote Thing context via ../Thing.schema.json - Leaf's own inline context defines just `nickname`, so a literal-@context check would misreport `name` as undefined - No new test needed; test_a_context_chain_leaving_the_directory_resolves already asserts the whole report passes --- tests/data/oold/README.md | 7 +++++++ tests/data/oold/remote_context/Leaf.schema.json | 3 +++ 2 files changed, 10 insertions(+) diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md index b013732..9d5abb5 100644 --- a/tests/data/oold/README.md +++ b/tests/data/oold/README.md @@ -15,6 +15,13 @@ broken/ deliberately broken schemas: the checks must fail on these remote_context/ a schema whose @context chain leaves its directory ``` +`remote_context/Leaf.schema.json` requires `name`, and that is deliberate: `name` is defined +only in the remote `../Thing.schema.json`, while Leaf's own inline `@context` defines just +`nickname`. Any check that reads `schema["@context"]` instead of the resolved context reports a +violation here, on a schema that is entirely correct. Keep the `required` when editing this +fixture; without it the schema still exercises context resolution, but nothing notices a check +judging the literal context rather than the resolved one. + ## Refreshing the snapshot When a new oold-schema version is tracked in `src/oold/validation/meta/`, refresh this slice from diff --git a/tests/data/oold/remote_context/Leaf.schema.json b/tests/data/oold/remote_context/Leaf.schema.json index 1f7aa10..ffc9c3b 100644 --- a/tests/data/oold/remote_context/Leaf.schema.json +++ b/tests/data/oold/remote_context/Leaf.schema.json @@ -13,6 +13,9 @@ } ], "type": "object", + "required": [ + "name" + ], "properties": { "name": { "type": "string" From ca1110907aa194b32458cfb90599c4b8a51609b5 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Thu, 6 Aug 2026 14:16:55 +0200 Subject: [PATCH 17/29] style: apply ruff-format to the check-registry drift test - Pre-existing drift, not introduced here: the line was exactly 120 characters, which failed `make check` on a clean checkout --- tests/test_validation/test_check_registry_drift.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_validation/test_check_registry_drift.py b/tests/test_validation/test_check_registry_drift.py index 40dbc0b..94cff5f 100644 --- a/tests/test_validation/test_check_registry_drift.py +++ b/tests/test_validation/test_check_registry_drift.py @@ -75,9 +75,7 @@ def test_every_emitted_id_is_registered(data_dir, broken_dir, compliance_dir): def test_every_registered_id_is_emitted(data_dir, broken_dir, compliance_dir): """Catches a stale entry, and a check that silently stopped running.""" emitted = {_normalize(c) for c in _emitted_ids(data_dir, broken_dir, compliance_dir)} - missing = sorted( - check.id for check in CHECKS if check.id not in emitted and check.id not in _ONLY_ON_FAILURE_PATHS - ) + missing = sorted(check.id for check in CHECKS if check.id not in emitted and check.id not in _ONLY_ON_FAILURE_PATHS) assert not missing, ( f"check_registry.CHECKS has entries the fixture corpus never produced: {missing} - either " "the check silently stopped running, or the entry is stale and should be removed" From a7a7f3cd167e24e38731a117ad9a87494acbd666 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Thu, 6 Aug 2026 14:17:11 +0200 Subject: [PATCH 18/29] feat(validation): check catalogue and fixture slice against facts - MetaBundle.self_check now validates the catalogue against the new oold-rules.schema.json, vendored alongside it - A truncated or malformed catalogue previously looked like a version stating fewer rules, with checks skipping silently - Loading stays lenient; the loss is now reported via meta.self-check instead of failing validation - Fixture slice provenance moves from README prose into index.json; `fixtures.tag` is compared against the newest tracked version by a test - Re-vendors rc.1's catalogue, which gained only the $schema line upstream --- .../meta/1.0.0-rc.1/oold-rules.json | 1 + .../meta/1.0.0-rc.1/oold-rules.schema.json | 165 ++++++++++++++++++ src/oold/validation/meta/index.json | 13 +- src/oold/validation/meta_store.py | 81 ++++++++- tests/test_validation/test_meta_store.py | 56 ++++++ 5 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 src/oold/validation/meta/1.0.0-rc.1/oold-rules.schema.json diff --git a/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json index 95dce9b..76673f7 100644 --- a/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json +++ b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json @@ -1,4 +1,5 @@ { + "$schema": "https://oo-ld.org/latest/meta/oold-rules.schema.json", "$comment": "Catalog of the normative statements in the OO-LD specification, generated from the :rule[...] markers in spec/sections/*.md by scripts/extract_rules.py. Do not edit by hand. Ids are immutable and never reused; see meta/RULES.md.", "spec_version": "1.0.0-rc.1", "areas": { diff --git a/src/oold/validation/meta/1.0.0-rc.1/oold-rules.schema.json b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.schema.json new file mode 100644 index 0000000..f39027f --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.schema.json @@ -0,0 +1,165 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-rules.schema.json", + "title": "OO-LD rule catalog", + "$comment": "Describes meta/oold-rules.json, which scripts/extract_rules.py generates from the :rule[...] markers in spec/sections/*.md. The catalog is data that downstream validators read to decide which requirements exist and how hard a violation lands, so a truncated or malformed copy does not fail loudly on its own: it just looks like a specification with fewer rules. This schema is what turns that into an error.", + "type": "object", + "required": [ + "spec_version", + "rules" + ], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "format": "iri-reference", + "description": "This document's schema. Released copies stamp their version in place of `latest`." + }, + "$comment": { + "type": "string" + }, + "spec_version": { + "$ref": "#/$defs/version", + "description": "The specification release this catalog was generated from. Moves with every tag, unlike a rule's `since`." + }, + "areas": { + "type": "object", + "description": "Area code to human-readable scope. Every rule's `area` is one of these keys.", + "propertyNames": { + "$ref": "#/$defs/area" + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "applies_to": { + "type": "object", + "description": "Binding to what enforcing it would take. Every rule's `applies_to` is one of these keys.", + "propertyNames": { + "$ref": "#/$defs/binding" + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/rule" + } + } + }, + "$defs": { + "area": { + "enum": [ + "CNF", + "SCH", + "CMP", + "INS", + "RT", + "VER", + "EXT" + ] + }, + "binding": { + "enum": [ + "document", + "implementation", + "advisory" + ] + }, + "version": { + "type": "string", + "minLength": 1 + }, + "rule": { + "type": "object", + "required": [ + "id", + "area", + "level", + "applies_to", + "section", + "summary", + "text", + "text_sha256", + "checkable", + "since", + "deprecated", + "source" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^OOLD-(CNF|SCH|CMP|INS|RT|VER|EXT)-[0-9]{3}$", + "description": "Permanent and never reused. Downstream checks cite it, so the pattern is asserted rather than assumed; see meta/RULES.md." + }, + "area": { + "$ref": "#/$defs/area" + }, + "level": { + "enum": [ + "MUST", + "MUST NOT", + "SHALL", + "SHALL NOT", + "SHOULD", + "SHOULD NOT", + "REQUIRED", + "RECOMMENDED" + ], + "description": "The RFC 2119 keyword in the marked prose. A validator reads this to decide whether a violation fails or warns, and never hardcodes it." + }, + "applies_to": { + "$ref": "#/$defs/binding" + }, + "section": { + "type": "string", + "minLength": 1 + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "The normative prose itself, cleaned of markup." + }, + "text_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "sha256 of `text`. meta/rules-baseline.json compares against this to catch a rule whose meaning changed under an unchanged id, so its shape is asserted here." + }, + "checkable": { + "type": "boolean", + "description": "Whether the requirement is decidable by inspecting a document. Defaults to true for `document` rules only." + }, + "since": { + "$ref": "#/$defs/version", + "description": "The release that first stated this rule. Carried forward once recorded; only an unseen id takes the current tag." + }, + "deprecated": { + "type": "boolean" + }, + "superseded_by": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^OOLD-(CNF|SCH|CMP|INS|RT|VER|EXT)-[0-9]{3}$" + }, + "description": "Present only on a deprecated rule, naming what replaced it." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Where the marker sits, as `
:`. Regenerated on every run, so it is provenance rather than a stable reference." + } + } + } + } +} diff --git a/src/oold/validation/meta/index.json b/src/oold/validation/meta/index.json index 2d29254..91701b3 100644 --- a/src/oold/validation/meta/index.json +++ b/src/oold/validation/meta/index.json @@ -43,18 +43,25 @@ "added": "2026-08-04", "id_base": "https://oo-ld.org/latest/meta/", "prerelease": true, - "notes": "First version to ship oold-rules.json, the catalogue of normative statements. The three meta-schemas are verbatim from the v1.0.0-rc.1 tag; the catalogue is not yet released and comes from the oold-schema branch feat/rule-catalog-rc1 (f56818f). Refresh it from the tag once that branch is merged and a release ships it.", + "notes": "First version to carry oold-rules.json, the catalogue of normative statements, and oold-rules.schema.json, which describes it. The three meta-schemas are verbatim from the v1.0.0-rc.1 tag. The catalogue is provisional: no release has shipped one yet, so it comes from the oold-schema branch feat/rule-catalog-rc1 instead, and rules_source records which commit. The v1.0.0-rc.1 tag itself will never gain a catalogue, so there is nothing here to refresh from a tag. When a release does ship one, vendor that version as its own entry rather than back-filling this one.", "rules_source": { "branch": "feat/rule-catalog-rc1", - "commit": "f56818f", + "commit": "c83583d0d37de8452843e0fb86de113dea1b6b9f", "released": false }, "sha256": { "oold-meta-schema.json": "cad3151c6bf0ac3e74acd46a4fee59b9287a551a9f62aa68aa7e2a718f360dbc", "oold-pattern-lint.schema.json": "d89fce19cd2fd42fa740d92968fcf61a1764ea25e741ed5cd4e72040a45c9a86", "oold-ui-meta-schema.json": "dd389d13a5e03268d4a4ff845dec7f4f28238f7edbd9fe0992399b37ac358212", - "oold-rules.json": "befaf98f6bb0e3e9f26c8961800a36ba996e7d669e97388b564137b390aa6da0" + "oold-rules.json": "cb4b8c6ba198971469095a3932e8b9d50be9448d3f0f2185e92ba76cbcbbdc3e", + "oold-rules.schema.json": "6fbe9914625a8f1f2ffed4a7e70bdd0f47af3e35b435e47437527cfe47593630" } } + }, + "fixtures": { + "$comment": "Provenance of the fixture slice in tests/data/oold/, which is a copy of the upstream examples/ directory. Recorded here rather than stated in that folder's README, because a tag is data: the README claimed v0.8.0 for a full release after the slice had moved to v1.0.0-rc.1, and nothing noticed. `tag` must name the newest entry in `versions`, so that fixtures and meta-schemas always come from one release; a test asserts it. The locally authored fixtures under broken/ and remote_context/ are not part of this slice and no refresh touches them.", + "tag": "v1.0.0-rc.1", + "source": "examples/", + "destination": "tests/data/oold/" } } diff --git a/src/oold/validation/meta_store.py b/src/oold/validation/meta_store.py index 6f5ff0f..fbf1acd 100644 --- a/src/oold/validation/meta_store.py +++ b/src/oold/validation/meta_store.py @@ -46,6 +46,13 @@ #: the run failing, which is what lets an older meta version stay usable. RULES_FILE = "oold-rules.json" +#: The schema describing the catalog, vendored beside it from the same source and optional for the +#: same reason. It exists because the catalog is data the validator *trusts*: an unreadable one +#: leaves every ``rule.*`` check with nothing to attribute a finding to, and those checks then skip. +#: A skip is the correct response to a version that never stated a rule and the wrong one to a +#: broken file, and without this schema the two are indistinguishable. +RULES_SCHEMA_FILE = "oold-rules.schema.json" + #: Selector for the unreleased upstream state. REMOTE = "remote" LATEST = "latest" @@ -142,6 +149,12 @@ class MetaBundle: registry: Registry = field(repr=False) #: The rule catalog for this version, empty when it predates one. rules: list[dict[str, Any]] = field(default_factory=list, repr=False) + #: The whole catalog document, kept so :meth:`self_check` can judge it against its schema. + rules_document: dict[str, Any] | None = field(default=None, repr=False) + #: The schema describing that document, when this version vendors one. + rules_schema: dict[str, Any] | None = field(default=None, repr=False) + #: Why the catalog could not be read, when a file was there but unusable. + rules_error: str | None = field(default=None, repr=False) @property def meta(self) -> dict[str, Any]: @@ -191,6 +204,12 @@ def self_check(self) -> list[str]: The reference harness gets this for free when ajv compiles the meta-schema (``validate.mjs`` line 68). Here it is explicit, so a badly curated version folder is reported as a failing check rather than crashing mid-run. + + The rule catalog is judged too, when this version vendors the schema for it. It is not a + schema itself but data the validator trusts, and trusting it silently is the failure this + guards: a truncated catalog looks exactly like a specification that states fewer rules, so + the checks enforcing the missing ones stand down with a message saying the version never + stated them. That message would be a lie, and nothing else in the run contradicts it. """ problems: list[str] = [] for name, document in sorted(self.documents.items()): @@ -198,8 +217,27 @@ def self_check(self) -> list[str]: Draft202012Validator.check_schema(document) except SchemaError as exc: problems.append(f"{name} is not a valid JSON Schema 2020-12 document: {exc.message}") + problems.extend(self._catalog_problems()) return problems + def _catalog_problems(self) -> list[str]: + """The rule catalog's own problems: unreadable, or disagreeing with its schema.""" + if self.rules_error: + return [self.rules_error] + if self.rules_document is None or self.rules_schema is None: + return [] + try: + Draft202012Validator.check_schema(self.rules_schema) + except SchemaError as exc: + return [f"{RULES_SCHEMA_FILE} is not a valid JSON Schema 2020-12 document: {exc.message}"] + errors = sorted(Draft202012Validator(self.rules_schema).iter_errors(self.rules_document), key=str) + # One line per problem, located, because "the catalog is invalid" is not actionable when + # the file is a thousand lines of generated data. + return [ + f"{RULES_FILE} violates {RULES_SCHEMA_FILE} at {'/'.join(str(p) for p in e.path) or ''}: {e.message}" + for e in errors + ] + def declared_keywords(self) -> list[str]: """Every ``x-oold-*`` / ``x-oold-ui-*`` keyword the meta-schemas define. @@ -240,19 +278,38 @@ def retrieve(uri: str) -> Resource: return Registry(retrieve=retrieve).with_resources(pairs) -def _read_rules(directory: Path) -> list[dict[str, Any]]: - """Load the optional rule catalog from a version directory. +def _read_rules(directory: Path) -> tuple[dict[str, Any] | None, str | None]: + """Load the optional rule catalog from a version directory, and any problem reading it. A malformed catalog is treated as absent rather than fatal: rule ids are an annotation on - findings, so losing them must never stop a schema from being validated. + findings, so losing them must never stop a schema from being validated. The problem is + returned rather than raised or swallowed, so :meth:`MetaBundle.self_check` can report it. That + split is the point - the run continues, but a broken catalog no longer passes for a + specification that happens to state nothing. """ path = directory / RULES_FILE if not path.is_file(): - return [] + return None, None try: - return json.loads(path.read_text(encoding="utf-8")).get("rules", []) + return json.loads(path.read_text(encoding="utf-8")), None + except (OSError, json.JSONDecodeError) as exc: + return None, f"{RULES_FILE} is present but unreadable, so no rule can be cited: {exc}" + + +def _read_rules_schema(directory: Path) -> dict[str, Any] | None: + """Load the optional schema describing the catalog. Absent is not a problem in itself. + + Only versions from 1.0.0-rc.1 onward vendor one, and a version with a catalog but no schema + is simply left unchecked rather than reported: the missing file is the older layout, not a + defect in this one. + """ + path = directory / RULES_SCHEMA_FILE + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): - return [] + return None def _read_documents(directory: Path, label: str) -> dict[str, Any]: @@ -275,12 +332,16 @@ def load_tracked(version: str) -> MetaBundle: available = ", ".join(tracked_versions()) or "none" raise MetaSchemaError(f"meta-schema version {version!r} is not tracked (available: {available})") documents = _read_documents(directory, f"meta-schema version {version}") + catalog, catalog_error = _read_rules(directory) return MetaBundle( version=version, origin=str(directory), documents=documents, registry=_build_registry(documents), - rules=_read_rules(directory), + rules=(catalog or {}).get("rules", []), + rules_document=catalog, + rules_schema=_read_rules_schema(directory), + rules_error=catalog_error, ) @@ -342,12 +403,16 @@ def load_remote(offline: bool = False, timeout: float = 10.0) -> MetaBundle: # The stamp is provenance for the report, so a corrupt one must not fail the run. with contextlib.suppress(OSError, json.JSONDecodeError, KeyError): origin = f"{target} (fetched {json.loads(stamp.read_text(encoding='utf-8'))['fetched']})" + catalog, catalog_error = _read_rules(target) return MetaBundle( version=REMOTE, origin=origin, documents=documents, registry=_build_registry(documents), - rules=_read_rules(target), + rules=(catalog or {}).get("rules", []), + rules_document=catalog, + rules_schema=_read_rules_schema(target), + rules_error=catalog_error, ) diff --git a/tests/test_validation/test_meta_store.py b/tests/test_validation/test_meta_store.py index b2fc1bc..f1e28c9 100644 --- a/tests/test_validation/test_meta_store.py +++ b/tests/test_validation/test_meta_store.py @@ -4,6 +4,7 @@ import hashlib import json +from dataclasses import replace from pathlib import Path import pytest @@ -112,6 +113,61 @@ def test_bundle_self_check_is_clean(): assert bundle.self_check() == [] +def test_the_newest_version_vendors_a_schema_for_its_rule_catalogue(): + """Without it the catalogue is data the validator trusts with nothing checking it.""" + bundle = load_tracked(latest_version()) + assert bundle.rules, "the newest tracked version should ship a rule catalogue" + assert bundle.rules_schema, "and the schema describing it, vendored from the same source" + + +def test_a_damaged_catalogue_is_reported_rather_than_read_as_a_shorter_specification(): + """The failure this schema exists for, and the reason it is not merely nice to have. + + A truncated catalogue is indistinguishable from a specification that states fewer rules: the + checks enforcing the missing ones skip, each saying the version never stated its rule, and the + run passes. Every one of those messages is false. `meta.self-check` is what contradicts them. + """ + bundle = load_tracked(latest_version()) + damaged = json.loads(json.dumps(bundle.rules_document)) + damaged["rules"] = damaged["rules"][:5] + damaged["rules"][0]["text_sha256"] = "deadbeef" + + problems = replace(bundle, rules_document=damaged)._catalog_problems() + assert problems, "a corrupted catalogue passed self-check" + assert any("text_sha256" in p for p in problems), problems + + +def test_an_unreadable_catalogue_is_reported_not_swallowed(tmp_path): + """Loading stays lenient so validation continues; the problem surfaces as a finding.""" + (tmp_path / meta_store.RULES_FILE).write_text("{ not json", encoding="utf-8") + catalog, error = meta_store._read_rules(tmp_path) + assert catalog is None + assert error and meta_store.RULES_FILE in error + + +def test_a_version_without_a_catalogue_schema_still_loads(): + """0.7.0 and 0.8.0 predate both files. Absence is the older layout, not a defect.""" + for version in tracked_versions(): + bundle = load_tracked(version) + if bundle.rules_schema is None: + assert bundle.self_check() == [], f"{version} must load clean without a catalogue schema" + + +def test_the_fixture_slice_records_the_release_it_came_from(): + """The tag is data, and it belongs beside the other provenance rather than in prose. + + `tests/data/oold/README.md` claimed v0.8.0 for a full release after the slice had already + moved to v1.0.0-rc.1, because a vendoring updated the files and not the sentence describing + them. A compliance fixture asserts the rules of the version that introduced it, so a slice and + a meta-schema from different releases produce failures that say nothing about this code. + """ + fixtures = meta_store.load_index()["fixtures"] + assert fixtures["tag"] == f"v{tracked_versions()[-1]}", ( + "tests/data/oold/ and the newest tracked meta-schema version must come from one release; " + "refresh the slice (see its README) or vendor the matching version" + ) + + def test_bundle_exposes_the_three_documents(): bundle = load_tracked(latest_version()) assert bundle.meta["$id"] From 92524af7fd721819d8d0874757d23dcfbdc6ba92 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Thu, 6 Aug 2026 14:17:24 +0200 Subject: [PATCH 19/29] docs: fix the vendoring procedure and say what a new check owes - meta/README.md now lists all five files to copy, including the catalogue and its schema added in 1.0.0-rc.1 - CONTRIBUTING.md now documents `predates_catalog` and its default - Clarifies that a new rule.* check needs no broken-fixture test but does need a corpus schema that exercises its predicate - Fixture README no longer states the slice's tag, now recorded in index.json --- CONTRIBUTING.md | 31 +++++++++++++++++++++++++---- src/oold/validation/meta/README.md | 32 +++++++++++++++++++++++------- tests/data/oold/README.md | 21 ++++++++++++++------ 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53f20a9..8f9a9c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,7 +113,7 @@ The check id, a short description, the rule it enforces, and the predicate are w Use a `rule.*` check id: `lint.*`, `schema.*` and `roundtrip.*` are the checks carried over from the reference harness, and several of them already cite a rule. -The predicate returns a list of problem strings, empty when the schema conforms. Three things +The predicate returns a list of problem strings, empty when the schema conforms. Four things about it are easy to get wrong: - **Judge the resolved context, not the literal one.** `ContextView` is what the term definitions @@ -125,9 +125,32 @@ about it are easy to get wrong: - **Prefer skipping to guessing.** A rule absent from the selected version's catalogue is skipped automatically. If a rule is only partially decidable, check the part you are sure of; a false positive costs far more than a missed finding, because it teaches people to ignore the output. - -Then add tests to `tests/test_validation/test_check_registry.py` - one schema that conforms and one -that violates. A check that only ever sees valid input is not known to fire at all. +- **Leave `predates_catalog` alone.** It defaults to `False`, which is right for a new rule. + Setting it `True` claims the requirement is older than the catalogue itself, and makes the check + run against 0.7.0 and 0.8.0, which ship no catalogue and never stated your rule. It is reserved + for the four checks carried over from the reference harness. This one fails silently in the + wrong direction: nothing breaks, the old versions are simply judged by a rule that postdates + them. `test_predates_catalog_is_exactly_what_runs_under_a_pre_catalogue_version` is the guard. + +### What the check owes in tests + +Unit tests in `tests/test_validation/test_check_registry.py` are the obligation: one schema that +conforms and one that violates. A check that only ever sees valid input is not known to fire at +all. A `rule.*` predicate is a pure function of `(schema, ContextView)`, so a test constructs the +`ContextView` directly and there is nothing else to arrange. + +A fixture under `tests/data/oold/broken/` is **not** expected of a `rule.*` check, and none of the +existing ones has one. Those fixtures exist for checks whose verdict depends on machinery a unit +test cannot stub - `schema.meta` compiling a meta-schema, `roundtrip.generated` making a real RDF +round trip, `context.predicates` running a real JSON-LD expansion. Add one only if your check is of +that kind. + +There is a third obligation neither of those covers, and it is the one that has actually gone +missing: **at least one schema in `tests/data/oold/` must exercise the predicate through the +pipeline.** Isolated unit tests prove the predicate is correct, never that it is reached with a +correctly resolved `ContextView`. If the corpus gives your check nothing to judge, it passes +everywhere and proves nothing; extend a fixture until it does. `remote_context/Leaf.schema.json` +carries a `required` for exactly this reason. Finally, confirm the gap actually closed: diff --git a/src/oold/validation/meta/README.md b/src/oold/validation/meta/README.md index 5d33e0d..1f12593 100644 --- a/src/oold/validation/meta/README.md +++ b/src/oold/validation/meta/README.md @@ -6,12 +6,17 @@ one schema can be checked against several meta-schema versions in a single run. ``` meta/ -├── index.json provenance: upstream tag, commit, checksums +├── index.json provenance: upstream tag, commit, checksums, and the fixture slice's tag ├── 0.7.0/ oold-meta-schema.json, oold-pattern-lint.schema.json, oold-ui-meta-schema.json -├── 0.8.0/ same three files; `latest` resolves here +├── 0.8.0/ the same three files +├── 1.0.0-rc.1/ those three, plus oold-rules.json and the oold-rules.schema.json describing it └── / ``` +Which version `latest` resolves to is deliberately not written down here. It is the highest one +present, decided by `tracked_versions()`, and `oold meta list` prints it. A hand-maintained copy of +a derived fact only rots: this line used to name 0.8.0 and was still naming it two versions later. + Nothing here is written at runtime. `--meta remote` fetches the unreleased `main` state into the user cache (`~/.cache/oold/meta/`, or `OOLD_CACHE_DIR`) and never touches this folder, so a released version cannot change meaning behind your back. @@ -21,9 +26,9 @@ version cannot change meaning behind your back. When oold-schema cuts a release, from a checkout of it: ```bash -V=0.8.0 +V=1.0.0 mkdir -p src/oold/validation/meta/$V -for f in oold-meta-schema oold-pattern-lint.schema oold-ui-meta-schema; do +for f in oold-meta-schema oold-pattern-lint.schema oold-ui-meta-schema oold-rules oold-rules.schema; do git -C ../oold-schema show v$V:meta/$f.json > src/oold/validation/meta/$V/$f.json done sha256sum src/oold/validation/meta/$V/*.json @@ -31,14 +36,25 @@ git -C ../oold-schema rev-parse v$V git -C ../oold-schema log -1 --format=%cI v$V ``` +**Five files, not three.** `oold-rules.json` is the catalogue of normative statements and +`oold-rules.schema.json` describes it; both arrived in 1.0.0-rc.1. A version predating them ships +only the first three, so drop the last two from the loop for such a version. Listing only the three +meta-schemas here once cost a vendoring the catalogue entirely, which is silent: findings simply +stop citing rules and every `rule.*` check skips as though the version had stated nothing. + Extract from the **tag**, not from the working tree. The two diverge: at the time 0.7.0 was added, `main` had already changed all three files, including the canonical `$id` domain. +The catalogue is the one exception, and only while it is unreleased. `1.0.0-rc.1`'s copy comes from +an oold-schema branch because no tag carries one yet; when that happens, record the branch and +commit under `rules_source` so the provenance is still exact. Never do this for a meta-schema. + Then add an entry to `index.json` with the tag, commit, commit date, the `$id` base in use for that release, and the checksums. -Finally refresh the fixture slice in `tests/data/oold/` from the **same tag** (see its README), so -that fixtures and meta-schemas always come from one release, and confirm both still pass: +Finally refresh the fixture slice in `tests/data/oold/` from the **same tag** (see its README) and +update `fixtures.tag` in `index.json` to match, so that fixtures and meta-schemas always come from +one release. Then confirm both still pass: ```bash uv run oold validate tests/data/oold --offline --meta all @@ -47,7 +63,9 @@ make validate && uv run pytest tests/test_validation -q Keeping the two in step is not cosmetic. A compliance fixture asserts the lint rules of the release that introduced them, so a newer fixture set combined with an older meta-schema fails in ways that -say nothing about the code. +say nothing about the code. `fixtures.tag` is what makes the pairing checkable rather than a habit: +`test_the_fixture_slice_records_the_release_it_came_from` compares it against the newest tracked +version, because this step has been skipped before and prose did not notice. ## Why `id_base` is recorded and not assumed diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md index 9d5abb5..693e0d5 100644 --- a/tests/data/oold/README.md +++ b/tests/data/oold/README.md @@ -1,20 +1,29 @@ # OO-LD test fixtures -A snapshot of [oold-schema](https://github.com/OO-LD/oold-schema) `examples/`, taken at tag -**v0.8.0** - the same release the newest tracked meta-schemas in -`src/oold/validation/meta/0.8.0/` come from. +A snapshot of [oold-schema](https://github.com/OO-LD/oold-schema) `examples/`, taken at the release +that `src/oold/validation/meta/index.json` records under `fixtures.tag` - always the newest version +tracked beside it. That pairing matters. A compliance fixture asserts the lint rules of the version that introduced them, so combining a newer fixture set with an older meta-schema produces failures that say nothing about this code. Upstream's current `main` is covered instead by the opt-in parity tests (`tests/test_validation/test_parity_live.py`), which validate against `--meta remote`. +The tag is recorded in `index.json` rather than written here on purpose. It used to be stated in +this paragraph, and a vendoring updated the fixture files without updating the sentence describing +them, so the README claimed v0.8.0 for a full release while the slice was v1.0.0-rc.1. Nothing +noticed, because prose is not checked. +`test_the_fixture_slice_records_the_release_it_came_from` now checks the recorded value. + ``` -. examples/ from v0.8.0, plus compliance/ +. examples/ from the recorded tag, plus compliance/ broken/ deliberately broken schemas: the checks must fail on these remote_context/ a schema whose @context chain leaves its directory ``` +Only the top level and `compliance/` are the upstream snapshot. `broken/` and `remote_context/` +are written here, exist in no oold-schema release, and the refresh below never touches them. + `remote_context/Leaf.schema.json` requires `name`, and that is deliberate: `name` is defined only in the remote `../Thing.schema.json`, while Leaf's own inline `@context` defines just `nickname`. Any check that reads `schema["@context"]` instead of the resolved context reports a @@ -25,10 +34,10 @@ judging the literal context rather than the resolved one. ## Refreshing the snapshot When a new oold-schema version is tracked in `src/oold/validation/meta/`, refresh this slice from -the *same tag* so the two stay in step: +the *same tag* so the two stay in step, then record that tag as `fixtures.tag` in `index.json`: ```bash -V=0.8.0 +V=$(uv run python -c "from oold.validation.meta_store import latest_version; print(latest_version())") DEST=tests/data/oold for f in $(git -C ../oold-schema ls-tree --name-only v$V examples/ | grep '\.json$'); do git -C ../oold-schema show "v$V:$f" > "$DEST/$(basename $f)" From 6589527cc9791b76bf6fbebc43bc1c6fed25b7b5 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 12 Aug 2026 16:22:10 +0200 Subject: [PATCH 20/29] feat(validation): vendor reshaped catalogue, and enforce its new rules - Rule ids move to a minted hex suffix (e.g. OOLD-RT-002 -> OOLD-RT-08f2); all 14 registry-cited ids remapped - `checkable` renamed to `machine_checkable` throughout, including checkable_rules() and the CLI label - Four new checks: rule.uuid (OOLD-VER-edb9), rule.multilang-default (OOLD-EXT-dd76), rule.base-alignment (OOLD-CMP-53bf), rule.scoped-context (OOLD-CMP-5266) - rule.scoped-context only flags an embed arriving by $ref to another document, not a self-reference - Verdicts unchanged; parity holds at 6/6 --- CLAUDE.md | 2 +- CONTRIBUTING.md | 16 +- src/oold/validation/check_registry.py | 168 +++++- src/oold/validation/cli.py | 13 +- src/oold/validation/mcp_server.py | 8 +- .../meta/1.0.0-rc.1/oold-rules.json | 560 +++++++++++------- .../meta/1.0.0-rc.1/oold-rules.schema.json | 17 +- src/oold/validation/meta/index.json | 8 +- src/oold/validation/meta_store.py | 4 +- src/oold/validation/pipeline.py | 12 +- src/oold/validation/report.py | 2 +- tests/data/oold/README.md | 1 + .../broken/base_uri_misaligned.schema.json | 16 + tests/test_validation/test_check_registry.py | 115 +++- tests/test_validation/test_pipeline.py | 4 + tests/test_validation/test_rules.py | 62 +- 16 files changed, 695 insertions(+), 313 deletions(-) create mode 100644 tests/data/oold/broken/base_uri_misaligned.schema.json diff --git a/CLAUDE.md b/CLAUDE.md index 51013f7..7497ed4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ but that is the rarer case. ### Rules come from the specification, not from this code -The OO-LD spec numbers its normative statements (`OOLD-RT-002`) and publishes them as +The OO-LD spec numbers its normative statements (`OOLD-RT-08f2`) and publishes them as `oold-rules.json`, vendored per version under `src/oold/validation/meta//`. Three consequences that are easy to get wrong: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f9a9c9..000ff85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,15 +72,15 @@ uv run zensical build -s ## Translating a specification rule -The OO-LD specification numbers each of its normative statements (`OOLD-RT-002`, `OOLD-INS-004`, -...) and publishes them as `oold-rules.json`, which this repository vendors per meta-schema -version. When oold-schema adds a rule, its `make check` prints a pointer back to this section, -because a new rule is the moment the validator falls behind the specification. +The OO-LD specification numbers each of its normative statements (`OOLD-RT-08f2`, ...) and +publishes them as `oold-rules.json`, which this repository vendors per meta-schema version. When +oold-schema adds a rule, its `make check` prints a pointer back to this section, because a new +rule is the moment the validator falls behind the specification. Not every rule becomes a check, so start by reading it: ```bash -uv run oold rules explain OOLD-RT-002 +uv run oold rules explain OOLD-RT-08f2 uv run oold rules list --unchecked # everything still waiting for a check ``` @@ -88,8 +88,8 @@ uv run oold rules list --unchecked # everything still waiting for a check | `applies_to` | Meaning | Action | | --- | --- | --- | -| `document` + `checkable: true` | Decidable by looking at a schema or instance | Add a check, as below | -| `document`, not `checkable` | Binds documents but needs human judgement | Nothing; it stays listed as unchecked | +| `document` + `machine_checkable: true` | Decidable by looking at a schema or instance | Add a check, as below | +| `document`, not `machine_checkable` | Binds documents but needs human judgement | Nothing; it stays listed as unchecked | | `implementation` | Constrains what the library *does*, which no validator can see | A test against the library, not a `CheckInfo` | | `advisory` | Guidance only | Nothing | @@ -104,7 +104,7 @@ def _missing_id(schema: dict[str, Any], context: ContextView) -> list[str]: CHECKS = ( - CheckInfo("rule.id", "a schema has a $id", rule="OOLD-VER-001", per_version=True, run=_missing_id), + CheckInfo("rule.id", "a schema has a $id", rule="OOLD-VER-3b96", per_version=True, run=_missing_id), ... ) ``` diff --git a/src/oold/validation/check_registry.py b/src/oold/validation/check_registry.py index 442ea91..0d81f55 100644 --- a/src/oold/validation/check_registry.py +++ b/src/oold/validation/check_registry.py @@ -1,7 +1,7 @@ """The registry of every check id the validator can emit. Two identifier systems appear in a finding: the check id (``lint.container``) names which check -in this package produced it, and the rule id (``OOLD-RT-002``) names the normative statement it +in this package produced it, and the rule id (``OOLD-RT-08f2``) names the normative statement it enforces, when there is one. Rule ids come from the specification and are permanent; check ids are implementation-defined and follow this package's structure. A finding cites both, because fourteen of the twenty-eight checks enforce no rule at all - `schema.meta` is definitional, @@ -28,6 +28,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from typing import Any +from urllib.parse import urljoin from .compliance import run_suite, vocabulary_coverage from .frame import collect_composed_properties, instance_rdf_types @@ -45,6 +46,10 @@ #: check matches the file name rather than any single URL. _OOLD_META = re.compile(r"oold-meta-schema\.json$") +#: A canonical, hyphenated UUID, optionally prefixed with the `urn:uuid:` scheme. Version and +#: variant bits are not checked - the rule asks for "a UUID value", not a version 4 UUID. +_UUID = re.compile(r"^(?:urn:uuid:)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + @dataclass class RuleFinding: @@ -274,6 +279,109 @@ def _processing_mode_not_declared(schema: dict[str, Any], context: ContextView) return [f"@version is {value!r}; it must be the JSON number 1.1, not a string"] +def _uuid_annotation_missing_or_invalid(schema: dict[str, Any], context: ContextView) -> list[str]: + """`x-oold-uuid`, if present, must actually be a UUID.""" + value = schema.get("x-oold-uuid") + if value is None: + return ["schema declares no x-oold-uuid annotation"] + if not isinstance(value, str) or not _UUID.match(value): + return [f"x-oold-uuid is {value!r}, which is not a UUID"] + return [] + + +def _multilang_missing_default(schema: dict[str, Any], context: ContextView) -> list[str]: + """A schema using a multilingual keyword must still carry the plain default it falls back to. + + Only fires when the multilingual keyword is actually used - the word "still" in the rule is + what scopes it; a schema using neither keyword says nothing about localization at all. + """ + problems = [] + if "x-oold-multilang-title" in schema and "title" not in schema: + problems.append("x-oold-multilang-title is present but the schema declares no default title") + if "x-oold-multilang-description" in schema and "description" not in schema: + problems.append("x-oold-multilang-description is present but the schema declares no default description") + return problems + + +def _base_uri_misaligned(schema: dict[str, Any], context: ContextView) -> list[str]: + """`$id` and the resolved `@base` should resolve a relative reference to the same place. + + Only judged when both are actually present: with no `@base` there is nothing on the JSON-LD + side to compare against, and guessing which of the two is "correct" is not this check's job. + """ + schema_id = schema.get("$id") + if not isinstance(schema_id, str) or not schema_id: + return [] + base = context.keyword("@base") + if not base: + return [] + + probe = "Sibling.schema.json" + under_schema = urljoin(schema_id, probe) + under_jsonld = urljoin(urljoin(schema_id, base), probe) + if under_schema == under_jsonld: + return [] + return [ + f"$id ({schema_id!r}) and the resolved @base ({base!r}) are not aligned: a relative " + f"reference resolves to {under_schema!r} under $id but {under_jsonld!r} under @base" + ] + + +def _embedded_ref_missing_scoped_context(schema: dict[str, Any], context: ContextView) -> list[str]: + """An object embedded by `$ref` to another document should get a scoped `@context`. + + Deliberately narrow: an inline `type: object` embed is not flagged, because the rule's own + paragraph permits flattening those terms onto the root context for a cyclic embed graph, and + this check cannot tell a cyclic graph from a careless one without resolving remote schemas. A + `$ref` to the schema's own `$id` is exempted for the same reason - a self-reference cannot be + given a scoped remote context without recursing. + """ + own_id = schema.get("$id") + problems = [] + for name, prop in collect_composed_properties(schema).items(): + target = _ref_embed_target(prop) + if target is None: + continue + if own_id and target == own_id: + continue + definition = context.terms.get(name) + if definition is None: + continue # no term at all: a different check covers ungrounded predicates + if not isinstance(definition, dict) or "@context" in definition: + continue + problems.append( + f"{name!r} brings in an embedded object by $ref ({target}) but its term declares no " + "scoped @context, so the embedded schema's terms resolve globally. If the embed graph " + "is cyclic this may be deliberate: the specification allows flattening onto the root " + "context in that case." + ) + return problems + + +def _ref_embed_target(prop: Any) -> str | None: + """The `$ref` target when a property, or the `items` of an array property, embeds by reference. + + Only a direct `$ref`, or one nested inside a single-entry `allOf`, counts - the shapes that + unambiguously mean "this property's value is another schema document". `x-oold-range` is not + walked into: it denotes a scalar reference, not an embedded object. + """ + if not isinstance(prop, dict): + return None + target = _ref_target(prop) + if target: + return target + return _ref_target(prop.get("items")) if isinstance(prop.get("items"), dict) else None + + +def _ref_target(node: dict[str, Any]) -> str | None: + if isinstance(node.get("$ref"), str): + return node["$ref"] + branches = node.get("allOf") + if isinstance(branches, list) and len(branches) == 1 and isinstance(branches[0], dict): + return _ref_target(branches[0]) + return None + + # ---------------------------------------------------------------------------- the registry @@ -335,7 +443,7 @@ class CheckInfo: CheckInfo( "lint.pattern", "no @context term coerces a literal to a datatype JSON encodes natively", - rule="OOLD-RT-001", + rule="OOLD-RT-d9bd", per_version=True, detects=_lint_pattern, predates_catalog=True, @@ -343,14 +451,14 @@ class CheckInfo: CheckInfo( "lint.container", "a strictly array-typed property declares @container @set or @list", - rule="OOLD-RT-002", + rule="OOLD-RT-08f2", detects=array_properties_missing_container, predates_catalog=True, ), CheckInfo( "lint.iri-format", "a bare-IRI-string reference declares an iri-reference or stricter uri* format", - rule="OOLD-EXT-006", + rule="OOLD-EXT-6ea3", default_status=WARN, detects=iri_references_missing_format, predates_catalog=True, @@ -378,7 +486,7 @@ class CheckInfo: CheckInfo( "context.predicates", "every declared property produces a grounded predicate", - rule="OOLD-EXT-007", + rule="OOLD-EXT-2b61", detects=check_predicates, predates_catalog=True, ), @@ -425,7 +533,7 @@ class CheckInfo: ), CheckInfo( "coverage.rules", - "every checkable rule in the catalog is enforced by some check", + "every machine-checkable rule in the catalog is enforced by some check", default_status=WARN, per_version=True, # Compares the catalogue against this very registry; there is no external function to @@ -446,73 +554,101 @@ class CheckInfo: CheckInfo( "rule.id", "a schema has a $id", - rule="OOLD-VER-001", + rule="OOLD-VER-3b96", per_version=True, run=_missing_id, ), CheckInfo( "rule.id-fragment", "a $id has no non-empty fragment", - rule="OOLD-CMP-005", + rule="OOLD-CMP-dd2b", per_version=True, run=_id_has_fragment, ), CheckInfo( "rule.range-ref", "x-oold-range references use x-oold-ref", - rule="OOLD-EXT-005", + rule="OOLD-EXT-3fe9", per_version=True, run=_range_uses_ref, ), CheckInfo( "rule.instance-type", "a pinned type agrees with x-oold-instance-rdf-type", - rule="OOLD-INS-002", + rule="OOLD-INS-4b5c", per_version=True, run=_inline_type_disagrees, ), CheckInfo( "rule.free-text-iri", "a free-text range is not coerced to @id", - rule="OOLD-INS-009", + rule="OOLD-INS-2e5d", per_version=True, run=_free_text_range_coerced_to_iri, ), CheckInfo( "rule.closed-object", "a closed object still permits $schema and @context", - rule="OOLD-INS-005", + rule="OOLD-INS-ba9e", per_version=True, run=_closed_object_rejects_metadata, ), CheckInfo( "rule.version", "a schema states x-oold-version", - rule="OOLD-VER-002", + rule="OOLD-VER-3662", per_version=True, run=_missing_version, ), CheckInfo( "rule.id-alias", "@id is exposed through an alias", - rule="OOLD-INS-007", + rule="OOLD-INS-2b3f", per_version=True, run=_id_not_aliased, ), CheckInfo( "rule.dialect", "a schema declares the OO-LD dialect", - rule="OOLD-EXT-002", + rule="OOLD-EXT-5184", per_version=True, run=_dialect_not_declared, ), CheckInfo( "rule.processing-mode", "a context declares @version 1.1", - rule="OOLD-EXT-001", + rule="OOLD-EXT-ddda", per_version=True, run=_processing_mode_not_declared, ), + CheckInfo( + "rule.uuid", + "a schema carries an x-oold-uuid annotation holding a UUID value", + rule="OOLD-VER-edb9", + per_version=True, + run=_uuid_annotation_missing_or_invalid, + ), + CheckInfo( + "rule.multilang-default", + "a schema using x-oold-multilang-title/description also declares the plain default", + rule="OOLD-EXT-dd76", + per_version=True, + run=_multilang_missing_default, + ), + CheckInfo( + "rule.base-alignment", + "a schema's $id and resolved @base resolve a relative reference the same way", + rule="OOLD-CMP-53bf", + per_version=True, + run=_base_uri_misaligned, + ), + CheckInfo( + "rule.scoped-context", + "an embedded object brought in by $ref is reflected as that property's scoped @context", + rule="OOLD-CMP-5266", + per_version=True, + run=_embedded_ref_missing_scoped_context, + ), ) diff --git a/src/oold/validation/cli.py b/src/oold/validation/cli.py index 5bc349d..1063a6f 100644 --- a/src/oold/validation/cli.py +++ b/src/oold/validation/cli.py @@ -235,7 +235,7 @@ def rules_group() -> None: @click.option( "--unchecked", is_flag=True, - help="Only checkable rules that no check enforces yet, which is the coverage gap.", + help="Only machine-checkable rules that no check enforces yet, which is the coverage gap.", ) @_json_option def rules_list(meta, offline: bool, area: str | None, unchecked: bool, as_json: bool) -> None: @@ -248,7 +248,7 @@ def rules_list(meta, offline: bool, area: str | None, unchecked: bool, as_json: rules = [r for r in rules if r["area"].upper() == area.upper()] if unchecked: enforced = set(rule_map().values()) - rules = [r for r in bundle.checkable_rules() if r["id"] not in enforced] + rules = [r for r in bundle.machine_checkable_rules() if r["id"] not in enforced] if as_json: click.echo(json.dumps(rules, indent=2)) @@ -279,7 +279,10 @@ def rules_explain(rule_id: str, meta, offline: bool, as_json: bool) -> None: from .check_registry import rule_map bundle = _rules_bundle(meta, offline) - rule = bundle.rule(rule_id.upper()) + # Case-insensitive by comparing both sides upper, rather than upper-casing rule_id alone: + # ids now mint a lowercase hex suffix (OOLD-RT-08f2), so `.upper()` on the query alone would + # no longer match the catalogue's own casing. + rule = next((r for r in bundle.rules if r["id"].upper() == rule_id.upper()), None) if rule is None: raise click.ClickException( f"{rule_id} is not in the catalog for meta-schema {bundle.version}. " @@ -296,7 +299,7 @@ def rules_explain(rule_id: str, meta, offline: bool, as_json: bool) -> None: click.echo(f" area {rule['area']}") click.echo(f" applies to {rule['applies_to']}") click.echo( - f" checkable {rule['checkable']}" + f" machine-checkable {rule['machine_checkable']}" + (f" (enforced by {enforced_by[rule['id']]})" if rule["id"] in enforced_by else "") ) click.echo(f" since {rule['since']}") @@ -331,7 +334,7 @@ def checks_group() -> None: """Look up the checks this validator can run. Check ids (``lint.container``, ``rule.id-fragment``) name which check produced a finding; - rule ids (``OOLD-RT-002``, see ``oold rules``) name the specification requirement it + rule ids (``OOLD-RT-08f2``, see ``oold rules``) name the specification requirement it enforces, when it enforces one at all. The two are not peers: see ``specs/2026-08-04-check-registry-design.md`` for why. """ diff --git a/src/oold/validation/mcp_server.py b/src/oold/validation/mcp_server.py index e7d0436..13efe19 100644 --- a/src/oold/validation/mcp_server.py +++ b/src/oold/validation/mcp_server.py @@ -259,7 +259,7 @@ def list_oold_rules( ) -> dict[str, Any]: """List the normative rules the specification defines, and which checks enforce them. - Every validation finding cites a rule id such as OOLD-RT-002; this resolves those ids to the + Every validation finding cites a rule id such as OOLD-RT-08f2; this resolves those ids to the requirement text, its level (MUST / SHOULD / ...), and the specification URL. Use it to explain a finding, or with unenforced_only to see which requirements the validator does not yet check. @@ -268,7 +268,7 @@ def list_oold_rules( meta: Meta-schema versions to read the catalog from. The catalog was introduced upstream after 0.8.0, so ["remote"] may be needed until a release ships it. area: Restrict to one area, e.g. RT (round-trip), CMP (composition), INS (instances). - unenforced_only: Only checkable rules that no check enforces yet. + unenforced_only: Only machine-checkable rules that no check enforces yet. offline: Never fetch over the network. """ from .check_registry import rule_map @@ -289,7 +289,7 @@ def list_oold_rules( } enforced_by = {v: k for k, v in rule_map().items()} - rules = bundle.checkable_rules() if unenforced_only else bundle.rules + rules = bundle.machine_checkable_rules() if unenforced_only else bundle.rules if area: rules = [r for r in rules if r["area"].upper() == area.upper()] if unenforced_only: @@ -310,7 +310,7 @@ def list_oold_checks( """List the checks this validator can run, mirroring list_oold_rules for check ids. A finding cites two identifiers: the check id (e.g. lint.container) names which check in this - validator produced it, the rule id (e.g. OOLD-RT-002, see list_oold_rules) names the + validator produced it, the rule id (e.g. OOLD-RT-08f2, see list_oold_rules) names the specification requirement it enforces, when it enforces one at all. Use unmapped_only to see the checks that enforce no rule - these are this validator's own methodology (satisfiability, round-trip, self-tests about the fixture suite) rather than a numbered requirement. diff --git a/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json index 76673f7..aebb60d 100644 --- a/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json +++ b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json @@ -18,315 +18,337 @@ }, "rules": [ { - "id": "OOLD-CMP-001", + "id": "OOLD-CMP-1d7e", "area": "CMP", "level": "MUST NOT", "applies_to": "document", - "section": "composition", - "summary": "A schema must be usable as a JSON-LD context with no further processing, so every $ref is reflected in the @context.", - "text": "It MUST NOT be required to further process an OO-LD schema document in order to interpret it as a JSON-LD context. This implies that all occurrences of `$ref` in the schema are reflected in the JSON-LD context. An embedded object reached under an object-valued property - one whose value is an object, directly (`type: object`) or as the `items` of an array (`type: array`), whether inlined or brought in by `$ref` - SHOULD be reflected as that property's scoped JSON-LD context, so its terms resolve only under that property and cannot conflict with a same-named term elsewhere. That scoped context MAY reference the embedded schema remotely (by URL) or carry its terms inline. Where the embed graph is cyclic - a value type whose scoped context transitively references itself through remote schema files - JSON-LD processors cannot resolve the recursive remote contexts (see [](#round-trip)); breaking the cycle requires migrating the remote reference to a local (inline) context - inlining the term definitions so there is no remote hop to recurse - which MAY be flattened onto the root context as a shared vocabulary. Moving the remote reference to the root does not break the cycle; only replacing it with local definitions does. A `$ref` at the root level of the OO-LD schema is listed at the root of the JSON-LD context. (A scalar reference - a property whose value is an IRI string, not an embedded object - carries its target type in [`x-oold-range`](#range-of-properties), not a `$ref`, and so contributes no scoped context.) In case of multiple `$ref` within `allOf` the corresponding remote contexts are merged into an array-valued `@context` (see [](#merging-remote-contexts)). For `oneOf` / `anyOf` this requires care to avoid conflicts. At any time the importing OO-LD schema MAY define its own or override the imported JSON-LD context.", - "text_sha256": "e9c7b033426df2b69444b5bc87fad3e95d9a0076c027c473854660d48e8b00a8", - "checkable": true, + "section": "merging-remote-contexts", + "summary": "Reflected oneOf/anyOf branch contexts must not map the same keyword to different IRIs at the root.", + "text": "The remote contexts of `oneOf` / `anyOf` branches MAY also be reflected into the `@context`, but they MUST NOT conflict at the root - they MUST NOT map the same keyword to different IRIs there.", + "text_sha256": "2bb32571967d138ce1b962cd0951dbf255af2de7c7bf2a2dbfd42718017dc18e", + "context": "`oneOf` / `anyOf`. The remote contexts of `oneOf` / `anyOf` branches MAY also be reflected into the `@context`, but they MUST NOT conflict at the root - they MUST NOT map the same keyword to different IRIs there. A JSON-LD processor merges all listed contexts (most-recently-wins) and has no notion of which branch a given instance matched, so a root-level conflict would be decided by context order rather than by the branch the data conforms to.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "06-composition.md:5" + "source": "06-composition.md:49" }, { - "id": "OOLD-CMP-002", + "id": "OOLD-CMP-5266", "area": "CMP", - "level": "MUST", + "level": "SHOULD", "applies_to": "document", - "section": "merging-remote-contexts", - "summary": "A schema with multiple $refs must list their remote contexts as an array, in allOf order.", - "text": "Multiple `$ref` (e.g. in `allOf`) each correspond to a remote context. By the reflection rule above, the schema's own `@context` MUST list those remote contexts as an array, in the same order as the `allOf` members, so the schema stays usable as a context without further processing. A JSON-LD processor then resolves that array in order, later entries overriding earlier ones - duplicate context terms are overridden using a most-recently-defined-wins mechanism (JSONLD11-API, Context Processing Algorithm). The schema MAY append its own context object as the last array entry to override an inherited term. The single-context `@import` keyword is an alternative only when exactly one remote context is wrapped and locally modified (it cannot contain a nested `@import`), so the array form is used for the multi-`$ref` case.", - "text_sha256": "d15b85d655e00efb9b2dabd2ecd9af008279874dacc158d073fdc4042818a061", - "checkable": true, + "section": "composition", + "summary": "An embedded object property should be reflected as that property's scoped JSON-LD context.", + "text": "An embedded object reached under an object-valued property - one whose value is an object, directly (`type: object`) or as the `items` of an array (`type: array`), whether inlined or brought in by `$ref` - SHOULD be reflected as that property's scoped JSON-LD context, so its terms resolve only under that property and cannot conflict with a same-named term elsewhere.", + "text_sha256": "62cca7366f12688f2708768b351e60662cb4704c3039f2c7f9243446ec9d21a6", + "context": "It MUST NOT be required to further process an OO-LD schema document in order to interpret it as a JSON-LD context. This implies that all occurrences of `$ref` in the schema are reflected in the JSON-LD context. An embedded object reached under an object-valued property - one whose value is an object, directly (`type: object`) or as the `items` of an array (`type: array`), whether inlined or brought in by `$ref` - SHOULD be reflected as that property's scoped JSON-LD context, so its terms resolve only under that property and cannot conflict with a same-named term elsewhere. That scoped context MAY reference the embedded schema remotely (by URL) or carry its terms inline. Where the embed graph is cyclic - a value type whose scoped context transitively references itself through remote schema files - JSON-LD processors cannot resolve the recursive remote contexts (see [](#round-trip)); breaking the cycle requires migrating the remote reference to a local (inline) context - inlining the term definitions so there is no remote hop to recurse - which MAY be flattened onto the root context as a shared vocabulary. Moving the remote reference to the root does not break the cycle; only replacing it with local definitions does. A `$ref` at the root level of the OO-LD schema is listed at the root of the JSON-LD context. (A scalar reference - a property whose value is an IRI string, not an embedded object - carries its target type in [`x-oold-range`](#range-of-properties), not a `$ref`, and so contributes no scoped context.) In case of multiple `$ref` within `allOf` the corresponding remote contexts are merged into an array-valued `@context` (see [](#merging-remote-contexts)). For `oneOf` / `anyOf` this requires care to avoid conflicts. At any time the importing OO-LD schema MAY define its own or override the imported JSON-LD context.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "06-composition.md:47" + "source": "06-composition.md:5" }, { - "id": "OOLD-CMP-003", + "id": "OOLD-CMP-53bf", "area": "CMP", - "level": "MUST NOT", + "level": "SHOULD", "applies_to": "document", "section": "merging-remote-contexts", - "summary": "Reflected oneOf/anyOf branch contexts must not map the same keyword to different IRIs at the root.", - "text": "`oneOf` / `anyOf`. The remote contexts of `oneOf` / `anyOf` branches MAY also be reflected into the `@context`, but they MUST NOT conflict at the root - they MUST NOT map the same keyword to different IRIs there. A JSON-LD processor merges all listed contexts (most-recently-wins) and has no notion of which branch a given instance matched, so a root-level conflict would be decided by context order rather than by the branch the data conforms to.", - "text_sha256": "c89bc6e726c69bfe3a1e17705a43d68a0d5b9dc48320126c47301b773333e47b", - "checkable": true, + "summary": "A schema's JSON Schema and JSON-LD base URIs should be aligned so a relative reference resolves the same under both.", + "text": "Relative references resolve against the schema's `$id` (the JSON Schema base URI) and, on the JSON-LD side, against `@base` / the retrieval URL; these base URIs SHOULD be aligned so a relative reference resolves to the same absolute URL under both.", + "text_sha256": "47730120a068a22fa8d75d91b9797166ad500f332b8b84e06f82476d15296511", + "context": "Independent references and base URIs. A JSON Schema `$ref` and a JSON-LD `@context` entry are independent references: they MAY point to the same document (the typical OO-LD case, where one document is both a schema and a context) or to different documents - for example a plain JSON Schema referenced via `$ref` together with a separate remote `@context` that supplies the semantics. Relative references resolve against the schema's `$id` (the JSON Schema base URI) and, on the JSON-LD side, against `@base` / the retrieval URL; these base URIs SHOULD be aligned so a relative reference resolves to the same absolute URL under both. `$id` MUST NOT contain a non-empty fragment (JSONSCHEMA §8.2.1).", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "06-composition.md:49" + "source": "06-composition.md:74" }, { - "id": "OOLD-CMP-004", + "id": "OOLD-CMP-a05a", "area": "CMP", "level": "MUST", "applies_to": "document", "section": "merging-remote-contexts", "summary": "A scoped context that must apply only to the immediate node sets @propagate false; contexts in one array share it.", - "text": "Propagation (`@propagate`). A `$ref` inside a `type: object` property is reflected as a property-scoped context, which by default propagates into the whole subtree rooted at that property (\"By default ... contexts propagate across node objects, other than for type-scoped contexts, which default to false\"). Where a referenced context should apply only to the immediate node, the schema MUST set `\"@propagate\": false` on that scoped context.", - "text_sha256": "6dd7a2e03456e05cc665f8e0289244a8ebbd6a24f54fcbaa945974612cf56285", - "checkable": true, + "text": "Where a referenced context should apply only to the immediate node, the schema MUST set `\"@propagate\": false` on that scoped context.", + "text_sha256": "c9059a4861176c0efa21e2f497d557b096d3105ca0cf872497191c5ed80cc7e8", + "context": "Propagation (`@propagate`). A `$ref` inside a `type: object` property is reflected as a property-scoped context, which by default propagates into the whole subtree rooted at that property (\"By default ... contexts propagate across node objects, other than for type-scoped contexts, which default to false\"). Where a referenced context should apply only to the immediate node, the schema MUST set `\"@propagate\": false` on that scoped context.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, "source": "06-composition.md:70" }, { - "id": "OOLD-CMP-005", + "id": "OOLD-CMP-b926", + "area": "CMP", + "level": "MUST NOT", + "applies_to": "document", + "section": "composition", + "summary": "A schema must be usable as a JSON-LD context with no further processing, so every $ref is reflected in the @context.", + "text": "It MUST NOT be required to further process an OO-LD schema document in order to interpret it as a JSON-LD context.", + "text_sha256": "fb9e604440b34e28343756729d47c1cf923d35a93bc11354181e4cd87b069a4c", + "context": "It MUST NOT be required to further process an OO-LD schema document in order to interpret it as a JSON-LD context. This implies that all occurrences of `$ref` in the schema are reflected in the JSON-LD context. An embedded object reached under an object-valued property - one whose value is an object, directly (`type: object`) or as the `items` of an array (`type: array`), whether inlined or brought in by `$ref` - SHOULD be reflected as that property's scoped JSON-LD context, so its terms resolve only under that property and cannot conflict with a same-named term elsewhere. That scoped context MAY reference the embedded schema remotely (by URL) or carry its terms inline. Where the embed graph is cyclic - a value type whose scoped context transitively references itself through remote schema files - JSON-LD processors cannot resolve the recursive remote contexts (see [](#round-trip)); breaking the cycle requires migrating the remote reference to a local (inline) context - inlining the term definitions so there is no remote hop to recurse - which MAY be flattened onto the root context as a shared vocabulary. Moving the remote reference to the root does not break the cycle; only replacing it with local definitions does. A `$ref` at the root level of the OO-LD schema is listed at the root of the JSON-LD context. (A scalar reference - a property whose value is an IRI string, not an embedded object - carries its target type in [`x-oold-range`](#range-of-properties), not a `$ref`, and so contributes no scoped context.) In case of multiple `$ref` within `allOf` the corresponding remote contexts are merged into an array-valued `@context` (see [](#merging-remote-contexts)). For `oneOf` / `anyOf` this requires care to avoid conflicts. At any time the importing OO-LD schema MAY define its own or override the imported JSON-LD context.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:5" + }, + { + "id": "OOLD-CMP-dd2b", "area": "CMP", "level": "MUST NOT", "applies_to": "document", "section": "merging-remote-contexts", "summary": "A schema $id must not contain a non-empty fragment.", - "text": "Independent references and base URIs. A JSON Schema `$ref` and a JSON-LD `@context` entry are independent references: they MAY point to the same document (the typical OO-LD case, where one document is both a schema and a context) or to different documents - for example a plain JSON Schema referenced via `$ref` together with a separate remote `@context` that supplies the semantics. Relative references resolve against the schema's `$id` (the JSON Schema base URI) and, on the JSON-LD side, against `@base` / the retrieval URL; these base URIs SHOULD be aligned so a relative reference resolves to the same absolute URL under both. `$id` MUST NOT contain a non-empty fragment (JSONSCHEMA §8.2.1).", - "text_sha256": "a1726b6c66d62e77f19ac7ec7ed40e58a289000ab6bffcc15dc484eeb6d1afd6", - "checkable": true, + "text": "`$id` MUST NOT contain a non-empty fragment (JSONSCHEMA §8.2.1).", + "text_sha256": "bc845f8e67383d4802f512fbf76d46caa3873cae4b10e370cca35952b5d62c0f", + "context": "Independent references and base URIs. A JSON Schema `$ref` and a JSON-LD `@context` entry are independent references: they MAY point to the same document (the typical OO-LD case, where one document is both a schema and a context) or to different documents - for example a plain JSON Schema referenced via `$ref` together with a separate remote `@context` that supplies the semantics. Relative references resolve against the schema's `$id` (the JSON Schema base URI) and, on the JSON-LD side, against `@base` / the retrieval URL; these base URIs SHOULD be aligned so a relative reference resolves to the same absolute URL under both. `$id` MUST NOT contain a non-empty fragment (JSONSCHEMA §8.2.1).", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, "source": "06-composition.md:74" }, { - "id": "OOLD-CMP-006", + "id": "OOLD-CMP-e4a3", + "area": "CMP", + "level": "MUST", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "A schema with multiple $refs must list their remote contexts as an array, in allOf order.", + "text": "By the reflection rule above, the schema's own `@context` MUST list those remote contexts as an array, in the same order as the `allOf` members, so the schema stays usable as a context without further processing.", + "text_sha256": "7c113ef3179658fded82c089504e6f781d0c22a75a46be372fec0cf83b1748f7", + "context": "Multiple `$ref` (e.g. in `allOf`) each correspond to a remote context. By the reflection rule above, the schema's own `@context` MUST list those remote contexts as an array, in the same order as the `allOf` members, so the schema stays usable as a context without further processing. A JSON-LD processor then resolves that array in order, later entries overriding earlier ones - duplicate context terms are overridden using a most-recently-defined-wins mechanism (JSONLD11-API, Context Processing Algorithm). The schema MAY append its own context object as the last array entry to override an inherited term. The single-context `@import` keyword is an alternative only when exactly one remote context is wrapped and locally modified (it cannot contain a nested `@import`), so the array form is used for the multi-`$ref` case.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:47" + }, + { + "id": "OOLD-CMP-f3c7", "area": "CMP", "level": "MUST NOT", "applies_to": "document", "section": "merge-and-override-model", "summary": "Composition is narrow-only: a derived schema may restrict a constraint but must not relax it.", - "text": "When such a merge is required, OO-LD resolves the `allOf` chain by applying JSON Merge Patch (RFC7396) semantics: keyed by object member, most-recently-defined (most-derived) wins, and a `null` value removes a key. For the `@context` this coincides with JSON-LD's own override rule. For assertion-bearing keywords the resolved view additionally honors narrow-only composition: a derived schema MAY restrict a constraint but MUST NOT relax it, matching how code generators let a subclass tighten - never loosen - a superclass property's validation.", - "text_sha256": "0577522e552c16a3888a866cad50e69af94c65dc0d9fbf54e6d759c000256c24", - "checkable": true, + "text": "For assertion-bearing keywords the resolved view additionally honors narrow-only composition: a derived schema MAY restrict a constraint but MUST NOT relax it, matching how code generators let a subclass tighten - never loosen - a superclass property's validation.", + "text_sha256": "6e73492f741ebab68002a6728d628b42613c8f4d7adccee5e7ff92bdc61e0b84", + "context": "When such a merge is required, OO-LD resolves the `allOf` chain by applying JSON Merge Patch (RFC7396) semantics: keyed by object member, most-recently-defined (most-derived) wins, and a `null` value removes a key. For the `@context` this coincides with JSON-LD's own override rule. For assertion-bearing keywords the resolved view additionally honors narrow-only composition: a derived schema MAY restrict a constraint but MUST NOT relax it, matching how code generators let a subclass tighten - never loosen - a superclass property's validation.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, "source": "06-composition.md:80" }, { - "id": "OOLD-CNF-001", + "id": "OOLD-CNF-1120", "area": "CNF", "level": "MUST", "applies_to": "document", "section": "notation", "summary": "A conforming schema or instance must be interchangeable as JSON, canonicalized per RFC 8785.", - "text": "The normative data model of OO-LD is the JSON data model shared by JSONSCHEMA and JSON-LD11. JSON (RFC8259) is the canonical serialization: a conforming OO-LD schema or instance MUST be interchangeable as JSON, and the canonical form used for identity and integrity (for example content-hashing a versioned schema) is its JSON Canonicalization Scheme (RFC8785) serialization.", - "text_sha256": "ebd7a19c376bcfd0285729b14755a9ef9e845891ea7f715cbcd0eae9c8ea056f", - "checkable": false, + "text": "JSON (RFC8259) is the canonical serialization: a conforming OO-LD schema or instance MUST be interchangeable as JSON, and the canonical form used for identity and integrity (for example content-hashing a versioned schema) is its JSON Canonicalization Scheme (RFC8785) serialization.", + "text_sha256": "a9cdf1bd1358785e00667c7d0ce7baf6dea8e8c1ad3736385ee92243ed38a2e6", + "context": "The normative data model of OO-LD is the JSON data model shared by JSONSCHEMA and JSON-LD11. JSON (RFC8259) is the canonical serialization: a conforming OO-LD schema or instance MUST be interchangeable as JSON, and the canonical form used for identity and integrity (for example content-hashing a versioned schema) is its JSON Canonicalization Scheme (RFC8785) serialization.", + "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, "source": "03-conformance.md:9" }, { - "id": "OOLD-EXT-001", + "id": "OOLD-EXT-2b61", "area": "EXT", - "level": "SHOULD", + "level": "MUST", "applies_to": "document", - "section": "processing-mode", - "summary": "A generated context should declare @version 1.1 as a JSON number.", - "text": "Generated OO-LD contexts SHOULD therefore declare `\"@version\": 1.1` (the JSON number `1.1`, not the string `\"1.1\"`). Modern processors default to the 1.1 processing mode, so this is a guard rather than a strict requirement: it prevents a JSON-LD 1.0 processor from silently mis-processing a 1.1 document (JSON-LD11 §4.1.1). Because the first encountered `@version` entry determines the processing mode, it is sufficient to declare `\"@version\": 1.1` once in the base context of a composition (for example a root `Thing` schema).", - "text_sha256": "c8215e2a9f1b2bf8f2272e7eebcf09768f413f25222b0fa2d249b1fb1cb5fa42", - "checkable": true, + "section": "range-reference-form", + "summary": "A compact-IRI prefix used by a property must be defined in the @context.", + "text": "Compact form specifically - a `\"pattern\"` such as `\"^[A-Za-z_][\\\\w.-]:(?!//)\\\\S$\"`, which accepts `ex:alice` and `schema:Person` while rejecting `http://…`; the prefix MUST be defined in the `@context`.", + "text_sha256": "9ed092ee23b716d012311740effff835e67e883d41215e2fd27f66107a383708", + "context": "Stricter, ASCII only - `\"format\": \"uri\"` or `\"uri-reference\"`, where values are known not to use internationalized (non-ASCII) IRIs. - Compact form specifically - a `\"pattern\"` such as `\"^[A-Za-z_][\\\\w.-]:(?!//)\\\\S$\"`, which accepts `ex:alice` and `schema:Person` while rejecting `http://…`; the prefix MUST be defined in the `@context`.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:13" + "source": "09-extensions.md:327" }, { - "id": "OOLD-EXT-002", + "id": "OOLD-EXT-3fe9", "area": "EXT", - "level": "SHOULD", + "level": "MUST", "applies_to": "document", - "section": "jsonschema-extensions", - "summary": "A schema should declare the OO-LD dialect meta-schema as its $schema.", - "text": "OO-LD targets JSONSCHEMA (2020-12) as its normative dialect. An OO-LD schema SHOULD declare the OO-LD dialect meta-schema (which extends 2020-12) as its `$schema`, e.g. `\"$schema\": \"https://oo-ld.org/latest/meta/oold-meta-schema.json\"` - pinning a specific version (e.g. `.../0.4.0/meta/oold-meta-schema.json`) for reproducibility. Declaring the plain 2020-12 meta-schema (`https://json-schema.org/draft/2020-12/schema`) remains valid for tools that only understand standard JSON Schema.", - "text_sha256": "6bd4e71c2522bb54cd27f845d954f8ed1398059918f641863dd62dbc1994ed8f", - "checkable": true, + "section": "range-of-properties", + "summary": "References inside x-oold-range must use x-oold-ref, never $ref.", + "text": "References to other schemas inside `x-oold-range` MUST use `x-oold-ref`, never `$ref` (see below).", + "text_sha256": "6626132bb2c6a394d430fb9e4db9a559c26b71914acdc07ed8fb3a4b06c88d75", + "context": "An OO-LD subschema, the most expressive form. Unions (`anyOf` / `oneOf`), intersections (`allOf`) and inline constraints can be combined to describe an anonymous subclass. References to other schemas inside `x-oold-range` MUST use `x-oold-ref`, never `$ref` (see below). The single-IRI form (1) is a shorthand for `{ \"allOf\": [ { \"x-oold-ref\": \"Organization.schema.json\" } ] }`:", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:185" + "source": "09-extensions.md:298" }, { - "id": "OOLD-EXT-003", + "id": "OOLD-EXT-436a", "area": "EXT", - "level": "REQUIRED", - "applies_to": "document", - "section": "jsonschema-extensions", - "summary": "JSON Schema 2020-12 is required as the dialect, because composition places $ref alongside sibling keywords.", - "text": "2020-12 is REQUIRED, not merely preferred: OO-LD's composition places `$ref` alongside sibling keywords (e.g. a property carrying `type`, `x-oold-range` and `@context`, or `allOf: [{$ref: ...}]` next to `properties`). Keywords adjacent to `$ref` are only evaluated from JSON Schema 2019-09 onward; in Draft 4 and Draft 7 they are ignored (JSONSCHEMA §8.2.3.1). Keywords such as `const` (used throughout this document) are likewise only available from draft-06 onward. Migration from the earlier Draft-4-style notation: rename `definitions` to `$defs`, `id` to `$id`, and use the numeric form of `exclusiveMinimum`/`exclusiveMaximum` instead of the boolean form.", - "text_sha256": "b1b8b6e6bc07d9cf85a6eed6493f55a0db0817c234da035caea43885e6428944", - "checkable": true, + "level": "SHOULD", + "applies_to": "implementation", + "section": "semantic-delivery", + "summary": "For OpenAPI 3.0, deliver the context and type per class as vendor extensions.", + "text": "For OpenAPI 3.0, which rejects unprefixed keywords in a Schema Object (and typically bundles several classes with no document root to host one `@context`), the context and type SHOULD be delivered per class as `x-jsonld-context` and `x-jsonld-type` following [REST API Linked Data Keywords](https://datatracker.ietf.org/doc/html/draft-polli-restapi-ld-keywords-08): `@context` maps to `x-jsonld-context` and `x-oold-instance-rdf-type` to `x-jsonld-type`.", + "text_sha256": "90622f946111378db3b4fed08c6981f87e452e982f9f208a562ada1ade8122da", + "context": "For OpenAPI 3.0, which rejects unprefixed keywords in a Schema Object (and typically bundles several classes with no document root to host one `@context`), the context and type SHOULD be delivered per class as `x-jsonld-context` and `x-jsonld-type` following [REST API Linked Data Keywords](https://datatracker.ietf.org/doc/html/draft-polli-restapi-ld-keywords-08): `@context` maps to `x-jsonld-context` and `x-oold-instance-rdf-type` to `x-jsonld-type`. That draft requires references inside these keywords not to be dereferenced automatically, consistent with the `x-oold-ref` rule (see [](#why-x-oold-ref)). The mapping is reversible, so such an export can be read back into an OO-LD schema.", + "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:187" + "source": "09-extensions.md:427" }, { - "id": "OOLD-EXT-004", + "id": "OOLD-EXT-5184", "area": "EXT", - "level": "MUST", + "level": "SHOULD", "applies_to": "document", - "section": "localizing-schema-annotations", - "summary": "x-oold-multilang-title/description must map BCP 47 language tags to translated strings.", - "text": "The JSON Schema annotation keywords `title` and `description` carry a single, default human-readable string used by tooling (for example for UI generation). To provide localized variants, OO-LD adds the keywords `x-oold-multilang-title` and `x-oold-multilang-description`. Their value MUST be an object whose keys are [BCP 47](https://www.rfc-editor.org/info/bcp47) language tags (e.g. `en`, `de`, `en-GB`) and whose values are the translated strings. A schema SHOULD still provide a default `title` / `description`; a consumer that has no entry for the requested language falls back to that default. These keywords localize the schema's own labels and are not interpreted as JSON-LD.", - "text_sha256": "a676fc85873bef5f2f8afc1fbba2b44ba202e0527e4884156365ff32ca79f0e5", - "checkable": true, + "section": "jsonschema-extensions", + "summary": "A schema should declare the OO-LD dialect meta-schema as its $schema.", + "text": "An OO-LD schema SHOULD declare the OO-LD dialect meta-schema (which extends 2020-12) as its `$schema`, e.g. `\"$schema\": \"https://oo-ld.org/latest/meta/oold-meta-schema.json\"` - pinning a specific version (e.g. `.../0.4.0/meta/oold-meta-schema.json`) for reproducibility.", + "text_sha256": "128c5740185a075885f4ff1aeddf90f73f951ed978e47ef6e8f1187d1e9b5753", + "context": "OO-LD targets JSONSCHEMA (2020-12) as its normative dialect. An OO-LD schema SHOULD declare the OO-LD dialect meta-schema (which extends 2020-12) as its `$schema`, e.g. `\"$schema\": \"https://oo-ld.org/latest/meta/oold-meta-schema.json\"` - pinning a specific version (e.g. `.../0.4.0/meta/oold-meta-schema.json`) for reproducibility. Declaring the plain 2020-12 meta-schema (`https://json-schema.org/draft/2020-12/schema`) remains valid for tools that only understand standard JSON Schema.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:195" + "source": "09-extensions.md:185" }, { - "id": "OOLD-EXT-005", + "id": "OOLD-EXT-61aa", "area": "EXT", - "level": "MUST", - "applies_to": "document", - "section": "range-of-properties", - "summary": "References inside x-oold-range must use x-oold-ref, never $ref.", - "text": "An OO-LD subschema, the most expressive form. Unions (`anyOf` / `oneOf`), intersections (`allOf`) and inline constraints can be combined to describe an anonymous subclass. References to other schemas inside `x-oold-range` MUST use `x-oold-ref`, never `$ref` (see below). The single-IRI form (1) is a shorthand for `{ \"allOf\": [ { \"x-oold-ref\": \"Organization.schema.json\" } ] }`:", - "text_sha256": "ec04833484058c216d9658ae580f0fe29929d3bce683e7fc739654707fc0c067", - "checkable": true, + "level": "SHOULD", + "applies_to": "implementation", + "section": "semantic-delivery", + "summary": "A consumer accepting arbitrary JSON Schema keywords should receive the native form unchanged.", + "text": "A consumer that accepts arbitrary JSON Schema keywords SHOULD receive the native form unchanged.", + "text_sha256": "aa1601aa9ebbaf66046ee3c18acd7690ee8da151843196a42f3d4c1958ed42cd", + "context": "A consumer that accepts arbitrary JSON Schema keywords SHOULD receive the native form unchanged. This covers plain JSON Schema 2020-12 validators, OpenAPI 3.1, and - because they place no restriction on `@context` - Model Context Protocol tool schemas (`inputSchema` / `outputSchema`) as well as LLM tool-use and structured-output APIs, which carry the context through and can use it as grounding.", + "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:298" + "source": "09-extensions.md:426" }, { - "id": "OOLD-EXT-006", + "id": "OOLD-EXT-6ea3", "area": "EXT", "level": "SHOULD", "applies_to": "document", "section": "range-reference-form", "summary": "An IRI-valued property should constrain its lexical form with an IRI/URI-family format.", - "text": "The value of an IRI-valued property is a JSON string. Its role as a reference comes from the `@context` (`\"@type\": \"@id\"`) and its class from `x-oold-range`. Its lexical form SHOULD be constrained with an IRI/URI-family `format` so that malformed values are rejected; the choices, from most to least permissive:", - "text_sha256": "881fe0ebc6467edebbc166318d25d826960380e619c22e5ea38a6f3352125ff0", - "checkable": true, + "text": "Its lexical form SHOULD be constrained with an IRI/URI-family `format` so that malformed values are rejected; the choices, from most to least permissive:", + "text_sha256": "a1fd73169ee2f84b0a897e74e5495e9019dd4a8ac1c763a108fc8cb02a0afe9e", + "context": "The value of an IRI-valued property is a JSON string. Its role as a reference comes from the `@context` (`\"@type\": \"@id\"`) and its class from `x-oold-range`. Its lexical form SHOULD be constrained with an IRI/URI-family `format` so that malformed values are rejected; the choices, from most to least permissive:", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, "source": "09-extensions.md:322" }, { - "id": "OOLD-EXT-007", + "id": "OOLD-EXT-af50", "area": "EXT", - "level": "MUST", + "level": "REQUIRED", "applies_to": "document", - "section": "range-reference-form", - "summary": "A compact-IRI prefix used by a property must be defined in the @context.", - "text": "Stricter, ASCII only - `\"format\": \"uri\"` or `\"uri-reference\"`, where values are known not to use internationalized (non-ASCII) IRIs. - Compact form specifically - a `\"pattern\"` such as `\"^[A-Za-z_][\\\\w.-]:(?!//)\\\\S$\"`, which accepts `ex:alice` and `schema:Person` while rejecting `http://…`; the prefix MUST be defined in the `@context`.", - "text_sha256": "69077b848bf1fe47f3dc600091208e6fe1285186bad60e9abef0d26f151f4a4d", - "checkable": true, + "section": "jsonschema-extensions", + "summary": "JSON Schema 2020-12 is required as the dialect, because composition places $ref alongside sibling keywords.", + "text": "2020-12 is REQUIRED, not merely preferred: OO-LD's composition places `$ref` alongside sibling keywords (e.g. a property carrying `type`, `x-oold-range` and `@context`, or `allOf: [{$ref: ...}]` next to `properties`).", + "text_sha256": "72547da1a2d09844c2095de159112d47feea6881c50f48ad7d968f755330748b", + "context": "2020-12 is REQUIRED, not merely preferred: OO-LD's composition places `$ref` alongside sibling keywords (e.g. a property carrying `type`, `x-oold-range` and `@context`, or `allOf: [{$ref: ...}]` next to `properties`). Keywords adjacent to `$ref` are only evaluated from JSON Schema 2019-09 onward; in Draft 4 and Draft 7 they are ignored (JSONSCHEMA §8.2.3.1). Keywords such as `const` (used throughout this document) are likewise only available from draft-06 onward. Migration from the earlier Draft-4-style notation: rename `definitions` to `$defs`, `id` to `$id`, and use the numeric form of `exclusiveMinimum`/`exclusiveMaximum` instead of the boolean form.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:327" + "source": "09-extensions.md:187" }, { - "id": "OOLD-EXT-008", + "id": "OOLD-EXT-dd76", "area": "EXT", "level": "SHOULD", - "applies_to": "implementation", - "section": "semantic-delivery", - "summary": "A consumer accepting arbitrary JSON Schema keywords should receive the native form unchanged.", - "text": "A consumer that accepts arbitrary JSON Schema keywords SHOULD receive the native form unchanged. This covers plain JSON Schema 2020-12 validators, OpenAPI 3.1, and - because they place no restriction on `@context` - Model Context Protocol tool schemas (`inputSchema` / `outputSchema`) as well as LLM tool-use and structured-output APIs, which carry the context through and can use it as grounding.", - "text_sha256": "636b1004494749d5400cc0025a1bab6403cdb4404a0aea91d6665013dc25bbb4", - "checkable": false, + "applies_to": "document", + "section": "localizing-schema-annotations", + "summary": "A schema using multilingual annotations should still provide a default title and description.", + "text": "A schema SHOULD still provide a default `title` / `description`; a consumer that has no entry for the requested language falls back to that default.", + "text_sha256": "bbbf0172cbf0c77fbe39625c8df206a570aa41f405c9e9a213152154d1e4486d", + "context": "The JSON Schema annotation keywords `title` and `description` carry a single, default human-readable string used by tooling (for example for UI generation). To provide localized variants, OO-LD adds the keywords `x-oold-multilang-title` and `x-oold-multilang-description`. Their value MUST be an object whose keys are [BCP 47](https://www.rfc-editor.org/info/bcp47) language tags (e.g. `en`, `de`, `en-GB`) and whose values are the translated strings. A schema SHOULD still provide a default `title` / `description`; a consumer that has no entry for the requested language falls back to that default. These keywords localize the schema's own labels and are not interpreted as JSON-LD.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:426" + "source": "09-extensions.md:195" }, { - "id": "OOLD-EXT-009", + "id": "OOLD-EXT-ddda", "area": "EXT", "level": "SHOULD", - "applies_to": "implementation", - "section": "semantic-delivery", - "summary": "For OpenAPI 3.0, deliver the context and type per class as vendor extensions.", - "text": "For OpenAPI 3.0, which rejects unprefixed keywords in a Schema Object (and typically bundles several classes with no document root to host one `@context`), the context and type SHOULD be delivered per class as `x-jsonld-context` and `x-jsonld-type` following [REST API Linked Data Keywords](https://datatracker.ietf.org/doc/html/draft-polli-restapi-ld-keywords-08): `@context` maps to `x-jsonld-context` and `x-oold-instance-rdf-type` to `x-jsonld-type`. That draft requires references inside these keywords not to be dereferenced automatically, consistent with the `x-oold-ref` rule (see [](#why-x-oold-ref)). The mapping is reversible, so such an export can be read back into an OO-LD schema.", - "text_sha256": "2e7d6caba59b7eafe971ca8779f73cd897b6070b3dbcbd6890f1d8994efb461d", - "checkable": false, - "since": "1.0.0-rc.1", - "deprecated": false, - "source": "09-extensions.md:427" - }, - { - "id": "OOLD-INS-001", - "area": "INS", - "level": "SHOULD", "applies_to": "document", - "section": "schema-instances", - "summary": "Instances should reference a versioned schema URL.", - "text": "Instances SHOULD use a versioned schema URL so that it is unambiguous which schema version they conform to.", - "text_sha256": "e9929e4d9ba01bc251bf092b05c886ff703d9432859d20c4c7db8dcdc6e8244d", - "checkable": true, + "section": "processing-mode", + "summary": "A generated context should declare @version 1.1 as a JSON number.", + "text": "Generated OO-LD contexts SHOULD therefore declare `\"@version\": 1.1` (the JSON number `1.1`, not the string `\"1.1\"`).", + "text_sha256": "d4435d679d95a50a4cb65f720ee08a3c8637db3652d2b8310d1d08cac0de0946", + "context": "Generated OO-LD contexts SHOULD therefore declare `\"@version\": 1.1` (the JSON number `1.1`, not the string `\"1.1\"`). Modern processors default to the 1.1 processing mode, so this is a guard rather than a strict requirement: it prevents a JSON-LD 1.0 processor from silently mis-processing a 1.1 document (JSON-LD11 §4.1.1). Because the first encountered `@version` entry determines the processing mode, it is sufficient to declare `\"@version\": 1.1` once in the base context of a composition (for example a root `Thing` schema).", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "07-schema-instances.md:17" + "source": "09-extensions.md:13" }, { - "id": "OOLD-INS-002", - "area": "INS", + "id": "OOLD-EXT-ef09", + "area": "EXT", "level": "MUST", "applies_to": "document", - "section": "semantic-type", - "summary": "An inline type must be consistent with the schema's x-oold-instance-rdf-type.", - "text": "If an inline `type` is present it MUST be consistent with the schema's `x-oold-instance-rdf-type`. Note that `@type` alone lets a consumer locate the schema (case 3 above) only when one of the type IRIs resolves to an OO-LD schema.", - "text_sha256": "168de023827ee98f1e7ab446fa83320d5143e3c7fa0c201f59ff65a6dda3dcc6", - "checkable": true, + "section": "localizing-schema-annotations", + "summary": "x-oold-multilang-title/description must map BCP 47 language tags to translated strings.", + "text": "Their value MUST be an object whose keys are [BCP 47](https://www.rfc-editor.org/info/bcp47) language tags (e.g. `en`, `de`, `en-GB`) and whose values are the translated strings.", + "text_sha256": "f283baf414a7d41c21181aab27a32af73c618fa651fb20cb8d9ad80392063cf6", + "context": "The JSON Schema annotation keywords `title` and `description` carry a single, default human-readable string used by tooling (for example for UI generation). To provide localized variants, OO-LD adds the keywords `x-oold-multilang-title` and `x-oold-multilang-description`. Their value MUST be an object whose keys are [BCP 47](https://www.rfc-editor.org/info/bcp47) language tags (e.g. `en`, `de`, `en-GB`) and whose values are the translated strings. A schema SHOULD still provide a default `title` / `description`; a consumer that has no entry for the requested language falls back to that default. These keywords localize the schema's own labels and are not interpreted as JSON-LD.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "07-schema-instances.md:107" + "source": "09-extensions.md:195" }, { - "id": "OOLD-INS-003", + "id": "OOLD-INS-1d33", "area": "INS", "level": "MUST", "applies_to": "implementation", "section": "identity", "summary": "An exported identifiable entity must carry an IRI.", - "text": "An implementation MAY use a non-IRI identifier internally, but when it exports an identifiable entity (to JSON-LD / RDF) it MUST assign an `@id` (or the aliased `id`). The `@id` SHOULD be resolvable, and it is RECOMMENDED to mint it from an autogenerated UUID - mirroring the schema's `x-oold-uuid` - e.g. `https://example.org/a1b2c3d4-1234-...`.", - "text_sha256": "b63e120752aa4612586e20f828bc0c2a624e2e29241277c90d8c0f99119ea007", - "checkable": false, + "text": "An implementation MAY use a non-IRI identifier internally, but when it exports an identifiable entity (to JSON-LD / RDF) it MUST assign an `@id` (or the aliased `id`).", + "text_sha256": "0ca31c621870dd1904ce0662b98d9c3633da1d2fa2eb149d4d7c592407a26ba3", + "context": "An implementation MAY use a non-IRI identifier internally, but when it exports an identifiable entity (to JSON-LD / RDF) it MUST assign an `@id` (or the aliased `id`). The `@id` SHOULD be resolvable, and it is RECOMMENDED to mint it from an autogenerated UUID - mirroring the schema's `x-oold-uuid` - e.g. `https://example.org/a1b2c3d4-1234-...`.", + "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, "source": "07-schema-instances.md:62" }, { - "id": "OOLD-INS-004", + "id": "OOLD-INS-1df7", "area": "INS", "level": "MUST NOT", - "applies_to": "implementation", - "section": "referencing-schema", - "summary": "A consuming side must not be assumed to hold an rdf:type-to-schema registry; exports are self-sufficient.", - "text": "An implementation MAY additionally maintain a registry mapping `rdf:type` IRIs to OO-LD schemas to resolve case 3, but such a registry MUST NOT be assumed to exist on the consuming side - so exports must be self-sufficient (see below).", - "text_sha256": "1f3874f3ae2f46ff72ae03f0f5b715296e7b637357bb3d8ac6eec656688fc17b", - "checkable": false, - "since": "1.0.0-rc.1", - "deprecated": false, - "source": "07-schema-instances.md:35" - }, - { - "id": "OOLD-INS-005", - "area": "INS", - "level": "MUST", "applies_to": "document", - "section": "referencing-schema", - "summary": "A schema closing its objects must still permit the $schema and @context members.", - "text": "Because an instance carries `$schema` and `@context` as ordinary members, an OO-LD schema that closes its objects with `additionalProperties: false` or `unevaluatedProperties: false` MUST permit these two members, or conforming instances would fail validation.", - "text_sha256": "abd2bf2e989b2e2aea4cd7af0f6f7a3d69f802e6722467da727bd915940f1b68", - "checkable": true, + "section": "value-forms", + "summary": "Under the value-form pattern a reference is written as an object and its term must not carry @type.", + "text": "References are written as objects, and the term MUST NOT carry `@type`.", + "text_sha256": "7ed52efe5f63156ec8bd6abec09bae6117bf64fead052cf226687b15def1f02c", + "context": "Value-form - a single plain term (no `@type: \"@id\"`); the value shape alone disambiguates: a bare scalar is a literal, `{ \"id\": ... }` is a reference, a typed object is embedded. References are written as objects, and the term MUST NOT carry `@type`.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "07-schema-instances.md:37" + "source": "07-schema-instances.md:138" }, { - "id": "OOLD-INS-006", + "id": "OOLD-INS-27aa", "area": "INS", "level": "SHOULD NOT", "applies_to": "implementation", "section": "referencing-schema", "summary": "A consumer should not blindly trust the schema an instance declares for itself.", - "text": "`@context` already provides a JSON-LD-native link to the schema (resolution case 2 above), so `$schema` is kept primarily for compatibility with the widespread editor and CI convention, not as a second authoritative mechanism. JSON Schema deliberately does not standardize `$schema` on instances, partly over a self-validation concern: a consumer SHOULD NOT blindly trust the schema an instance declares for itself (a crafted instance could point at a permissive schema) and remains responsible for validating against a schema it trusts.", - "text_sha256": "7814a782bc6b257c15c4769bdf97b5503af73c2def9ee965d0e36fb6809cf527", - "checkable": false, + "text": "JSON Schema deliberately does not standardize `$schema` on instances, partly over a self-validation concern: a consumer SHOULD NOT blindly trust the schema an instance declares for itself (a crafted instance could point at a permissive schema) and remains responsible for validating against a schema it trusts.", + "text_sha256": "5fe40a0c4116c197a46477bc16e26da36e667f9c1f2cdccc812a20078affaf30", + "context": "`@context` already provides a JSON-LD-native link to the schema (resolution case 2 above), so `$schema` is kept primarily for compatibility with the widespread editor and CI convention, not as a second authoritative mechanism. JSON Schema deliberately does not standardize `$schema` on instances, partly over a self-validation concern: a consumer SHOULD NOT blindly trust the schema an instance declares for itself (a crafted instance could point at a permissive schema) and remains responsible for validating against a schema it trusts.", + "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, "source": "07-schema-instances.md:39" }, { - "id": "OOLD-INS-007", + "id": "OOLD-INS-2b3f", "area": "INS", "level": "SHOULD", "applies_to": "document", @@ -334,41 +356,104 @@ "summary": "Schemas should expose @id through an aliased id property.", "text": "To keep instance keys variable-name-friendly, schemas SHOULD expose `@id` through an aliased `id` property (as with `type` -> `@type`):", "text_sha256": "4f852db64a6532e49924b789cb0fdc636d349b6990b28a6b417d10ed49e564e0", - "checkable": true, + "context": "To keep instance keys variable-name-friendly, schemas SHOULD expose `@id` through an aliased `id` property (as with `type` -> `@type`):", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, "source": "07-schema-instances.md:45" }, { - "id": "OOLD-INS-008", + "id": "OOLD-INS-2e5d", + "area": "INS", + "level": "MUST NOT", + "applies_to": "document", + "section": "value-forms", + "summary": "A property whose range includes free text must not use @type @id.", + "text": "A property whose range is references only therefore uses `@type: \"@id\"` and MAY be written as a bare IRI string; a property whose range includes free text MUST NOT use `@type: \"@id\"`.", + "text_sha256": "a25ac65edb99dd6ad5c5c7eb84e9c8c31eae9bc323fe5f241c7d8b0dfefa8948", + "context": "A single `@context` term cannot interpret a bare string as both a literal and an IRI: `@type: \"@id\"` coerces every string value to an IRI (so free text becomes an - often invalid, then dropped - IRI), while a plain term keeps every string a literal. A property whose range is references only therefore uses `@type: \"@id\"` and MAY be written as a bare IRI string; a property whose range includes free text MUST NOT use `@type: \"@id\"`.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:134" + }, + { + "id": "OOLD-INS-4b5c", + "area": "INS", + "level": "MUST", + "applies_to": "document", + "section": "semantic-type", + "summary": "An inline type must be consistent with the schema's x-oold-instance-rdf-type.", + "text": "If an inline `type` is present it MUST be consistent with the schema's `x-oold-instance-rdf-type`.", + "text_sha256": "e299166560553f9f4cc299e1da6315d09fbd878673eecdf78fce526bf6ae2f1b", + "context": "If an inline `type` is present it MUST be consistent with the schema's `x-oold-instance-rdf-type`. Note that `@type` alone lets a consumer locate the schema (case 3 above) only when one of the type IRIs resolves to an OO-LD schema.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:107" + }, + { + "id": "OOLD-INS-75c6", "area": "INS", "level": "MUST", "applies_to": "implementation", "section": "semantic-type", "summary": "Tooling exporting an instance must materialize the schema-declared rdf:type(s) as @type.", - "text": "These types live in the schema, not in the instance data, so a JSON-LD-only processor - which sees only the instance and its `@context` - cannot derive them. Therefore, when OO-LD tooling exports an instance (to JSON-LD / RDF), it MUST materialize the declared `rdf:type`(s) as an `@type` on the instance, so that the type reaches RDF without access to the schema or to a type registry.", - "text_sha256": "3261cc51ea1af8dc6d591e62a569a74944d5f1e8a878e1d51f67f1a33a2d02c6", - "checkable": false, + "text": "Therefore, when OO-LD tooling exports an instance (to JSON-LD / RDF), it MUST materialize the declared `rdf:type`(s) as an `@type` on the instance, so that the type reaches RDF without access to the schema or to a type registry.", + "text_sha256": "3363473f627d54f6a732c599af8d3692ac0ba421a6b2770dff6347173934402c", + "context": "These types live in the schema, not in the instance data, so a JSON-LD-only processor - which sees only the instance and its `@context` - cannot derive them. Therefore, when OO-LD tooling exports an instance (to JSON-LD / RDF), it MUST materialize the declared `rdf:type`(s) as an `@type` on the instance, so that the type reaches RDF without access to the schema or to a type registry.", + "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, "source": "07-schema-instances.md:78" }, { - "id": "OOLD-INS-009", + "id": "OOLD-INS-9416", "area": "INS", - "level": "MUST NOT", + "level": "SHOULD", "applies_to": "document", - "section": "value-forms", - "summary": "A property whose range includes free text must not use @type @id.", - "text": "A single `@context` term cannot interpret a bare string as both a literal and an IRI: `@type: \"@id\"` coerces every string value to an IRI (so free text becomes an - often invalid, then dropped - IRI), while a plain term keeps every string a literal. A property whose range is references only therefore uses `@type: \"@id\"` and MAY be written as a bare IRI string; a property whose range includes free text MUST NOT use `@type: \"@id\"`.", - "text_sha256": "26ba5dd6a3a0b998a0d191907742c08f82ece9ccc76278dd6ac08bf5b61c707d", - "checkable": true, + "section": "schema-instances", + "summary": "Instances should reference a versioned schema URL.", + "text": "Instances SHOULD use a versioned schema URL so that it is unambiguous which schema version they conform to.", + "text_sha256": "e9929e4d9ba01bc251bf092b05c886ff703d9432859d20c4c7db8dcdc6e8244d", + "context": "Instances SHOULD use a versioned schema URL so that it is unambiguous which schema version they conform to.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "07-schema-instances.md:134" + "source": "07-schema-instances.md:17" + }, + { + "id": "OOLD-INS-ba9e", + "area": "INS", + "level": "MUST", + "applies_to": "document", + "section": "referencing-schema", + "summary": "A schema closing its objects must still permit the $schema and @context members.", + "text": "Because an instance carries `$schema` and `@context` as ordinary members, an OO-LD schema that closes its objects with `additionalProperties: false` or `unevaluatedProperties: false` MUST permit these two members, or conforming instances would fail validation.", + "text_sha256": "abd2bf2e989b2e2aea4cd7af0f6f7a3d69f802e6722467da727bd915940f1b68", + "context": "Because an instance carries `$schema` and `@context` as ordinary members, an OO-LD schema that closes its objects with `additionalProperties: false` or `unevaluatedProperties: false` MUST permit these two members, or conforming instances would fail validation.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:37" }, { - "id": "OOLD-INS-010", + "id": "OOLD-INS-cd80", + "area": "INS", + "level": "SHOULD", + "applies_to": "document", + "section": "identity", + "summary": "An instance @id should be resolvable, and is recommended to be minted from an autogenerated UUID.", + "text": "The `@id` SHOULD be resolvable, and it is RECOMMENDED to mint it from an autogenerated UUID - mirroring the schema's `x-oold-uuid` - e.g. `https://example.org/a1b2c3d4-1234-...`.", + "text_sha256": "c6075abdc17f9bb557fca8e7cc0d67b4ebc4af17797cb1ce28c4b6f8035cc89f", + "context": "An implementation MAY use a non-IRI identifier internally, but when it exports an identifiable entity (to JSON-LD / RDF) it MUST assign an `@id` (or the aliased `id`). The `@id` SHOULD be resolvable, and it is RECOMMENDED to mint it from an autogenerated UUID - mirroring the schema's `x-oold-uuid` - e.g. `https://example.org/a1b2c3d4-1234-...`.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:62" + }, + { + "id": "OOLD-INS-da1a", "area": "INS", "level": "SHOULD", "applies_to": "advisory", @@ -376,97 +461,104 @@ "summary": "A model ecosystem should adopt one of the two ambiguous-range patterns consistently.", "text": "For a property whose range mixes free text with references and/or embedded objects (for example `Text | PostalAddress | Place`), two patterns keep the instance round-trippable (see [](#round-trip)); a model ecosystem SHOULD adopt one of them consistently:", "text_sha256": "fd5834cb52f2ba7a41d5919db177c636bb08de85e3d8484c246a8c70e88fe921", - "checkable": false, + "context": "For a property whose range mixes free text with references and/or embedded objects (for example `Text | PostalAddress | Place`), two patterns keep the instance round-trippable (see [](#round-trip)); a model ecosystem SHOULD adopt one of them consistently:", + "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, "source": "07-schema-instances.md:136" }, { - "id": "OOLD-INS-011", + "id": "OOLD-INS-f010", "area": "INS", "level": "MUST NOT", - "applies_to": "document", - "section": "value-forms", - "summary": "Under the value-form pattern a reference is written as an object and its term must not carry @type.", - "text": "Value-form - a single plain term (no `@type: \"@id\"`); the value shape alone disambiguates: a bare scalar is a literal, `{ \"id\": ... }` is a reference, a typed object is embedded. References are written as objects, and the term MUST NOT carry `@type`.", - "text_sha256": "bb09381c42aa4a7cc0471a5ab4e57ee3fd2c06249d3c332d50a1dff8c638c315", - "checkable": true, - "since": "1.0.0-rc.1", - "deprecated": false, - "source": "07-schema-instances.md:138" - }, - { - "id": "OOLD-RT-001", - "area": "RT", - "level": "MUST NOT", - "applies_to": "document", - "section": "value-forms", - "summary": "A term must not coerce a literal to a datatype JSON-LD produces by default from a native JSON value (xsd:string, xsd:boolean, xsd:integer, xsd:double).", - "text": "A term MUST NOT declare `@type` with a datatype that JSON-LD produces by default from a native JSON value: `xsd:string` (from a string), `xsd:boolean` (from a boolean), `xsd:integer` (from an integer number), and `xsd:double` (from a fractional number). These are exactly the datatypes reconstruction converts back to native JSON values without an `@type` (JSONLD11-API, RDF to Object Conversion; see [](#round-trip)): the value arrives from RDF with no datatype, and a term is never selected against a conflicting or absent type mapping (JSONLD11-API, Term Selection), so the value reappears under the full predicate IRI instead. Coercing to one of these is redundant and lossy - a native JSON number already round-trips as `xsd:integer` or `xsd:double` with no coercion at all, and a boolean/string likewise. This is inherent to the compaction algorithm, not a tooling limitation; such terms are left plain (no `@type`), and the projection to RDF still yields the correct datatype from the native JSON type (JSONLD11-API, Data Round Tripping). The behaviour assumes reconstruction with native types (`useNativeTypes`), the mainstream default: a processor that instead keeps every literal as a typed value object would select the coerced term, but then plain native numbers and booleans no longer return as native JSON either (they come back as `{ \"@value\": ..., \"@type\": ... }` objects), which defeats the structural model - so native-type reconstruction is assumed throughout.", - "text_sha256": "954c75f41845b3c0ac622d8dbd710157319d250aedc19bce76eb0abc4332d1d1", - "checkable": true, + "applies_to": "implementation", + "section": "referencing-schema", + "summary": "A consuming side must not be assumed to hold an rdf:type-to-schema registry; exports are self-sufficient.", + "text": "An implementation MAY additionally maintain a registry mapping `rdf:type` IRIs to OO-LD schemas to resolve case 3, but such a registry MUST NOT be assumed to exist on the consuming side - so exports must be self-sufficient (see below).", + "text_sha256": "1f3874f3ae2f46ff72ae03f0f5b715296e7b637357bb3d8ac6eec656688fc17b", + "context": "An implementation MAY additionally maintain a registry mapping `rdf:type` IRIs to OO-LD schemas to resolve case 3, but such a registry MUST NOT be assumed to exist on the consuming side - so exports must be self-sufficient (see below).", + "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, - "source": "07-schema-instances.md:164" + "source": "07-schema-instances.md:35" }, { - "id": "OOLD-RT-002", + "id": "OOLD-RT-08f2", "area": "RT", "level": "MUST", "applies_to": "document", "section": "round-trip", "summary": "A strictly array-typed property must declare @container @set or @list.", - "text": "Multi-valued properties are set-valued in RDF: order is not preserved, duplicates are removed, and a single value compacts to a scalar. Because the reconstruction MUST re-validate, a property that is strictly an array (JSON Schema `type: \"array\"`) MUST declare `@container: \"@set\"` (or `\"@list\"`): without it a single-element array returns as a scalar and violates the `array` type. A property that also permits a scalar (an `anyOf`/`oneOf` of a literal and an array) MAY declare it for a stable array shape, but need not - the scalar form still validates, and a single value and a one-element array are JSON-LD-equivalent. Round-trip equality is set equality; use `@list` only where order is significant, at the cost of merge and query ergonomics.", - "text_sha256": "1f7adf73c70d7fb0ea1419d03bba2cac5dd99560fe2e4f9ec5511ce54fd8f28b", - "checkable": true, + "text": "Because the reconstruction MUST re-validate, a property that is strictly an array (JSON Schema `type: \"array\"`) MUST declare `@container: \"@set\"` (or `\"@list\"`): without it a single-element array returns as a scalar and violates the `array` type.", + "text_sha256": "cccd90d1135476689792616dac8db9b85956567b4d689637621b77cdbec356f5", + "context": "Multi-valued properties are set-valued in RDF: order is not preserved, duplicates are removed, and a single value compacts to a scalar. Because the reconstruction MUST re-validate, a property that is strictly an array (JSON Schema `type: \"array\"`) MUST declare `@container: \"@set\"` (or `\"@list\"`): without it a single-element array returns as a scalar and violates the `array` type. A property that also permits a scalar (an `anyOf`/`oneOf` of a literal and an array) MAY declare it for a stable array shape, but need not - the scalar form still validates, and a single value and a one-element array are JSON-LD-equivalent. Round-trip equality is set equality; use `@list` only where order is significant, at the cost of merge and query ergonomics.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, "source": "07-schema-instances.md:180" }, { - "id": "OOLD-RT-005", + "id": "OOLD-RT-d376", "area": "RT", "level": "SHOULD", "applies_to": "document", "section": "round-trip", "summary": "The embed graph formed by scoped @contexts should be acyclic.", - "text": "An embedded object is mapped by a scoped `@context` on its property (referencing the embedded type's own context). These scoped contexts form an embed graph between schemas, and that graph SHOULD be acyclic: model a property whose value is an independent entity, or whose type would close an embed cycle (a type embedding itself, or two types embedding each other), as a reference - `@type: \"@id\"` plus `x-oold-range`, with no scoped `@context` - rather than an embed. This is the linked-data analog of using a pointer instead of inlining a recursive data structure. A self-reference through the top-level `@context` (a property that nests the same type but carries no scoped context, so the global context maps the nested keys - e.g. a `Process` with sub-`Process`es) is not part of this graph and round-trips normally, bounded by the instance's actual depth.", - "text_sha256": "abd98388126428c7de580af27d61a97dd38f8cce9680445e12a63467f779ddb4", - "checkable": true, + "text": "These scoped contexts form an embed graph between schemas, and that graph SHOULD be acyclic: model a property whose value is an independent entity, or whose type would close an embed cycle (a type embedding itself, or two types embedding each other), as a reference - `@type: \"@id\"` plus `x-oold-range`, with no scoped `@context` - rather than an embed.", + "text_sha256": "c8d004af368510bff41449b8c6432b829dcf78f4966aff541cbe7c508a60ed52", + "context": "An embedded object is mapped by a scoped `@context` on its property (referencing the embedded type's own context). These scoped contexts form an embed graph between schemas, and that graph SHOULD be acyclic: model a property whose value is an independent entity, or whose type would close an embed cycle (a type embedding itself, or two types embedding each other), as a reference - `@type: \"@id\"` plus `x-oold-range`, with no scoped `@context` - rather than an embed. This is the linked-data analog of using a pointer instead of inlining a recursive data structure. A self-reference through the top-level `@context` (a property that nests the same type but carries no scoped context, so the global context maps the nested keys - e.g. a `Process` with sub-`Process`es) is not part of this graph and round-trips normally, bounded by the instance's actual depth.", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, "source": "07-schema-instances.md:187" }, { - "id": "OOLD-SCH-001", + "id": "OOLD-RT-d9bd", + "area": "RT", + "level": "MUST NOT", + "applies_to": "document", + "section": "value-forms", + "summary": "A term must not coerce a literal to a datatype JSON-LD produces by default from a native JSON value (xsd:string, xsd:boolean, xsd:integer, xsd:double).", + "text": "A term MUST NOT declare `@type` with a datatype that JSON-LD produces by default from a native JSON value: `xsd:string` (from a string), `xsd:boolean` (from a boolean), `xsd:integer` (from an integer number), and `xsd:double` (from a fractional number).", + "text_sha256": "b32b762e810a70e38f25329563c43687ce6402c92f464eb8764ccaceb9376c54", + "context": "A term MUST NOT declare `@type` with a datatype that JSON-LD produces by default from a native JSON value: `xsd:string` (from a string), `xsd:boolean` (from a boolean), `xsd:integer` (from an integer number), and `xsd:double` (from a fractional number). These are exactly the datatypes reconstruction converts back to native JSON values without an `@type` (JSONLD11-API, RDF to Object Conversion; see [](#round-trip)): the value arrives from RDF with no datatype, and a term is never selected against a conflicting or absent type mapping (JSONLD11-API, Term Selection), so the value reappears under the full predicate IRI instead. Coercing to one of these is redundant and lossy - a native JSON number already round-trips as `xsd:integer` or `xsd:double` with no coercion at all, and a boolean/string likewise. This is inherent to the compaction algorithm, not a tooling limitation; such terms are left plain (no `@type`), and the projection to RDF still yields the correct datatype from the native JSON type (JSONLD11-API, Data Round Tripping). The behaviour assumes reconstruction with native types (`useNativeTypes`), the mainstream default: a processor that instead keeps every literal as a typed value object would select the coerced term, but then plain native numbers and booleans no longer return as native JSON either (they come back as `{ \"@value\": ..., \"@type\": ... }` objects), which defeats the structural model - so native-type reconstruction is assumed throughout.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:164" + }, + { + "id": "OOLD-SCH-a9ee", "area": "SCH", "level": "MUST NOT", "applies_to": "implementation", "section": "basic-concepts", "summary": "An OO-LD schema document must not be interpreted as a JSON-LD document.", - "text": "An OO-LD schema is consumed as a JSON-LD remote context (referenced by its URL from an instance's `@context`), never as a JSON-LD document. OO-LD schema documents MUST NOT be interpreted as JSON-LD documents, because that would apply the schema's own `@context` to the schema itself and produce incorrect triples.", - "text_sha256": "1dc261ae9c95bcd88a08eef6376f64063c7f893cb1d06a4de5c110856f75cb11", - "checkable": false, + "text": "OO-LD schema documents MUST NOT be interpreted as JSON-LD documents, because that would apply the schema's own `@context` to the schema itself and produce incorrect triples.", + "text_sha256": "635a77aac991bbe8295616c5465c2963e0d6a51618ed49c2d835d448dc53bfca", + "context": "An OO-LD schema is consumed as a JSON-LD remote context (referenced by its URL from an instance's `@context`), never as a JSON-LD document. OO-LD schema documents MUST NOT be interpreted as JSON-LD documents, because that would apply the schema's own `@context` to the schema itself and produce incorrect triples.", + "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, "source": "05-basic-concepts.md:11" }, { - "id": "OOLD-VER-001", + "id": "OOLD-VER-2e63", "area": "VER", - "level": "MUST", + "level": "SHOULD", "applies_to": "document", "section": "identification", - "summary": "A schema must have a $id serving as its global unique identifier.", - "text": "OO-LD schemas MUST have a `$id` (JSONSCHEMA §8.2.1) which works as a global and unique identifier of the schema. The value of `$id` MAY be an absolute URI (details below). The schema SHOULD be resolvable via this URI. The schema SHOULD have an annotation `x-oold-uuid` with a UUID value.", - "text_sha256": "1f48402923618b3933db2579c4cc92891bc7bdf5e095e5b12487119fc7bf6705", - "checkable": true, + "summary": "A schema should be resolvable via its $id.", + "text": "The schema SHOULD be resolvable via this URI.", + "text_sha256": "fd7f4ef994bb7fea2782f7c30ee8f8c2a3f9b8161c61ced1f2f038a64f106c2c", + "context": "OO-LD schemas MUST have a `$id` (JSONSCHEMA §8.2.1) which works as a global and unique identifier of the schema. The value of `$id` MAY be an absolute URI (details below). The schema SHOULD be resolvable via this URI. The schema SHOULD have an annotation `x-oold-uuid` with a UUID value.", + "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, "source": "08-identification-versioning.md:5" }, { - "id": "OOLD-VER-002", + "id": "OOLD-VER-3662", "area": "VER", "level": "SHOULD", "applies_to": "document", @@ -474,13 +566,29 @@ "summary": "A schema version should be stated with x-oold-version.", "text": "The schema version SHOULD be indicated by `x-oold-version`; a prior version MAY be indicated with `x-oold-prior-version`:", "text_sha256": "7d62b2cbfa7d78f02f91d1e08fa0ac97e07385088b1b5da426e9f46dea77961a", - "checkable": true, + "context": "The schema version SHOULD be indicated by `x-oold-version`; a prior version MAY be indicated with `x-oold-prior-version`:", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, "source": "08-identification-versioning.md:49" }, { - "id": "OOLD-VER-003", + "id": "OOLD-VER-3b96", + "area": "VER", + "level": "MUST", + "applies_to": "document", + "section": "identification", + "summary": "A schema must have a $id serving as its global unique identifier.", + "text": "OO-LD schemas MUST have a `$id` (JSONSCHEMA §8.2.1) which works as a global and unique identifier of the schema.", + "text_sha256": "0489dab8d39ad1fbe8057598def7a10fa00816ece4c6482b9e0de23145c82a3b", + "context": "OO-LD schemas MUST have a `$id` (JSONSCHEMA §8.2.1) which works as a global and unique identifier of the schema. The value of `$id` MAY be an absolute URI (details below). The schema SHOULD be resolvable via this URI. The schema SHOULD have an annotation `x-oold-uuid` with a UUID value.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:5" + }, + { + "id": "OOLD-VER-534a", "area": "VER", "level": "SHOULD", "applies_to": "document", @@ -488,10 +596,26 @@ "summary": "The schema version should be part of the schema location URL.", "text": "The version SHOULD be part of the schema's location:", "text_sha256": "9e4671a42c0df7845c72b1cb55c9723532573150183495259835ccfab7f4d6e2", - "checkable": true, + "context": "The version SHOULD be part of the schema's location:", + "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, "source": "08-identification-versioning.md:63" + }, + { + "id": "OOLD-VER-edb9", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "identification", + "summary": "A schema should carry an x-oold-uuid annotation holding a UUID value.", + "text": "The schema SHOULD have an annotation `x-oold-uuid` with a UUID value.", + "text_sha256": "0d1901b754364d33a17411f33fe61ba98d469d1df7389adab8f0a44c9276f355", + "context": "OO-LD schemas MUST have a `$id` (JSONSCHEMA §8.2.1) which works as a global and unique identifier of the schema. The value of `$id` MAY be an absolute URI (details below). The schema SHOULD be resolvable via this URI. The schema SHOULD have an annotation `x-oold-uuid` with a UUID value.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:5" } ] } diff --git a/src/oold/validation/meta/1.0.0-rc.1/oold-rules.schema.json b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.schema.json index f39027f..755d6dc 100644 --- a/src/oold/validation/meta/1.0.0-rc.1/oold-rules.schema.json +++ b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.schema.json @@ -85,7 +85,7 @@ "summary", "text", "text_sha256", - "checkable", + "machine_checkable", "since", "deprecated", "source" @@ -94,8 +94,8 @@ "properties": { "id": { "type": "string", - "pattern": "^OOLD-(CNF|SCH|CMP|INS|RT|VER|EXT)-[0-9]{3}$", - "description": "Permanent and never reused. Downstream checks cite it, so the pattern is asserted rather than assumed; see meta/RULES.md." + "pattern": "^OOLD-(CNF|SCH|CMP|INS|RT|VER|EXT)-[0-9a-f]{4}$", + "description": "Permanent and never reused. The suffix is a minted hex value, not a sequential number, so a deprecated rule leaves no visible gap. Downstream checks cite the id, so the pattern is asserted rather than assumed; see meta/RULES.md." }, "area": { "$ref": "#/$defs/area" @@ -134,9 +134,14 @@ "pattern": "^[0-9a-f]{64}$", "description": "sha256 of `text`. meta/rules-baseline.json compares against this to catch a rule whose meaning changed under an unchanged id, so its shape is asserted here." }, - "checkable": { + "context": { + "type": "string", + "minLength": 1, + "description": "The containing block `text` was taken from, for display. May equal `text` when the rule's sentence is the whole block. Not hashed, and not part of the baseline comparison." + }, + "machine_checkable": { "type": "boolean", - "description": "Whether the requirement is decidable by inspecting a document. Defaults to true for `document` rules only." + "description": "Whether the requirement is mechanically decidable by inspecting a document. Defaults to true for `document` rules only. This says nothing about whether any given validator actually enforces it - that is a separate, downstream fact." }, "since": { "$ref": "#/$defs/version", @@ -150,7 +155,7 @@ "minItems": 1, "items": { "type": "string", - "pattern": "^OOLD-(CNF|SCH|CMP|INS|RT|VER|EXT)-[0-9]{3}$" + "pattern": "^OOLD-(CNF|SCH|CMP|INS|RT|VER|EXT)-[0-9a-f]{4}$" }, "description": "Present only on a deprecated rule, naming what replaced it." }, diff --git a/src/oold/validation/meta/index.json b/src/oold/validation/meta/index.json index 91701b3..de81837 100644 --- a/src/oold/validation/meta/index.json +++ b/src/oold/validation/meta/index.json @@ -43,18 +43,18 @@ "added": "2026-08-04", "id_base": "https://oo-ld.org/latest/meta/", "prerelease": true, - "notes": "First version to carry oold-rules.json, the catalogue of normative statements, and oold-rules.schema.json, which describes it. The three meta-schemas are verbatim from the v1.0.0-rc.1 tag. The catalogue is provisional: no release has shipped one yet, so it comes from the oold-schema branch feat/rule-catalog-rc1 instead, and rules_source records which commit. The v1.0.0-rc.1 tag itself will never gain a catalogue, so there is nothing here to refresh from a tag. When a release does ship one, vendor that version as its own entry rather than back-filling this one.", + "notes": "First version to carry oold-rules.json, the catalogue of normative statements, and oold-rules.schema.json, which describes it. The three meta-schemas are verbatim from the v1.0.0-rc.1 tag. The catalogue is provisional: no release has shipped one yet, so it comes from the oold-schema branch feat/rule-catalog-rc1 instead, and rules_source records which commit. The v1.0.0-rc.1 tag itself will never gain a catalogue, so there is nothing here to refresh from a tag. When a release does ship one, vendor that version as its own entry rather than back-filling this one. Refreshed to a later feat/rule-catalog-rc1 commit that mints hex rule ids (OOLD-RT-08f2 rather than OOLD-RT-002), renames checkable to machine_checkable, adds a context field, and narrows text to the single normative sentence.", "rules_source": { "branch": "feat/rule-catalog-rc1", - "commit": "c83583d0d37de8452843e0fb86de113dea1b6b9f", + "commit": "3ebbcd85e829760052e3fda6858f05c7a483ee4a", "released": false }, "sha256": { "oold-meta-schema.json": "cad3151c6bf0ac3e74acd46a4fee59b9287a551a9f62aa68aa7e2a718f360dbc", "oold-pattern-lint.schema.json": "d89fce19cd2fd42fa740d92968fcf61a1764ea25e741ed5cd4e72040a45c9a86", "oold-ui-meta-schema.json": "dd389d13a5e03268d4a4ff845dec7f4f28238f7edbd9fe0992399b37ac358212", - "oold-rules.json": "cb4b8c6ba198971469095a3932e8b9d50be9448d3f0f2185e92ba76cbcbbdc3e", - "oold-rules.schema.json": "6fbe9914625a8f1f2ffed4a7e70bdd0f47af3e35b435e47437527cfe47593630" + "oold-rules.json": "1d9bae4ffac7c500725793b5745f32d0d450d4226707ef390656a2193ba7418e", + "oold-rules.schema.json": "71e0d2e437d05a0a718612ed273993c3e216681c6c4cd426a6b3c1018f07e07a" } } }, diff --git a/src/oold/validation/meta_store.py b/src/oold/validation/meta_store.py index fbf1acd..76dde75 100644 --- a/src/oold/validation/meta_store.py +++ b/src/oold/validation/meta_store.py @@ -168,7 +168,7 @@ def rule(self, rule_id: str) -> dict[str, Any] | None: """Look up one rule, or None when this version ships no catalog or lacks the id.""" return next((r for r in self.rules if r["id"] == rule_id), None) - def checkable_rules(self) -> list[dict[str, Any]]: + def machine_checkable_rules(self) -> list[dict[str, Any]]: """Rules a validator can enforce by inspecting a document. `implementation` rules constrain a library rather than a document, and `advisory` ones @@ -177,7 +177,7 @@ def checkable_rules(self) -> list[dict[str, Any]]: return [ r for r in self.rules - if r.get("checkable") and r.get("applies_to") == "document" and not r.get("deprecated") + if r.get("machine_checkable") and r.get("applies_to") == "document" and not r.get("deprecated") ] @property diff --git a/src/oold/validation/pipeline.py b/src/oold/validation/pipeline.py index ef3276b..892f28e 100644 --- a/src/oold/validation/pipeline.py +++ b/src/oold/validation/pipeline.py @@ -703,12 +703,12 @@ def run_compliance(path: str | Path, options: Options | None = None) -> Report: def _check_rule_coverage(run: _Run, target: str, bundle: MetaBundle) -> None: - """Report which checkable rules this validator actually enforces. + """Report which machine-checkable rules this validator actually enforces. Both directions are reported as a warning rather than a failure, for different reasons. - An unenforced checkable rule is the gap the catalog exists to expose; failing on it would - block every run on requirements nobody has implemented a check for yet. + An unenforced machine-checkable rule is the gap the catalog exists to expose; failing on it + would block every run on requirements nobody has implemented a check for yet. A mapped id the catalog does not contain looks like a dangling reference, but it is ambiguous: it is equally what a *older* meta version looks like, one minted before that rule @@ -728,12 +728,12 @@ def _check_rule_coverage(run: _Run, target: str, bundle: MetaBundle) -> None: mapped = set(rule_map().values()) unknown = sorted({r for r in mapped if not bundle.rule(r)}) - checkable = bundle.checkable_rules() + checkable = bundle.machine_checkable_rules() missing = sorted(r["id"] for r in checkable if r["id"] not in mapped) notes: list[str] = [] if missing: - notes.append(f"{len(missing)}/{len(checkable)} checkable rule(s) have no check: " + ", ".join(missing)) + notes.append(f"{len(missing)}/{len(checkable)} machine-checkable rule(s) have no check: " + ", ".join(missing)) if unknown: notes.append( f"{len(unknown)} mapped rule id(s) absent from this catalog (newer than " @@ -754,6 +754,6 @@ def _check_rule_coverage(run: _Run, target: str, bundle: MetaBundle) -> None: "coverage.rules", target, OK, - f"all {len(checkable)} checkable rules are enforced", + f"all {len(checkable)} machine-checkable rules are enforced", meta_version=bundle.version, ) diff --git a/src/oold/validation/report.py b/src/oold/validation/report.py index 24d2d7d..0c97327 100644 --- a/src/oold/validation/report.py +++ b/src/oold/validation/report.py @@ -40,7 +40,7 @@ class Check: message: str = "" detail: dict[str, Any] = field(default_factory=dict) meta_version: str | None = None - #: The normative rule this check enforces, e.g. ``OOLD-RT-002``. None when the check maps to + #: The normative rule this check enforces, e.g. ``OOLD-RT-08f2``. None when the check maps to #: no single requirement, or when the meta version in use predates the rule catalog. rule: str | None = None diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md index 693e0d5..5020579 100644 --- a/tests/data/oold/README.md +++ b/tests/data/oold/README.md @@ -62,3 +62,4 @@ Each one exists to prove a specific check fires, rather than only that valid inp | `xsd_string_coercion` | `lint.pattern` - a term coercing a literal to `xsd:string` never round-trips | | `array_without_container` | `lint.container` - a strict array without `@container: @set` | | `iri_reference_without_format` | `lint.iri-format` (warns, does not fail) - a bare-IRI-string reference with no `iri-reference`/`uri*` format | +| `base_uri_misaligned` | `rule.base-alignment` (warns, does not fail) - an `@base` that resolves a relative reference somewhere other than `$id` does | diff --git a/tests/data/oold/broken/base_uri_misaligned.schema.json b/tests/data/oold/broken/base_uri_misaligned.schema.json new file mode 100644 index 0000000..846b997 --- /dev/null +++ b/tests/data/oold/broken/base_uri_misaligned.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://example.org/schemas/base_uri_misaligned.schema.json", + "title": "BaseUriMisaligned", + "@context": { + "@base": "https://example.org/other/", + "ex": "https://example.org/", + "name": "ex:name" + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/tests/test_validation/test_check_registry.py b/tests/test_validation/test_check_registry.py index 05cbf72..4325487 100644 --- a/tests/test_validation/test_check_registry.py +++ b/tests/test_validation/test_check_registry.py @@ -50,14 +50,14 @@ def test_severity_is_read_from_the_specification_not_hardcoded(): Nothing in this package repeats the level, so upstream relaxing a MUST changes the outcome with no code change here. """ - assert severity(CATALOG["OOLD-VER-001"]) == "fail", "OOLD-VER-001 is a MUST" - assert severity(CATALOG["OOLD-VER-002"]) == "warn", "OOLD-VER-002 is a SHOULD" - assert severity(CATALOG["OOLD-RT-001"]) == "fail", "MUST NOT is also a failure" + assert severity(CATALOG["OOLD-VER-3b96"]) == "fail", "OOLD-VER-3b96 is a MUST" + assert severity(CATALOG["OOLD-VER-3662"]) == "warn", "OOLD-VER-3662 is a SHOULD" + assert severity(CATALOG["OOLD-RT-d9bd"]) == "fail", "MUST NOT is also a failure" def test_a_rule_absent_from_the_catalogue_is_skipped(): """A version that never stated a requirement must not be judged against it.""" - without = {k: v for k, v in CATALOG.items() if k != "OOLD-VER-001"} + without = {k: v for k, v in CATALOG.items() if k != "OOLD-VER-3b96"} findings = {f.check_id: f for f in run_rule_checks({}, ContextView(), without)} assert findings["rule.id"].status == "skip" assert "not stated" in findings["rule.id"].message @@ -65,14 +65,14 @@ def test_a_rule_absent_from_the_catalogue_is_skipped(): def test_a_deprecated_rule_is_skipped(): retired = dict(CATALOG) - retired["OOLD-VER-001"] = {**retired["OOLD-VER-001"], "deprecated": True, "superseded_by": ["OOLD-VER-009"]} + retired["OOLD-VER-3b96"] = {**retired["OOLD-VER-3b96"], "deprecated": True, "superseded_by": ["OOLD-VER-0009"]} findings = {f.check_id: f for f in run_rule_checks({}, ContextView(), retired)} assert findings["rule.id"].status == "skip" assert "deprecated" in findings["rule.id"].message - assert "OOLD-VER-009" in findings["rule.id"].message + assert "OOLD-VER-0009" in findings["rule.id"].message -# ------------------------------------------------------------------ OOLD-VER-001 / CMP-005 +# ------------------------------------------------------------------ OOLD-VER-3b96 / CMP-dd2b def test_missing_id_is_reported(): @@ -87,7 +87,7 @@ def test_id_fragment(): assert outcome("rule.id-fragment", {"$id": "https://example.org/T.json#"}) == "ok" -# ------------------------------------------------------------------ OOLD-EXT-005 +# ------------------------------------------------------------------ OOLD-EXT-3fe9 def test_range_must_use_x_oold_ref_not_ref(): @@ -108,7 +108,7 @@ def test_a_ref_outside_a_range_is_not_flagged(): assert outcome("rule.range-ref", schema) == "ok" -# ------------------------------------------------------------------ OOLD-INS-002 +# ------------------------------------------------------------------ OOLD-INS-4b5c def test_pinned_type_must_agree_with_the_declared_rdf_type(): @@ -137,7 +137,7 @@ def test_inherited_rdf_type_is_used(): assert outcome("rule.instance-type", schema) == "fail" -# ------------------------------------------------------------------ OOLD-INS-009 +# ------------------------------------------------------------------ OOLD-INS-2e5d def test_free_text_range_must_not_be_coerced_to_iri(): @@ -167,7 +167,7 @@ def test_an_iri_branch_is_not_mistaken_for_free_text(): assert outcome("rule.free-text-iri", schema, context) == "ok" -# ------------------------------------------------------------------ OOLD-INS-005 +# ------------------------------------------------------------------ OOLD-INS-ba9e def test_a_closed_object_must_permit_schema_and_context(): @@ -231,6 +231,99 @@ def test_processing_mode_rejects_the_string_form(): assert outcome("rule.processing-mode", {}, ContextView(entries=[{"@version": "1.1"}])) == "warn" +# ------------------------------------------------------------------ OOLD-VER-edb9 + + +def test_uuid_annotation_must_be_present_and_valid(): + assert outcome("rule.uuid", {}) == "warn" + assert outcome("rule.uuid", {"x-oold-uuid": "not-a-uuid"}) == "warn" + assert outcome("rule.uuid", {"x-oold-uuid": "b5203131-7321-46bb-8a11-acb3d1015840"}) == "ok" + + +def test_uuid_annotation_accepts_the_urn_prefix(): + """`urn:uuid:...` is a legitimate way to write a UUID value.""" + assert outcome("rule.uuid", {"x-oold-uuid": "urn:uuid:b5203131-7321-46bb-8a11-acb3d1015840"}) == "ok" + + +# ------------------------------------------------------------------ OOLD-EXT-dd76 + + +def test_multilang_keyword_needs_its_plain_default(): + assert outcome("rule.multilang-default", {"x-oold-multilang-title": {"en": "Person"}}) == "warn" + assert outcome("rule.multilang-default", {"x-oold-multilang-description": {"en": "..."}}) == "warn" + conforming = {"x-oold-multilang-title": {"en": "Person"}, "title": "Person"} + assert outcome("rule.multilang-default", conforming) == "ok" + + +def test_a_schema_using_neither_multilang_keyword_is_not_judged(): + """The word "still" in the rule scopes it to schemas that use the multilingual keywords.""" + assert outcome("rule.multilang-default", {}) == "ok" + + +# ------------------------------------------------------------------ OOLD-CMP-53bf + + +def test_base_alignment_flags_a_mismatched_base(): + schema = {"$id": "https://example.org/schemas/A.schema.json"} + context = ContextView(entries=[{"@base": "https://example.org/other/"}]) + assert outcome("rule.base-alignment", schema, context) == "warn" + assert "@base" in message("rule.base-alignment", schema, context) + + +def test_base_alignment_accepts_an_aligned_base(): + schema = {"$id": "https://example.org/schemas/A.schema.json"} + context = ContextView(entries=[{"@base": "https://example.org/schemas/"}]) + assert outcome("rule.base-alignment", schema, context) == "ok" + + +def test_base_alignment_is_not_judged_without_both_an_id_and_a_base(): + assert outcome("rule.base-alignment", {}) == "ok" + assert outcome("rule.base-alignment", {"$id": "A.schema.json"}) == "ok" + + +# ------------------------------------------------------------------ OOLD-CMP-5266 + + +def test_scoped_context_flags_a_ref_embed_with_no_scoped_context(): + schema = {"properties": {"address": {"type": "object", "$ref": "Address.schema.json"}}} + context = ContextView(terms={"address": {"@id": "schema:address"}}) + assert outcome("rule.scoped-context", schema, context) == "warn" + assert "Address.schema.json" in message("rule.scoped-context", schema, context) + + +def test_scoped_context_accepts_a_ref_embed_with_a_scoped_context(): + schema = {"properties": {"address": {"type": "object", "$ref": "Address.schema.json"}}} + context = ContextView(terms={"address": {"@id": "schema:address", "@context": "Address.schema.json"}}) + assert outcome("rule.scoped-context", schema, context) == "ok" + + +def test_an_inline_embed_is_not_flagged(): + """The exception is for a cyclic embed graph, which only a $ref-based embed can form.""" + schema = {"properties": {"address": {"type": "object", "properties": {"street": {"type": "string"}}}}} + context = ContextView(terms={"address": {"@id": "schema:address"}}) + assert outcome("rule.scoped-context", schema, context) == "ok" + + +def test_a_self_reference_is_not_flagged(): + """A schema cannot scope a remote context onto itself without recursing.""" + schema = {"$id": "Person.schema.json", "properties": {"friend": {"$ref": "Person.schema.json"}}} + context = ContextView(terms={"friend": {"@id": "schema:knows"}}) + assert outcome("rule.scoped-context", schema, context) == "ok" + + +def test_a_property_with_no_term_at_all_is_not_flagged(): + """A different check covers a property with no @context term.""" + schema = {"properties": {"address": {"$ref": "Address.schema.json"}}} + assert outcome("rule.scoped-context", schema, ContextView()) == "ok" + + +def test_a_scalar_range_reference_is_not_flagged(): + """x-oold-range/x-oold-ref is a scalar reference, not an embedded object.""" + schema = {"properties": {"worksFor": {"x-oold-range": {"allOf": [{"x-oold-ref": "Organization.schema.json"}]}}}} + context = ContextView(terms={"worksFor": {"@id": "schema:worksFor"}}) + assert outcome("rule.scoped-context", schema, context) == "ok" + + # ------------------------------------------------------------------ against the real corpus diff --git a/tests/test_validation/test_pipeline.py b/tests/test_validation/test_pipeline.py index 452c0a7..59af64b 100644 --- a/tests/test_validation/test_pipeline.py +++ b/tests/test_validation/test_pipeline.py @@ -81,6 +81,10 @@ def test_a_context_chain_leaving_the_directory_resolves(remote_context_dir): ("array_without_container.schema.json", "lint.container", FAIL), # lint.iri-format only ever warns, so this one does not make the report fail overall. ("iri_reference_without_format.schema.json", "lint.iri-format", WARN), + # Likewise rule.base-alignment: OOLD-CMP-53bf is a SHOULD. This fixture exists because + # no other schema in the corpus declares @base, so without it the predicate is never + # reached through the pipeline and would pass by never running. + ("base_uri_misaligned.schema.json", "rule.base-alignment", WARN), ], ) def test_each_broken_fixture_fails_the_check_it_targets(broken_dir, fixture, check_id, status): diff --git a/tests/test_validation/test_rules.py b/tests/test_validation/test_rules.py index afb4720..e141082 100644 --- a/tests/test_validation/test_rules.py +++ b/tests/test_validation/test_rules.py @@ -21,38 +21,38 @@ "spec_version": "0.9.0", "rules": [ { - "id": "OOLD-RT-002", + "id": "OOLD-RT-08f2", "area": "RT", "level": "MUST", "applies_to": "document", "section": "round-trip", "summary": "A strictly array-typed property must declare @container @set or @list.", "text": "Because the reconstruction MUST re-validate, a property that is strictly an array MUST declare @container.", - "checkable": True, + "machine_checkable": True, "since": "0.8.0", "deprecated": False, }, { - "id": "OOLD-INS-003", + "id": "OOLD-INS-7cd1", "area": "INS", "level": "MUST", "applies_to": "implementation", "section": "identity", "summary": "An exported identifiable entity must carry an IRI.", "text": "When it exports an identifiable entity it MUST assign an @id.", - "checkable": False, + "machine_checkable": False, "since": "0.8.0", "deprecated": False, }, { - "id": "OOLD-VER-001", + "id": "OOLD-VER-3b96", "area": "VER", "level": "MUST", "applies_to": "document", "section": "identification", "summary": "A schema must have a $id.", "text": "OO-LD schemas MUST have a $id.", - "checkable": True, + "machine_checkable": True, "since": "0.8.0", "deprecated": False, }, @@ -60,29 +60,29 @@ # Nothing enforces @propagate, so this is the sample's coverage gap. It must stay # unenforced for the coverage tests to mean anything; if a check is ever written for # it, swap in another unenforced rule rather than deleting the assertions. - "id": "OOLD-CMP-004", + "id": "OOLD-CMP-9a44", "area": "CMP", "level": "MUST", "applies_to": "document", "section": "merge-and-override-model", "summary": "A scoped context that must apply only to the immediate node sets @propagate false.", "text": "The schema MUST set @propagate false on that scoped context.", - "checkable": True, + "machine_checkable": True, "since": "0.8.0", "deprecated": False, }, { - "id": "OOLD-RT-009", + "id": "OOLD-RT-4f18", "area": "RT", "level": "MUST", "applies_to": "document", "section": "round-trip", "summary": "A retired rule.", "text": "This rule MUST no longer be applied.", - "checkable": True, + "machine_checkable": True, "since": "0.8.0", "deprecated": True, - "superseded_by": ["OOLD-RT-002"], + "superseded_by": ["OOLD-RT-08f2"], }, ], } @@ -127,14 +127,14 @@ def test_a_version_without_a_catalog_still_loads(): for version in WITHOUT_CATALOG: bundle = load_tracked(version) assert bundle.rules == [] - assert bundle.rule("OOLD-RT-002") is None + assert bundle.rule("OOLD-RT-08f2") is None assert bundle.meta_validator().is_valid({"type": "object"}), "still usable" def test_catalog_is_loaded_when_present(catalog_version): bundle = load_tracked(catalog_version) assert bundle.has_rules - assert bundle.rule("OOLD-RT-002")["level"] == "MUST" + assert bundle.rule("OOLD-RT-08f2")["level"] == "MUST" assert bundle.rule("OOLD-NOPE-001") is None @@ -147,10 +147,10 @@ def test_a_malformed_catalog_is_treated_as_absent(catalog_version, tmp_path): def test_checkable_rules_exclude_implementation_advisory_and_deprecated(catalog_version): - ids = [r["id"] for r in load_tracked(catalog_version).checkable_rules()] - assert ids == ["OOLD-RT-002", "OOLD-VER-001", "OOLD-CMP-004"] - assert "OOLD-INS-003" not in ids, "an implementation rule is not checkable by a validator" - assert "OOLD-RT-009" not in ids, "a deprecated rule is not counted" + ids = [r["id"] for r in load_tracked(catalog_version).machine_checkable_rules()] + assert ids == ["OOLD-RT-08f2", "OOLD-VER-3b96", "OOLD-CMP-9a44"] + assert "OOLD-INS-7cd1" not in ids, "an implementation rule is not checkable by a validator" + assert "OOLD-RT-4f18" not in ids, "a deprecated rule is not counted" # ------------------------------------------------------------------ mapping @@ -199,7 +199,7 @@ def test_findings_cite_a_rule_when_the_catalog_has_it(catalog_version, broken_di Options(meta=(catalog_version,), offline=True), ) container = next(c for c in report.checks if c.id == "lint.container") - assert container.rule == "OOLD-RT-002" + assert container.rule == "OOLD-RT-08f2" # lint.pattern maps to a rule this sample catalog does not contain, so it stays uncited # rather than quoting a dangling code. assert next(c for c in report.checks if c.id == "lint.pattern").rule is None @@ -214,7 +214,7 @@ def test_rule_appears_in_the_serialised_report(catalog_version, broken_dir): ) payload = report.to_dict("summary") cited = [c for c in payload["checks"] if c.get("rule")] - assert any(c["rule"] == "OOLD-RT-002" for c in cited) + assert any(c["rule"] == "OOLD-RT-08f2" for c in cited) # ------------------------------------------------------------------ coverage @@ -235,13 +235,13 @@ def test_unenforced_rules_are_a_warning_not_a_failure(catalog_version, complianc report = run_compliance(compliance_dir, Options(meta=(catalog_version,), offline=True)) coverage = next(c for c in report.checks if c.id == "coverage.rules") assert coverage.status == "warn" - assert "OOLD-CMP-004" in coverage.detail["unenforced"] + assert "OOLD-CMP-9a44" in coverage.detail["unenforced"] def test_a_mapped_rule_missing_from_an_older_catalog_is_not_a_failure(catalog_version, compliance_dir): """A catalog predating a mapped rule is indistinguishable from a typo, so it only warns. - The sample catalog omits OOLD-RT-001, which the registry maps `lint.pattern` to. Failing there would break + The sample catalog omits OOLD-RT-d9bd, which the registry maps `lint.pattern` to. Failing there would break validation against any meta version older than the newest rule this package enforces. """ from oold.validation import Options, run_compliance @@ -249,7 +249,7 @@ def test_a_mapped_rule_missing_from_an_older_catalog_is_not_a_failure(catalog_ve report = run_compliance(compliance_dir, Options(meta=(catalog_version,), offline=True)) coverage = next(c for c in report.checks if c.id == "coverage.rules") assert coverage.status == "warn" - assert "OOLD-RT-001" in coverage.detail["unknown"] + assert "OOLD-RT-d9bd" in coverage.detail["unknown"] assert report.passed, "an older catalog must not fail the run" @@ -265,33 +265,33 @@ def run(): def test_rules_list(run, catalog_version): result = run("rules", "list", "--meta", catalog_version) assert result.exit_code == 0 - assert "OOLD-RT-002" in result.output + assert "OOLD-RT-08f2" in result.output assert "lint.container" in result.output, "the enforcing check is shown" def test_rules_list_filters_by_area(run, catalog_version): out = run("rules", "list", "--meta", catalog_version, "--area", "VER").output - assert "OOLD-VER-001" in out - assert "OOLD-RT-002" not in out + assert "OOLD-VER-3b96" in out + assert "OOLD-RT-08f2" not in out def test_rules_list_unchecked_shows_the_gap(run, catalog_version): out = run("rules", "list", "--meta", catalog_version, "--unchecked").output - assert "OOLD-CMP-004" in out, "no check enforces @propagate" - assert "OOLD-RT-002" not in out, "lint.container enforces it" - assert "OOLD-VER-001" not in out, "rule.id enforces it" + assert "OOLD-CMP-9a44" in out, "no check enforces @propagate" + assert "OOLD-RT-08f2" not in out, "lint.container enforces it" + assert "OOLD-VER-3b96" not in out, "rule.id enforces it" def test_rules_explain(run, catalog_version): - out = run("rules", "explain", "OOLD-RT-002", "--meta", catalog_version).output + out = run("rules", "explain", "OOLD-RT-08f2", "--meta", catalog_version).output assert "MUST" in out assert "enforced by lint.container" in out - assert "#rule-OOLD-RT-002" in out + assert "#rule-OOLD-RT-08f2" in out assert "MUST re-validate" in out, "the specification text is shown" def test_rules_explain_is_case_insensitive(run, catalog_version): - assert run("rules", "explain", "oold-rt-002", "--meta", catalog_version).exit_code == 0 + assert run("rules", "explain", "oold-rt-08f2", "--meta", catalog_version).exit_code == 0 def test_rules_explain_unknown_id_suggests_listing(run, catalog_version): From 793aaa1b58d63a4aa0656813ad3e966df58688b1 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 12 Aug 2026 16:51:26 +0200 Subject: [PATCH 21/29] test(validation): give three checks a fixture that actually reaches them - rule.instance-type and rule.closed-object previously passed without ever running, found via line coverage over a full corpus run - New fixtures make each fail exactly the check it targets; closed_object also trips roundtrip.generated - base_uri_misaligned is registered in the fixture table alongside them, having been added earlier without being listed - rule.id-alias is left with a known gap: no fixture yet exercises its non-violating path --- tests/data/oold/README.md | 2 ++ ...closed_object_rejects_metadata.schema.json | 16 +++++++++++++ .../broken/inline_type_disagrees.schema.json | 23 +++++++++++++++++++ tests/test_validation/test_pipeline.py | 5 ++++ 4 files changed, 46 insertions(+) create mode 100644 tests/data/oold/broken/closed_object_rejects_metadata.schema.json create mode 100644 tests/data/oold/broken/inline_type_disagrees.schema.json diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md index 5020579..fa38dc7 100644 --- a/tests/data/oold/README.md +++ b/tests/data/oold/README.md @@ -61,5 +61,7 @@ Each one exists to prove a specific check fires, rather than only that valid inp | `unresolvable_context_ref` | `context.predicates` - the `@context` chain points at a missing schema | | `xsd_string_coercion` | `lint.pattern` - a term coercing a literal to `xsd:string` never round-trips | | `array_without_container` | `lint.container` - a strict array without `@container: @set` | +| `inline_type_disagrees` | `rule.instance-type` - a pinned `type` naming a class absent from `x-oold-instance-rdf-type` | +| `closed_object_rejects_metadata` | `rule.closed-object` - `additionalProperties: false` without declaring `$schema` and `@context` | | `iri_reference_without_format` | `lint.iri-format` (warns, does not fail) - a bare-IRI-string reference with no `iri-reference`/`uri*` format | | `base_uri_misaligned` | `rule.base-alignment` (warns, does not fail) - an `@base` that resolves a relative reference somewhere other than `$id` does | diff --git a/tests/data/oold/broken/closed_object_rejects_metadata.schema.json b/tests/data/oold/broken/closed_object_rejects_metadata.schema.json new file mode 100644 index 0000000..53d6293 --- /dev/null +++ b/tests/data/oold/broken/closed_object_rejects_metadata.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "closed_object_rejects_metadata.schema.json", + "title": "ClosedObjectRejectsMetadata", + "@context": { + "ex": "https://example.org/", + "name": "ex:name" + }, + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/tests/data/oold/broken/inline_type_disagrees.schema.json b/tests/data/oold/broken/inline_type_disagrees.schema.json new file mode 100644 index 0000000..034ad69 --- /dev/null +++ b/tests/data/oold/broken/inline_type_disagrees.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "inline_type_disagrees.schema.json", + "title": "InlineTypeDisagrees", + "x-oold-instance-rdf-type": [ + "ex:Person" + ], + "@context": { + "ex": "https://example.org/", + "type": "@type", + "name": "ex:name" + }, + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ex:Organization" + }, + "name": { + "type": "string" + } + } +} diff --git a/tests/test_validation/test_pipeline.py b/tests/test_validation/test_pipeline.py index 59af64b..34e5972 100644 --- a/tests/test_validation/test_pipeline.py +++ b/tests/test_validation/test_pipeline.py @@ -79,6 +79,11 @@ def test_a_context_chain_leaving_the_directory_resolves(remote_context_dir): ("unresolvable_context_ref.schema.json", "context.predicates", FAIL), ("xsd_string_coercion.schema.json", "lint.pattern", FAIL), ("array_without_container.schema.json", "lint.container", FAIL), + # These two exist because their predicates returned before evaluating anything on the + # rest of the corpus: no other fixture pins a `type` alongside x-oold-instance-rdf-type, + # and none closes its objects. Both checks passed by never running. + ("inline_type_disagrees.schema.json", "rule.instance-type", FAIL), + ("closed_object_rejects_metadata.schema.json", "rule.closed-object", FAIL), # lint.iri-format only ever warns, so this one does not make the report fail overall. ("iri_reference_without_format.schema.json", "lint.iri-format", WARN), # Likewise rule.base-alignment: OOLD-CMP-53bf is a SHOULD. This fixture exists because From ebc3907cae24a685b78edcd856ca514331148a95 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 12 Aug 2026 17:12:56 +0200 Subject: [PATCH 22/29] fix(docs): restate and guard Zensical's default Markdown extensions - Declaring any markdown_extensions replaces Zensical's default set rather than extending it, silently dropping fifteen defaults - Admonitions in docs/how-to/backends.md, codegen.md, object-graph-mapping.md and rdf-export.md rendered as literal text - zensical.toml now restates all 22 defaults of the installed 0.0.45 - scripts/check_markdown_extensions.py compares that restatement against the installed Zensical and fails on drift; wired into `make check`, a pre-commit hook, and docs CI - pymdownx.smartsymbols stays enabled here, unlike oold-schema, since its trigger sequences only appear inside Mermaid fences --- .github/workflows/main.yml | 6 + .pre-commit-config.yaml | 14 ++ Makefile | 6 + scripts/check_markdown_extensions.py | 197 +++++++++++++++++++++++++++ zensical.toml | 82 ++++++++++- 5 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 scripts/check_markdown_extensions.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 536850c..702704e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -90,6 +90,12 @@ jobs: - name: Set up the environment uses: ./.github/actions/setup-python-env + # A project that declares any markdown_extensions replaces Zensical's defaults + # rather than extending them, so a missing one silently switches an extension + # off without failing the build below. Catch that before it does. + - name: Check the restated Markdown extensions match Zensical's defaults + run: uv run python scripts/check_markdown_extensions.py + - name: Check if documentation can be built run: uv run zensical build -s diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 88aac64..a96d75b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,3 +43,17 @@ repos: hooks: - id: conventional-pre-commit stages: [commit-msg] + + # Declaring any markdown_extensions in zensical.toml replaces Zensical's defaults + # instead of extending them, so dropping one switches an extension off with no + # warning and no build failure. See scripts/check_markdown_extensions.py. + - repo: local + hooks: + - id: markdown-extensions + name: check zensical.toml restates Zensical's default Markdown extensions + # Goes through make so the invocation matches `make check` and CI exactly. + entry: make check-extensions + language: system + pass_filenames: false + files: ^(zensical\.toml|scripts/check_markdown_extensions\.py|Makefile)$ + require_serial: true diff --git a/Makefile b/Makefile index ba5285f..6c77c7a 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,8 @@ check: ## Run code quality tools. @uv run ty check @echo "🚀 Checking for obsolete dependencies: Running deptry" @uv run deptry src + @echo "🚀 Checking docs build config: Markdown extensions still match Zensical's defaults" + @uv run python scripts/check_markdown_extensions.py .PHONY: test test: ## Test the code with pytest @@ -26,6 +28,10 @@ validate: ## Validate the committed OO-LD fixtures with the built-in validator @uv run oold validate tests/data/oold --offline @uv run oold compliance tests/data/oold/compliance --offline +.PHONY: check-extensions +check-extensions: ## Check zensical.toml still restates Zensical's default Markdown extensions + @uv run python scripts/check_markdown_extensions.py + .PHONY: benchmark benchmark: ## Run performance benchmarks with pytest-benchmark @echo "🚀 Running benchmarks: pytest-benchmark" diff --git a/scripts/check_markdown_extensions.py b/scripts/check_markdown_extensions.py new file mode 100644 index 0000000..38266e4 --- /dev/null +++ b/scripts/check_markdown_extensions.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python +""" +Guard zensical.toml against Zensical's default Markdown extensions drifting. + +Why this exists: a project that declares ANY markdown_extensions REPLACES Zensical's +default set instead of extending it. zensical/config.py reads them via + + config.get("markdown_extensions", DEFAULT_MARKDOWN_EXTENSIONS) + +which is a fallback, not a merge, so every default left out of zensical.toml is +silently switched off. There is no warning and no build failure: the extension simply +stops applying, and the damage surfaces as prose that renders wrong. That is how +`!!! note` / `!!! tip` admonitions ended up published as literal text. + +zensical.toml therefore restates the upstream defaults verbatim. This script checks +that the restatement is still true against the *installed* Zensical (the "zensical" +dev dependency pinned in pyproject.toml), so bumping that pin cannot quietly change +the effective set. Deliberate deviations are declared below and must each stay +justified. + +Usage: + python scripts/check_markdown_extensions.py + +Run via `make check-extensions`, which is also what `make check` and the +`markdown-extensions` pre-commit hook call. +""" + +import copy +import os +import sys + +try: + import tomllib # ty: ignore[unresolved-import] # stdlib on 3.11+; ty type-checks against the 3.10 floor +except ModuleNotFoundError: + sys.exit( + "This check needs Python 3.11+ for the stdlib tomllib parser (the project " + "itself supports 3.10+, but `make check` and CI both run on 3.12). Use that " + "interpreter, or a newer one, to run this check directly." + ) + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +CONFIG = os.path.join(ROOT, "zensical.toml") + +# Upstream defaults this project deliberately does NOT enable. Keep the reason with +# the entry; the same reasoning is spelled out next to the commented-out table in +# zensical.toml. Empty for now: pymdownx.smartsymbols was reviewed and kept enabled, +# see zensical.toml. +INTENTIONALLY_DISABLED: dict[str, str] = {} + +# Upstream defaults this project enables but configures differently. Their options are +# exempt from the comparison; their presence is not. Empty for now: the mermaid fence's +# explicit "format" and the trimmed pymdownx.tabbed used to differ from the default +# without a stated reason, and neither turned out to be needed, so both were restored +# to match the default instead of being recorded here. +INTENTIONAL_OVERRIDES: dict[str, str] = {} + +# Extensions this project adds on top of the defaults. Empty for now. +PROJECT_ADDITIONS: dict[str, str] = {} + + +def load_converter(): + """Return Zensical's own extension normalizer, so both sides are compared as + Zensical itself sees them rather than through a reimplementation here.""" + try: + import zensical.config as zconfig + except ImportError: + sys.exit( + "zensical is not importable.\n" + "Run `uv sync` (or `make install`) so the pinned dev dependency is " + "available, then run this via `make check-extensions`." + ) + missing = [ + n + for n in ("DEFAULT_MARKDOWN_EXTENSIONS", "_convert_markdown_extensions") + if not hasattr(zconfig, n) + ] + if missing: + sys.exit( + f"zensical.config no longer provides: {', '.join(missing)}.\n" + "The config internals this guard relies on have changed upstream. Re-read\n" + "zensical/config.py, confirm how markdown_extensions are resolved now, and\n" + "update this script and the restated block in zensical.toml together." + ) + return zconfig + + +def normalize(value): + """Reduce a config value to something comparable. Upstream stores the emoji hooks + as function objects while the TOML names them as strings, so callables collapse to + their dotted path.""" + if callable(value): + return f"{value.__module__}.{value.__qualname__}" + if isinstance(value, dict): + return {k: normalize(v) for k, v in value.items()} + if isinstance(value, list): + return [normalize(v) for v in value] + return value + + +def resolve(zconfig, table): + """Flatten a markdown_extensions table the way Zensical does, into {name: config}.""" + names, configs = zconfig._convert_markdown_extensions(copy.deepcopy(table)) + return {name: normalize(configs.get(name, {})) for name in names} + + +def main(): + zconfig = load_converter() + + with open(CONFIG, "rb") as fh: + declared_table = tomllib.load(fh)["project"]["markdown_extensions"] + + declared = resolve(zconfig, declared_table) + defaults = resolve(zconfig, zconfig.DEFAULT_MARKDOWN_EXTENSIONS) + + problems = [] + + for name, reason in sorted(INTENTIONALLY_DISABLED.items()): + if name not in defaults: + problems.append( + f"{name} is listed as deliberately disabled but is no longer a Zensical " + f"default. Drop it from INTENTIONALLY_DISABLED and from zensical.toml." + ) + elif name in declared: + problems.append( + f"{name} is listed as deliberately disabled ({reason}) but zensical.toml " + f"enables it. Remove the table or drop the allow-list entry." + ) + + for name in sorted(set(defaults) - set(declared) - set(INTENTIONALLY_DISABLED)): + problems.append( + f"{name} is a Zensical default but zensical.toml does not declare it, so it " + f"is silently switched off. Add [project.markdown_extensions.{name}] to the " + f"restated defaults block, or record it in INTENTIONALLY_DISABLED with a reason." + ) + + for name in sorted(set(declared) - set(defaults) - set(PROJECT_ADDITIONS)): + problems.append( + f"{name} is declared in zensical.toml but is not a Zensical default and is not " + f"a known project addition. Either Zensical dropped it from its defaults (move " + f"the table out of the restated block and into PROJECT_ADDITIONS if the docs " + f"still need it, or delete it), this project chose it without recording it " + f"(add it to PROJECT_ADDITIONS with a reason), or the name is misspelled and " + f"the extension is doing nothing." + ) + + for name, reason in sorted(PROJECT_ADDITIONS.items()): + if name not in declared: + problems.append( + f"{name} is listed as a project addition ({reason}) but zensical.toml no " + f"longer declares it. Drop the PROJECT_ADDITIONS entry." + ) + + for name in sorted(set(declared) & set(defaults)): + if name in INTENTIONAL_OVERRIDES: + if declared[name] == defaults[name]: + problems.append( + f"{name} is listed as an intentional override but its options now match " + f"the upstream default exactly. Drop the INTENTIONAL_OVERRIDES entry and " + f"the explanatory comment in zensical.toml." + ) + continue + if declared[name] != defaults[name]: + problems.append( + f"{name} options drifted from the upstream default.\n" + f" zensical.toml: {declared[name]}\n" + f" zensical: {defaults[name]}\n" + f" Restate the upstream value, or record the deviation in " + f"INTENTIONAL_OVERRIDES with a reason." + ) + + if problems: + print( + f"zensical.toml no longer matches Zensical's defaults " + f"({len(problems)} problem{'s' if len(problems) > 1 else ''}):\n", + file=sys.stderr, + ) + for problem in problems: + print(f" - {problem}\n", file=sys.stderr) + print( + "The restated block in zensical.toml exists so the effective extension set is\n" + "visible in one file. Reconcile it with the installed Zensical before building.", + file=sys.stderr, + ) + return 1 + + print( + f"markdown extensions OK ({len(declared)} declared, " + f"{len(set(declared) & set(defaults))} matching Zensical's defaults, " + f"{len(INTENTIONALLY_DISABLED)} deliberately disabled, " + f"{len(PROJECT_ADDITIONS)} project addition(s))" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/zensical.toml b/zensical.toml index f4bcdbd..234419a 100644 --- a/zensical.toml +++ b/zensical.toml @@ -92,26 +92,100 @@ link = "https://github.com/OO-LD/oold-python" icon = "fontawesome/brands/python" link = "https://pypi.org/project/oold" +# =========================================================================== +# BEGIN Zensical defaults - restated, not project choices +# +# Zensical enables a set of Markdown extensions out of the box, but a project +# that declares ANY markdown_extensions REPLACES that set instead of extending +# it: zensical/config.py reads them via +# config.get("markdown_extensions", DEFAULT_MARKDOWN_EXTENSIONS) +# which is a fallback, not a merge. Every default left out here is therefore +# silently switched off, with no warning and no build failure - the extension +# just stops applying. That is how the `!!! note` / `!!! tip` admonitions in +# how-to/backends.md, how-to/codegen.md, how-to/object-graph-mapping.md and +# how-to/rdf-export.md ended up published as literal text. +# +# The entries below reproduce the Markdown extension defaults of the "zensical" +# dev dependency pinned in pyproject.toml, so the effective set is visible in +# one file rather than hiding behind a fallback. scripts/check_markdown_extensions.py +# (run via `make check-extensions`) compares this block against the installed +# Zensical's DEFAULT_MARKDOWN_EXTENSIONS and fails the build if they drift, so +# upgrading zensical cannot change the effective set unnoticed. +# +# This project has no deviations from those defaults: a bare pymdownx.arithmatex, +# a pymdownx.tabbed missing combine_header_slug, and an explicit "format" on the +# mermaid fence used to differ from the default without a stated reason, and +# none of them turned out to be needed (the mermaid fence's "format" defaults to +# the exact same function - pymdownx.superfences.fence_code_format - when left +# unset, see pymdownx/superfences.py). They were restored to match the default +# rather than kept as unexplained overrides. See the note next to +# pymdownx.smartsymbols below for the one entry that got deliberate thought +# rather than a blanket restore. +# =========================================================================== + +[project.markdown_extensions.abbr] +[project.markdown_extensions.admonition] [project.markdown_extensions.attr_list] - +[project.markdown_extensions.def_list] +[project.markdown_extensions.footnotes] [project.markdown_extensions.md_in_html] +[project.markdown_extensions.pymdownx.betterem] +[project.markdown_extensions.pymdownx.caret] +[project.markdown_extensions.pymdownx.details] +[project.markdown_extensions.pymdownx.inlinehilite] +[project.markdown_extensions.pymdownx.keys] +[project.markdown_extensions.pymdownx.magiclink] +[project.markdown_extensions.pymdownx.mark] +[project.markdown_extensions.pymdownx.tilde] + +# Zensical enables this by default. It silently rewrites sequences such as +# (c), (r), (tm), +/-, -->, <--, =/= and fractions like 1/2 wherever they +# appear in prose - a sibling OO-LD project found it turning the "(c)" of an +# (a)/(b)/(c) enumeration into a copyright sign. docs/ here were checked for +# those sequences outside code fences: the only matches are the "-->" arrows +# of Mermaid diagrams, all inside ```mermaid fences in architecture.md, which +# smartsymbols never touches (fenced code content is not run through inline +# extensions). With no prose depending on a literal (c)/(r)/1/2/etc. found, +# this stays enabled as shipped. If that changes, move this table above the +# "END Zensical defaults" marker with a comment, and add it to +# INTENTIONALLY_DISABLED in scripts/check_markdown_extensions.py. +[project.markdown_extensions.pymdownx.smartsymbols] + +[project.markdown_extensions.pymdownx.arithmatex] +generic = true [project.markdown_extensions.pymdownx.emoji] emoji_generator = "zensical.extensions.emoji.to_svg" emoji_index = "zensical.extensions.emoji.twemoji" +[project.markdown_extensions.pymdownx.highlight] +anchor_linenums = true +line_spans = "__span" +pygments_lang_class = true + [project.markdown_extensions.pymdownx.superfences] [[project.markdown_extensions.pymdownx.superfences.custom_fences]] name = "mermaid" class = "mermaid" -format = "pymdownx.superfences.fence_code_format" [project.markdown_extensions.pymdownx.tabbed] alternate_style = true +combine_header_slug = true -[project.markdown_extensions.pymdownx.arithmatex] -generic = true +[project.markdown_extensions.pymdownx.tasklist] +custom_checkbox = true [project.markdown_extensions.toc] permalink = true + +# =========================================================================== +# END Zensical defaults +# =========================================================================== + +# No project-specific extensions or overrides at the moment: everything this +# project needs turned out to already be a Zensical default. If a genuine +# override or addition becomes necessary, add it here and record it in +# scripts/check_markdown_extensions.py's INTENTIONAL_OVERRIDES or +# PROJECT_ADDITIONS so the guard keeps checking it deliberately instead of +# flagging it as drift. From fb99f5c3aebdec7227bd2cd68840b4cc2bd8c061 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Wed, 12 Aug 2026 17:13:21 +0200 Subject: [PATCH 23/29] feat(validation): enforce four more catalogued rules - rule.multilang-shape (OOLD-EXT-ef09), rule.dialect-version (OOLD-EXT-af50), rule.context-array-order (OOLD-CMP-e4a3), rule.versioned-id (OOLD-VER-534a) - rule.context-array-order and rule.versioned-id read the literal @context and the rule's summary respectively, both documented exceptions - Three fixtures added so rule.versioned-id is actually reached, not just guarded - Not implemented: OOLD-CMP-a05a, OOLD-INS-9416, OOLD-RT-d376, OOLD-CMP-f3c7, each with a reason recorded in the code - Verdicts unchanged; parity holds at 6/6 --- src/oold/validation/check_registry.py | 134 ++++++++++++++++++ tests/data/oold/README.md | 3 + .../context_array_order_mismatch.schema.json | 15 ++ .../oold/broken/legacy_dialect.schema.json | 15 ++ .../versioned_id_missing_version.schema.json | 16 +++ tests/test_validation/test_check_registry.py | 94 ++++++++++++ tests/test_validation/test_pipeline.py | 7 + 7 files changed, 284 insertions(+) create mode 100644 tests/data/oold/broken/context_array_order_mismatch.schema.json create mode 100644 tests/data/oold/broken/legacy_dialect.schema.json create mode 100644 tests/data/oold/broken/versioned_id_missing_version.schema.json diff --git a/src/oold/validation/check_registry.py b/src/oold/validation/check_registry.py index 0d81f55..f5707f1 100644 --- a/src/oold/validation/check_registry.py +++ b/src/oold/validation/check_registry.py @@ -31,6 +31,7 @@ from urllib.parse import urljoin from .compliance import run_suite, vocabulary_coverage +from .formats import is_iri from .frame import collect_composed_properties, instance_rdf_types from .generate import generate from .instance_checks import roundtrip_instance, validate_instance @@ -50,6 +51,16 @@ #: variant bits are not checked - the rule asks for "a UUID value", not a version 4 UUID. _UUID = re.compile(r"^(?:urn:uuid:)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +#: A permissive BCP 47 language tag: a 2-3 letter primary subtag, then any number of hyphenated +#: subtags of 1-8 alphanumeric characters. Permissive on purpose: the rule only asks that a key +#: "look like" a BCP 47 tag, and rejecting an unusual but legal subtag (script, region, variant, +#: extension) would be a false positive. +_BCP47_TAG = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$") + +#: JSON Schema 2020-12's own meta-schema URI - the REQUIRED floor `rule.dialect-version` checks +#: for, distinct from `rule.dialect` above, which additionally prefers the OO-LD dialect itself. +_JSON_SCHEMA_2020_12 = re.compile(r"^https?://json-schema\.org/draft/2020-12/schema/?#?$") + @dataclass class RuleFinding: @@ -382,6 +393,101 @@ def _ref_target(node: dict[str, Any]) -> str | None: return None +def _multilang_shape_invalid(schema: dict[str, Any], context: ContextView) -> list[str]: + """`x-oold-multilang-title`/`x-oold-multilang-description` map BCP 47 tags to strings.""" + problems: list[str] = [] + for key in ("x-oold-multilang-title", "x-oold-multilang-description"): + value = schema.get(key) + if value is None: + continue + if not isinstance(value, dict): + problems.append(f"{key} is {value!r}, not an object mapping language tags to strings") + continue + for tag, text in value.items(): + if not _BCP47_TAG.match(tag): + problems.append(f"{key} has key {tag!r}, which does not look like a BCP 47 language tag") + if not isinstance(text, str): + problems.append(f"{key}[{tag!r}] is {text!r}, not a string") + return problems + + +def _dialect_not_2020_12(schema: dict[str, Any], context: ContextView) -> list[str]: + """A declared `$schema` must be 2020-12-based: the REQUIRED floor, not merely preferred. + + Distinct from `rule.dialect` (OOLD-EXT-5184), a SHOULD that a schema declare the *OO-LD* + dialect specifically. This one checks the REQUIRED floor underneath it: whatever dialect is + declared must be JSON Schema 2020-12 itself, or the OO-LD dialect meta-schema (which is built + on 2020-12). Skipped when `$schema` is absent - `rule.dialect` already reports that absence, + and guessing a dialect here would double-report the same schema. + """ + declared = schema.get("$schema") + if not isinstance(declared, str) or not declared: + return [] + if _OOLD_META.search(declared) or _JSON_SCHEMA_2020_12.match(declared): + return [] + return [f"$schema is {declared!r}, which is not JSON Schema 2020-12 or the OO-LD dialect meta-schema"] + + +def _context_array_order_mismatched(schema: dict[str, Any], context: ContextView) -> list[str]: + """`allOf`'s `$ref` targets must appear in `@context`, as an array, in the same order. + + Deliberate exception to "judge the resolved context" (see CLAUDE.md): this rule is about + whether the schema *as authored* stays directly usable as a remote `@context` without further + processing, which is a statement about its own array and the order of its own entries, not + about what a term means once resolution and inheritance are applied. So, on purpose, this + predicate reads `schema["@context"]` and `schema["allOf"]` literally rather than taking the + resolved `ContextView`. + """ + allof = schema.get("allOf") + if not isinstance(allof, list): + return [] + targets = [entry["$ref"] for entry in allof if isinstance(entry, dict) and isinstance(entry.get("$ref"), str)] + if len(targets) < 2: + return [] + + literal_context = schema.get("@context") + if not isinstance(literal_context, list): + return [ + f"allOf composes {len(targets)} remote contexts via $ref ({', '.join(targets)}) but " + "@context is not an array, so this schema is not directly usable as a context" + ] + + missing = [target for target in targets if target not in literal_context] + if missing: + return [f"@context does not list {target!r}, which allOf composes as a remote context" for target in missing] + + positions = [literal_context.index(target) for target in targets] + if positions != sorted(positions): + return [ + f"@context lists the allOf targets {targets!r} out of order (found at positions " + f"{positions!r}); they must appear in the same order as the allOf members" + ] + return [] + + +def _version_not_in_schema_location(schema: dict[str, Any], context: ContextView) -> list[str]: + """`x-oold-version` should be part of the schema's location URL: OOLD-VER-534a. + + The catalogued `text` for this rule is a truncated lead-in ("The version SHOULD be part of + the schema's location:") whose list of URL forms was cut off upstream, so this predicate is + written against the rule's `summary` instead, which states the intent completely: the schema + version should be part of the schema location URL. + + Only judged when both `x-oold-version` and an absolute `$id` are present: `rule.version` and + `rule.id` already cover their absence, and a relative `$id` names no location to carry a + version. + """ + version = schema.get("x-oold-version") + identifier = schema.get("$id") + if not isinstance(version, str) or not version: + return [] + if not isinstance(identifier, str) or not is_iri(identifier): + return [] + if version in identifier: + return [] + return [f"x-oold-version {version!r} does not appear in the absolute $id {identifier!r}"] + + # ---------------------------------------------------------------------------- the registry @@ -649,6 +755,34 @@ class CheckInfo: per_version=True, run=_embedded_ref_missing_scoped_context, ), + CheckInfo( + "rule.multilang-shape", + "x-oold-multilang-title/description map BCP 47 language tags to translated strings", + rule="OOLD-EXT-ef09", + per_version=True, + run=_multilang_shape_invalid, + ), + CheckInfo( + "rule.dialect-version", + "a declared $schema is JSON Schema 2020-12 or the OO-LD dialect built on it", + rule="OOLD-EXT-af50", + per_version=True, + run=_dialect_not_2020_12, + ), + CheckInfo( + "rule.context-array-order", + "allOf's $ref targets appear in @context, as an array, in the same order", + rule="OOLD-CMP-e4a3", + per_version=True, + run=_context_array_order_mismatched, + ), + CheckInfo( + "rule.versioned-id", + "x-oold-version is part of an absolute $id", + rule="OOLD-VER-534a", + per_version=True, + run=_version_not_in_schema_location, + ), ) diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md index fa38dc7..c3fd4b8 100644 --- a/tests/data/oold/README.md +++ b/tests/data/oold/README.md @@ -65,3 +65,6 @@ Each one exists to prove a specific check fires, rather than only that valid inp | `closed_object_rejects_metadata` | `rule.closed-object` - `additionalProperties: false` without declaring `$schema` and `@context` | | `iri_reference_without_format` | `lint.iri-format` (warns, does not fail) - a bare-IRI-string reference with no `iri-reference`/`uri*` format | | `base_uri_misaligned` | `rule.base-alignment` (warns, does not fail) - an `@base` that resolves a relative reference somewhere other than `$id` does | +| `legacy_dialect` | `rule.dialect-version` - `$schema` names `draft-07`, not a 2020-12-based dialect | +| `context_array_order_mismatch` | `rule.context-array-order` - `@context` lists two `allOf`-composed remote contexts out of order | +| `versioned_id_missing_version` | `rule.versioned-id` (warns, does not fail) - `x-oold-version` does not appear in an absolute `$id` | diff --git a/tests/data/oold/broken/context_array_order_mismatch.schema.json b/tests/data/oold/broken/context_array_order_mismatch.schema.json new file mode 100644 index 0000000..e30728a --- /dev/null +++ b/tests/data/oold/broken/context_array_order_mismatch.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "context_array_order_mismatch.schema.json", + "title": "ContextArrayOrderMismatch", + "@context": [ + "../Address.schema.json", + "../Thing.schema.json" + ], + "allOf": [ + { "$ref": "../Thing.schema.json" }, + { "$ref": "../Address.schema.json" } + ], + "type": "object", + "properties": {} +} diff --git a/tests/data/oold/broken/legacy_dialect.schema.json b/tests/data/oold/broken/legacy_dialect.schema.json new file mode 100644 index 0000000..55d0afd --- /dev/null +++ b/tests/data/oold/broken/legacy_dialect.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "legacy_dialect.schema.json", + "title": "LegacyDialect", + "@context": { + "ex": "https://example.org/", + "name": "ex:name" + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/tests/data/oold/broken/versioned_id_missing_version.schema.json b/tests/data/oold/broken/versioned_id_missing_version.schema.json new file mode 100644 index 0000000..bcc475d --- /dev/null +++ b/tests/data/oold/broken/versioned_id_missing_version.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://example.org/schemas/versioned_id_missing_version.schema.json", + "x-oold-version": "1.0.0", + "title": "VersionedIdMissingVersion", + "@context": { + "ex": "https://example.org/", + "name": "ex:name" + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/tests/test_validation/test_check_registry.py b/tests/test_validation/test_check_registry.py index 4325487..17b8b72 100644 --- a/tests/test_validation/test_check_registry.py +++ b/tests/test_validation/test_check_registry.py @@ -324,6 +324,100 @@ def test_a_scalar_range_reference_is_not_flagged(): assert outcome("rule.scoped-context", schema, context) == "ok" +# ------------------------------------------------------------------ OOLD-EXT-ef09 + + +def test_multilang_shape_must_be_bcp47_keys_with_string_values(): + assert outcome("rule.multilang-shape", {"x-oold-multilang-title": {"en": "Person", "de": "Person"}}) == "ok" + assert outcome("rule.multilang-shape", {"x-oold-multilang-title": "Person"}) == "fail" + assert outcome("rule.multilang-shape", {"x-oold-multilang-description": {"???": "text"}}) == "fail" + assert outcome("rule.multilang-shape", {"x-oold-multilang-title": {"en": 1}}) == "fail" + + +def test_multilang_shape_accepts_a_regional_subtag(): + """`en-GB` is a legal, non-two-letter-only BCP 47 tag.""" + assert outcome("rule.multilang-shape", {"x-oold-multilang-title": {"en-GB": "Colour"}}) == "ok" + + +def test_a_schema_using_neither_multilang_keyword_is_not_judged_by_shape(): + assert outcome("rule.multilang-shape", {}) == "ok" + + +# ------------------------------------------------------------------ OOLD-EXT-af50 + + +def test_dialect_version_requires_2020_12_or_the_oold_dialect(): + assert outcome("rule.dialect-version", {"$schema": "http://json-schema.org/draft-07/schema#"}) == "fail" + assert outcome("rule.dialect-version", {"$schema": "https://json-schema.org/draft/2020-12/schema"}) == "ok" + assert outcome("rule.dialect-version", {"$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json"}) == "ok" + + +def test_dialect_version_is_not_judged_when_schema_is_absent(): + """`rule.dialect` already reports the absence; judging it here too would double-report.""" + assert outcome("rule.dialect-version", {}) == "ok" + + +# ------------------------------------------------------------------ OOLD-CMP-e4a3 + + +def test_context_array_order_must_match_allof(): + schema = { + "allOf": [{"$ref": "Thing.schema.json"}, {"$ref": "Person.schema.json"}], + "@context": ["Thing.schema.json", "Person.schema.json"], + } + assert outcome("rule.context-array-order", schema) == "ok" + + +def test_context_array_order_flags_a_reordered_context(): + schema = { + "allOf": [{"$ref": "Thing.schema.json"}, {"$ref": "Person.schema.json"}], + "@context": ["Person.schema.json", "Thing.schema.json"], + } + assert outcome("rule.context-array-order", schema) == "fail" + assert "out of order" in message("rule.context-array-order", schema) + + +def test_context_array_order_flags_a_non_array_context(): + schema = { + "allOf": [{"$ref": "Thing.schema.json"}, {"$ref": "Person.schema.json"}], + "@context": {"ex": "https://example.org/"}, + } + assert outcome("rule.context-array-order", schema) == "fail" + assert "not an array" in message("rule.context-array-order", schema) + + +def test_context_array_order_flags_a_missing_target(): + schema = { + "allOf": [{"$ref": "Thing.schema.json"}, {"$ref": "Person.schema.json"}], + "@context": ["Thing.schema.json"], + } + assert outcome("rule.context-array-order", schema) == "fail" + assert "Person.schema.json" in message("rule.context-array-order", schema) + + +def test_context_array_order_is_not_judged_with_fewer_than_two_refs(): + schema = {"allOf": [{"$ref": "Thing.schema.json"}], "@context": {"ex": "https://example.org/"}} + assert outcome("rule.context-array-order", schema) == "ok" + + +# ------------------------------------------------------------------ OOLD-VER-534a + + +def test_versioned_id_should_appear_in_an_absolute_id(): + conforming = {"x-oold-version": "1.0.0", "$id": "https://example.org/schemas/1.0.0/Person.schema.json"} + assert outcome("rule.versioned-id", conforming) == "ok" + + violating = {"x-oold-version": "1.0.0", "$id": "https://example.org/schemas/Person.schema.json"} + assert outcome("rule.versioned-id", violating) == "warn" + assert "1.0.0" in message("rule.versioned-id", violating) + + +def test_versioned_id_is_not_judged_without_both_a_version_and_an_absolute_id(): + assert outcome("rule.versioned-id", {}) == "ok" + assert outcome("rule.versioned-id", {"x-oold-version": "1.0.0"}) == "ok" + assert outcome("rule.versioned-id", {"x-oold-version": "1.0.0", "$id": "Person.schema.json"}) == "ok" + + # ------------------------------------------------------------------ against the real corpus diff --git a/tests/test_validation/test_pipeline.py b/tests/test_validation/test_pipeline.py index 34e5972..7117d7f 100644 --- a/tests/test_validation/test_pipeline.py +++ b/tests/test_validation/test_pipeline.py @@ -90,6 +90,13 @@ def test_a_context_chain_leaving_the_directory_resolves(remote_context_dir): # no other schema in the corpus declares @base, so without it the predicate is never # reached through the pipeline and would pass by never running. ("base_uri_misaligned.schema.json", "rule.base-alignment", WARN), + ("legacy_dialect.schema.json", "rule.dialect-version", FAIL), + # No other fixture composes two or more allOf/$ref members, so without this one + # rule.context-array-order's >= 2 branch is never reached through the pipeline. + ("context_array_order_mismatch.schema.json", "rule.context-array-order", FAIL), + # No other fixture combines x-oold-version with an absolute $id, so without this one + # rule.versioned-id's comparison is never reached through the pipeline. + ("versioned_id_missing_version.schema.json", "rule.versioned-id", WARN), ], ) def test_each_broken_fixture_fails_the_check_it_targets(broken_dir, fixture, check_id, status): From cbcf95a3042c41078c7b670de4ce77b21db36a31 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Thu, 13 Aug 2026 12:03:52 +0200 Subject: [PATCH 24/29] chore(validation): vendor the 43-rule catalogue - Refreshes meta/1.0.0-rc.1/oold-rules.json from oold-schema: 40 rules to 43 - Adds OOLD-VER-befc, OOLD-VER-4261 and OOLD-EXT-1f92, split out of lead-in lists upstream - `oold rules list --unchecked` goes from 7 to 10 - rules_source now records feat/rule-list-scope (oold-schema PR #124, unmerged) - rule.versioned-id's docstring corrected: it is now explicitly the umbrella over OOLD-VER-befc and OOLD-VER-4261 --- scripts/check_markdown_extensions.py | 6 +- src/oold/validation/check_registry.py | 10 ++- .../meta/1.0.0-rc.1/oold-rules.json | 77 +++++++++++++++---- src/oold/validation/meta/index.json | 8 +- 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/scripts/check_markdown_extensions.py b/scripts/check_markdown_extensions.py index 38266e4..dc5e2c1 100644 --- a/scripts/check_markdown_extensions.py +++ b/scripts/check_markdown_extensions.py @@ -70,11 +70,7 @@ def load_converter(): "Run `uv sync` (or `make install`) so the pinned dev dependency is " "available, then run this via `make check-extensions`." ) - missing = [ - n - for n in ("DEFAULT_MARKDOWN_EXTENSIONS", "_convert_markdown_extensions") - if not hasattr(zconfig, n) - ] + missing = [n for n in ("DEFAULT_MARKDOWN_EXTENSIONS", "_convert_markdown_extensions") if not hasattr(zconfig, n)] if missing: sys.exit( f"zensical.config no longer provides: {', '.join(missing)}.\n" diff --git a/src/oold/validation/check_registry.py b/src/oold/validation/check_registry.py index f5707f1..5ac2660 100644 --- a/src/oold/validation/check_registry.py +++ b/src/oold/validation/check_registry.py @@ -468,10 +468,12 @@ def _context_array_order_mismatched(schema: dict[str, Any], context: ContextView def _version_not_in_schema_location(schema: dict[str, Any], context: ContextView) -> list[str]: """`x-oold-version` should be part of the schema's location URL: OOLD-VER-534a. - The catalogued `text` for this rule is a truncated lead-in ("The version SHOULD be part of - the schema's location:") whose list of URL forms was cut off upstream, so this predicate is - written against the rule's `summary` instead, which states the intent completely: the schema - version should be part of the schema location URL. + The catalogued `text` is the lead-in alone ("The version SHOULD be part of the schema's + location:"); the URL forms it introduces are now `context`, and the two that state a + requirement of their own were split into OOLD-VER-befc and OOLD-VER-4261 upstream. So this + stays the umbrella check: it asks only that the version appear in the location, not which + of the sanctioned layouts put it there. Deliberately - a validator cannot tell which layout + a schema intends, and the third form, a GitHub release tag, is a shape of its own. Only judged when both `x-oold-version` and an absolute `$id` are present: `rule.version` and `rule.id` already cover their absence, and a relative `$id` names no location to carry a diff --git a/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json index aebb60d..2a9f793 100644 --- a/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json +++ b/src/oold/validation/meta/1.0.0-rc.1/oold-rules.json @@ -152,6 +152,21 @@ "deprecated": false, "source": "03-conformance.md:9" }, + { + "id": "OOLD-EXT-1f92", + "area": "EXT", + "level": "RECOMMENDED", + "applies_to": "document", + "section": "range-reference-form", + "summary": "iri-reference is the recommended default format for an IRI-valued property.", + "text": "By RFC3987 this accepts absolute IRIs, compact IRIs (`ex:alice`, `schema:Person`) and context-relative references alike - the forms OO-LD instances routinely use - so it is the RECOMMENDED default.", + "text_sha256": "5d4c4a4f84533b109985d2562ad3459f3c9ce3dcd42d66aebeca8ca6c182e815", + "context": "- Any IRI reference - `\"format\": \"iri-reference\"`. By RFC3987 this accepts absolute IRIs, compact IRIs (`ex:alice`, `schema:Person`) and context-relative references alike - the forms OO-LD instances routinely use - so it is the RECOMMENDED default. It also accepts a bare term such as `alice`, expanded against the context's `@base` / `@vocab`.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:326" + }, { "id": "OOLD-EXT-2b61", "area": "EXT", @@ -161,11 +176,11 @@ "summary": "A compact-IRI prefix used by a property must be defined in the @context.", "text": "Compact form specifically - a `\"pattern\"` such as `\"^[A-Za-z_][\\\\w.-]:(?!//)\\\\S$\"`, which accepts `ex:alice` and `schema:Person` while rejecting `http://…`; the prefix MUST be defined in the `@context`.", "text_sha256": "9ed092ee23b716d012311740effff835e67e883d41215e2fd27f66107a383708", - "context": "Stricter, ASCII only - `\"format\": \"uri\"` or `\"uri-reference\"`, where values are known not to use internationalized (non-ASCII) IRIs. - Compact form specifically - a `\"pattern\"` such as `\"^[A-Za-z_][\\\\w.-]:(?!//)\\\\S$\"`, which accepts `ex:alice` and `schema:Person` while rejecting `http://…`; the prefix MUST be defined in the `@context`.", + "context": "- Stricter, ASCII only - `\"format\": \"uri\"` or `\"uri-reference\"`, where values are known not to use internationalized (non-ASCII) IRIs.\n- Compact form specifically - a `\"pattern\"` such as `\"^[A-Za-z_][\\\\w.-]:(?!//)\\\\S$\"`, which accepts `ex:alice` and `schema:Person` while rejecting `http://…`; the prefix MUST be defined in the `@context`.", "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:327" + "source": "09-extensions.md:329" }, { "id": "OOLD-EXT-3fe9", @@ -176,7 +191,7 @@ "summary": "References inside x-oold-range must use x-oold-ref, never $ref.", "text": "References to other schemas inside `x-oold-range` MUST use `x-oold-ref`, never `$ref` (see below).", "text_sha256": "6626132bb2c6a394d430fb9e4db9a559c26b71914acdc07ed8fb3a4b06c88d75", - "context": "An OO-LD subschema, the most expressive form. Unions (`anyOf` / `oneOf`), intersections (`allOf`) and inline constraints can be combined to describe an anonymous subclass. References to other schemas inside `x-oold-range` MUST use `x-oold-ref`, never `$ref` (see below). The single-IRI form (1) is a shorthand for `{ \"allOf\": [ { \"x-oold-ref\": \"Organization.schema.json\" } ] }`:", + "context": "3. An OO-LD subschema, the most expressive form. Unions (`anyOf` / `oneOf`), intersections (`allOf`) and inline constraints can be combined to describe an anonymous subclass. References to other schemas inside `x-oold-range` MUST use `x-oold-ref`, never `$ref` (see below). The single-IRI form (1) is a shorthand for `{ \"allOf\": [ { \"x-oold-ref\": \"Organization.schema.json\" } ] }`:", "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, @@ -191,11 +206,11 @@ "summary": "For OpenAPI 3.0, deliver the context and type per class as vendor extensions.", "text": "For OpenAPI 3.0, which rejects unprefixed keywords in a Schema Object (and typically bundles several classes with no document root to host one `@context`), the context and type SHOULD be delivered per class as `x-jsonld-context` and `x-jsonld-type` following [REST API Linked Data Keywords](https://datatracker.ietf.org/doc/html/draft-polli-restapi-ld-keywords-08): `@context` maps to `x-jsonld-context` and `x-oold-instance-rdf-type` to `x-jsonld-type`.", "text_sha256": "90622f946111378db3b4fed08c6981f87e452e982f9f208a562ada1ade8122da", - "context": "For OpenAPI 3.0, which rejects unprefixed keywords in a Schema Object (and typically bundles several classes with no document root to host one `@context`), the context and type SHOULD be delivered per class as `x-jsonld-context` and `x-jsonld-type` following [REST API Linked Data Keywords](https://datatracker.ietf.org/doc/html/draft-polli-restapi-ld-keywords-08): `@context` maps to `x-jsonld-context` and `x-oold-instance-rdf-type` to `x-jsonld-type`. That draft requires references inside these keywords not to be dereferenced automatically, consistent with the `x-oold-ref` rule (see [](#why-x-oold-ref)). The mapping is reversible, so such an export can be read back into an OO-LD schema.", + "context": "- For OpenAPI 3.0, which rejects unprefixed keywords in a Schema Object (and typically bundles several classes with no document root to host one `@context`), the context and type SHOULD be delivered per class as `x-jsonld-context` and `x-jsonld-type` following [REST API Linked Data Keywords](https://datatracker.ietf.org/doc/html/draft-polli-restapi-ld-keywords-08): `@context` maps to `x-jsonld-context` and `x-oold-instance-rdf-type` to `x-jsonld-type`. That draft requires references inside these keywords not to be dereferenced automatically, consistent with the `x-oold-ref` rule (see [](#why-x-oold-ref)). The mapping is reversible, so such an export can be read back into an OO-LD schema.", "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:427" + "source": "09-extensions.md:429" }, { "id": "OOLD-EXT-5184", @@ -221,11 +236,11 @@ "summary": "A consumer accepting arbitrary JSON Schema keywords should receive the native form unchanged.", "text": "A consumer that accepts arbitrary JSON Schema keywords SHOULD receive the native form unchanged.", "text_sha256": "aa1601aa9ebbaf66046ee3c18acd7690ee8da151843196a42f3d4c1958ed42cd", - "context": "A consumer that accepts arbitrary JSON Schema keywords SHOULD receive the native form unchanged. This covers plain JSON Schema 2020-12 validators, OpenAPI 3.1, and - because they place no restriction on `@context` - Model Context Protocol tool schemas (`inputSchema` / `outputSchema`) as well as LLM tool-use and structured-output APIs, which carry the context through and can use it as grounding.", + "context": "- A consumer that accepts arbitrary JSON Schema keywords SHOULD receive the native form unchanged. This covers plain JSON Schema 2020-12 validators, OpenAPI 3.1, and - because they place no restriction on `@context` - Model Context Protocol tool schemas (`inputSchema` / `outputSchema`) as well as LLM tool-use and structured-output APIs, which carry the context through and can use it as grounding.", "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:426" + "source": "09-extensions.md:428" }, { "id": "OOLD-EXT-6ea3", @@ -236,11 +251,11 @@ "summary": "An IRI-valued property should constrain its lexical form with an IRI/URI-family format.", "text": "Its lexical form SHOULD be constrained with an IRI/URI-family `format` so that malformed values are rejected; the choices, from most to least permissive:", "text_sha256": "a1fd73169ee2f84b0a897e74e5495e9019dd4a8ac1c763a108fc8cb02a0afe9e", - "context": "The value of an IRI-valued property is a JSON string. Its role as a reference comes from the `@context` (`\"@type\": \"@id\"`) and its class from `x-oold-range`. Its lexical form SHOULD be constrained with an IRI/URI-family `format` so that malformed values are rejected; the choices, from most to least permissive:", + "context": "The value of an IRI-valued property is a JSON string. Its role as a reference comes from the `@context` (`\"@type\": \"@id\"`) and its class from `x-oold-range`. Its lexical form SHOULD be constrained with an IRI/URI-family `format` so that malformed values are rejected; the choices, from most to least permissive:\n- Any IRI reference - `\"format\": \"iri-reference\"`. By RFC3987 this accepts absolute IRIs, compact IRIs (`ex:alice`, `schema:Person`) and context-relative references alike - the forms OO-LD instances routinely use - so it is the RECOMMENDED default. It also accepts a bare term such as `alice`, expanded against the context's `@base` / `@vocab`.\n- Absolute IRIs only - `\"format\": \"iri\"`. A compact IRI is itself a valid absolute IRI (scheme `ex`, path `alice`), so `iri` accepts `ex:alice`; choose it to additionally forbid relative references.\n- Stricter, ASCII only - `\"format\": \"uri\"` or `\"uri-reference\"`, where values are known not to use internationalized (non-ASCII) IRIs.", "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "09-extensions.md:322" + "source": "09-extensions.md:324" }, { "id": "OOLD-EXT-af50", @@ -326,7 +341,7 @@ "summary": "Under the value-form pattern a reference is written as an object and its term must not carry @type.", "text": "References are written as objects, and the term MUST NOT carry `@type`.", "text_sha256": "7ed52efe5f63156ec8bd6abec09bae6117bf64fead052cf226687b15def1f02c", - "context": "Value-form - a single plain term (no `@type: \"@id\"`); the value shape alone disambiguates: a bare scalar is a literal, `{ \"id\": ... }` is a reference, a typed object is embedded. References are written as objects, and the term MUST NOT carry `@type`.", + "context": "1. Value-form - a single plain term (no `@type: \"@id\"`); the value shape alone disambiguates: a bare scalar is a literal, `{ \"id\": ... }` is a reference, a typed object is embedded. References are written as objects, and the term MUST NOT carry `@type`.", "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, @@ -461,7 +476,7 @@ "summary": "A model ecosystem should adopt one of the two ambiguous-range patterns consistently.", "text": "For a property whose range mixes free text with references and/or embedded objects (for example `Text | PostalAddress | Place`), two patterns keep the instance round-trippable (see [](#round-trip)); a model ecosystem SHOULD adopt one of them consistently:", "text_sha256": "fd5834cb52f2ba7a41d5919db177c636bb08de85e3d8484c246a8c70e88fe921", - "context": "For a property whose range mixes free text with references and/or embedded objects (for example `Text | PostalAddress | Place`), two patterns keep the instance round-trippable (see [](#round-trip)); a model ecosystem SHOULD adopt one of them consistently:", + "context": "For a property whose range mixes free text with references and/or embedded objects (for example `Text | PostalAddress | Place`), two patterns keep the instance round-trippable (see [](#round-trip)); a model ecosystem SHOULD adopt one of them consistently:\n1. Value-form - a single plain term (no `@type: \"@id\"`); the value shape alone disambiguates: a bare scalar is a literal, `{ \"id\": ... }` is a reference, a typed object is embedded. References are written as objects, and the term MUST NOT carry `@type`.\n2. Separate keys - a canonical term `p` with `@type: \"@id\"` (a bare IRI string reference, plus embedded objects via a scoped `@context`) and a companion `p_text` that is a plain term for the literal.", "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, @@ -491,7 +506,7 @@ "summary": "A strictly array-typed property must declare @container @set or @list.", "text": "Because the reconstruction MUST re-validate, a property that is strictly an array (JSON Schema `type: \"array\"`) MUST declare `@container: \"@set\"` (or `\"@list\"`): without it a single-element array returns as a scalar and violates the `array` type.", "text_sha256": "cccd90d1135476689792616dac8db9b85956567b4d689637621b77cdbec356f5", - "context": "Multi-valued properties are set-valued in RDF: order is not preserved, duplicates are removed, and a single value compacts to a scalar. Because the reconstruction MUST re-validate, a property that is strictly an array (JSON Schema `type: \"array\"`) MUST declare `@container: \"@set\"` (or `\"@list\"`): without it a single-element array returns as a scalar and violates the `array` type. A property that also permits a scalar (an `anyOf`/`oneOf` of a literal and an array) MAY declare it for a stable array shape, but need not - the scalar form still validates, and a single value and a one-element array are JSON-LD-equivalent. Round-trip equality is set equality; use `@list` only where order is significant, at the cost of merge and query ergonomics.", + "context": "- Multi-valued properties are set-valued in RDF: order is not preserved, duplicates are removed, and a single value compacts to a scalar. Because the reconstruction MUST re-validate, a property that is strictly an array (JSON Schema `type: \"array\"`) MUST declare `@container: \"@set\"` (or `\"@list\"`): without it a single-element array returns as a scalar and violates the `array` type. A property that also permits a scalar (an `anyOf`/`oneOf` of a literal and an array) MAY declare it for a stable array shape, but need not - the scalar form still validates, and a single value and a one-element array are JSON-LD-equivalent. Round-trip equality is set equality; use `@list` only where order is significant, at the cost of merge and query ergonomics.", "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, @@ -536,7 +551,7 @@ "summary": "An OO-LD schema document must not be interpreted as a JSON-LD document.", "text": "OO-LD schema documents MUST NOT be interpreted as JSON-LD documents, because that would apply the schema's own `@context` to the schema itself and produce incorrect triples.", "text_sha256": "635a77aac991bbe8295616c5465c2963e0d6a51618ed49c2d835d448dc53bfca", - "context": "An OO-LD schema is consumed as a JSON-LD remote context (referenced by its URL from an instance's `@context`), never as a JSON-LD document. OO-LD schema documents MUST NOT be interpreted as JSON-LD documents, because that would apply the schema's own `@context` to the schema itself and produce incorrect triples.", + "context": "- An OO-LD schema is consumed as a JSON-LD remote context (referenced by its URL from an instance's `@context`), never as a JSON-LD document. OO-LD schema documents MUST NOT be interpreted as JSON-LD documents, because that would apply the schema's own `@context` to the schema itself and produce incorrect triples.", "machine_checkable": false, "since": "1.0.0-rc.1", "deprecated": false, @@ -570,7 +585,7 @@ "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "08-identification-versioning.md:49" + "source": "08-identification-versioning.md:51" }, { "id": "OOLD-VER-3b96", @@ -587,6 +602,21 @@ "deprecated": false, "source": "08-identification-versioning.md:5" }, + { + "id": "OOLD-VER-4261", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "versioning", + "summary": "Under schema-package versioning, the package version should be prepended before the schema id.", + "text": "For schema-package versioning (recommended), the version of the package SHOULD be prepended before the schema's ID, e.g. `https://example.org/my-package/2.0.0/b5203131-7321-46bb-8a11-acb3d1015840.schema.json`.", + "text_sha256": "4e2e5321caa2b8551df124896634f32ecdcd314d40dadde918a6310f297acf1b", + "context": "- For schema-package versioning (recommended), the version of the package SHOULD be prepended before the schema's ID, e.g. `https://example.org/my-package/2.0.0/b5203131-7321-46bb-8a11-acb3d1015840.schema.json`.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:68" + }, { "id": "OOLD-VER-534a", "area": "VER", @@ -596,11 +626,26 @@ "summary": "The schema version should be part of the schema location URL.", "text": "The version SHOULD be part of the schema's location:", "text_sha256": "9e4671a42c0df7845c72b1cb55c9723532573150183495259835ccfab7f4d6e2", - "context": "The version SHOULD be part of the schema's location:", + "context": "The version SHOULD be part of the schema's location:\n- For single-schema versioning, the version SHOULD be appended after the schema name, e.g. `https://example.org/b5203131-7321-46bb-8a11-acb3d1015840.schema.json/1.1.0`.\n- For schema-package versioning (recommended), the version of the package SHOULD be prepended before the schema's ID, e.g. `https://example.org/my-package/2.0.0/b5203131-7321-46bb-8a11-acb3d1015840.schema.json`.\n- or using release tags on GitHub, e.g. `https://raw.githubusercontent.com/MyOrg/my-package/refs/heads/2.0.0/b5203131-7321-46bb-8a11-acb3d1015840.schema.json`.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:65" + }, + { + "id": "OOLD-VER-befc", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "versioning", + "summary": "Under single-schema versioning, the version should be appended after the schema name in the $id.", + "text": "For single-schema versioning, the version SHOULD be appended after the schema name, e.g. `https://example.org/b5203131-7321-46bb-8a11-acb3d1015840.schema.json/1.1.0`.", + "text_sha256": "3e485bd18653ac3e879efe279ac5938bf1e8657575a547e99f5084e3f1cb9089", + "context": "- For single-schema versioning, the version SHOULD be appended after the schema name, e.g. `https://example.org/b5203131-7321-46bb-8a11-acb3d1015840.schema.json/1.1.0`.", "machine_checkable": true, "since": "1.0.0-rc.1", "deprecated": false, - "source": "08-identification-versioning.md:63" + "source": "08-identification-versioning.md:67" }, { "id": "OOLD-VER-edb9", diff --git a/src/oold/validation/meta/index.json b/src/oold/validation/meta/index.json index de81837..a709e79 100644 --- a/src/oold/validation/meta/index.json +++ b/src/oold/validation/meta/index.json @@ -43,17 +43,17 @@ "added": "2026-08-04", "id_base": "https://oo-ld.org/latest/meta/", "prerelease": true, - "notes": "First version to carry oold-rules.json, the catalogue of normative statements, and oold-rules.schema.json, which describes it. The three meta-schemas are verbatim from the v1.0.0-rc.1 tag. The catalogue is provisional: no release has shipped one yet, so it comes from the oold-schema branch feat/rule-catalog-rc1 instead, and rules_source records which commit. The v1.0.0-rc.1 tag itself will never gain a catalogue, so there is nothing here to refresh from a tag. When a release does ship one, vendor that version as its own entry rather than back-filling this one. Refreshed to a later feat/rule-catalog-rc1 commit that mints hex rule ids (OOLD-RT-08f2 rather than OOLD-RT-002), renames checkable to machine_checkable, adds a context field, and narrows text to the single normative sentence.", + "notes": "First version to carry oold-rules.json, the catalogue of normative statements, and oold-rules.schema.json, which describes it. The three meta-schemas are verbatim from the v1.0.0-rc.1 tag. The catalogue is provisional: no release has shipped one yet, so it comes from an unmerged oold-schema branch and rules_source records which commit. This refresh brought sentence-scoped `text` with the surrounding block kept as `context`, the `checkable` to `machine_checkable` rename, and the rules split out of lead-in lists. The v1.0.0-rc.1 tag itself will never gain a catalogue, so there is nothing here to refresh from a tag. When a release does ship one, vendor that version as its own entry rather than back-filling this one.", "rules_source": { - "branch": "feat/rule-catalog-rc1", - "commit": "3ebbcd85e829760052e3fda6858f05c7a483ee4a", + "branch": "feat/rule-list-scope", + "commit": "30534ecbb3956afb1022e09c99d2aeec0737ea3c", "released": false }, "sha256": { "oold-meta-schema.json": "cad3151c6bf0ac3e74acd46a4fee59b9287a551a9f62aa68aa7e2a718f360dbc", "oold-pattern-lint.schema.json": "d89fce19cd2fd42fa740d92968fcf61a1764ea25e741ed5cd4e72040a45c9a86", "oold-ui-meta-schema.json": "dd389d13a5e03268d4a4ff845dec7f4f28238f7edbd9fe0992399b37ac358212", - "oold-rules.json": "1d9bae4ffac7c500725793b5745f32d0d450d4226707ef390656a2193ba7418e", + "oold-rules.json": "4c96768fec8ee16cc9337eeb055fd775f31af6c4c3d3c1c86caa75b0e4880bca", "oold-rules.schema.json": "71e0d2e437d05a0a718612ed273993c3e216681c6c4cd426a6b3c1018f07e07a" } } From 5bcd043721226b48280e7980890264df9cdc623b Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Thu, 13 Aug 2026 14:26:46 +0200 Subject: [PATCH 25/29] docs(validation): record catalogue's source so a rebase cannot orphan it - rules_source now records the pull request number alongside the repository, branch and pre-merge commit - A rebased branch orphans a recorded commit; the entry it replaces pointed at exactly such an orphan on feat/rule-catalog-rc1 - Adds a `merged` slot to fill in once #124 lands --- src/oold/validation/meta/index.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/oold/validation/meta/index.json b/src/oold/validation/meta/index.json index a709e79..8cf65df 100644 --- a/src/oold/validation/meta/index.json +++ b/src/oold/validation/meta/index.json @@ -45,8 +45,12 @@ "prerelease": true, "notes": "First version to carry oold-rules.json, the catalogue of normative statements, and oold-rules.schema.json, which describes it. The three meta-schemas are verbatim from the v1.0.0-rc.1 tag. The catalogue is provisional: no release has shipped one yet, so it comes from an unmerged oold-schema branch and rules_source records which commit. This refresh brought sentence-scoped `text` with the surrounding block kept as `context`, the `checkable` to `machine_checkable` rename, and the rules split out of lead-in lists. The v1.0.0-rc.1 tag itself will never gain a catalogue, so there is nothing here to refresh from a tag. When a release does ship one, vendor that version as its own entry rather than back-filling this one.", "rules_source": { + "$comment": "Where this catalogue was taken from. `pr` is the durable reference: oold-schema rebases a branch when it merges, so `commit` names a pre-merge SHA that stops existing on main - the entry this replaced pointed at exactly such an orphan. Once the pull request merges, set `merged` to its commit on main and leave `pr` as the record of where it came from.", + "pr": 124, + "repository": "https://github.com/OO-LD/oold-schema", "branch": "feat/rule-list-scope", "commit": "30534ecbb3956afb1022e09c99d2aeec0737ea3c", + "merged": null, "released": false }, "sha256": { From 09ede4a0fca8fdfd2262ec532815011238afd6dd Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Thu, 13 Aug 2026 16:40:01 +0200 Subject: [PATCH 26/29] feat(validation): track upstream's two-tier meta-schema split - oold-schema splits the dialect meta-schema into a wrapper plus oold-meta-schema-base.json - `files` in meta/index.json is now per-source via `meta_files(source)`, so only the remote bundle carries the new base file - Restores parity, which had been failing 4 of 6 because the wrapper's $ref could not resolve - declared_keywords() now collects x-oold-* across every document in the bundle, restoring all 26 keywords and picking up x-oold-sssom - Two meta_store tests updated to serve the four-file remote list --- src/oold/validation/meta/README.md | 17 ++++++++ src/oold/validation/meta/index.json | 9 ++++- src/oold/validation/meta_store.py | 51 +++++++++++++++++++----- tests/test_validation/test_meta_store.py | 29 ++++++++++---- 4 files changed, 86 insertions(+), 20 deletions(-) diff --git a/src/oold/validation/meta/README.md b/src/oold/validation/meta/README.md index 1f12593..8ccd9ef 100644 --- a/src/oold/validation/meta/README.md +++ b/src/oold/validation/meta/README.md @@ -17,6 +17,23 @@ Which version `latest` resolves to is deliberately not written down here. It is present, decided by `tracked_versions()`, and `oold meta list` prints it. A hand-maintained copy of a derived fact only rots: this line used to name 0.8.0 and was still naming it two versions later. +## The file list is per source, not global + +`index.json`'s top-level `files` is the *shared default* file set - the three meta-schemas every +tracked version ships today. `meta_files(source)` reads it for a tracked version, or for `remote`, +but a source can override it with its own `files` entry when its set actually differs. `remote` +already does: unreleased `main` split the dialect meta-schema into a wrapper +(`oold-meta-schema.json`, document-level obligations) and a body it `$ref`s +(`oold-meta-schema-base.json`, the keyword syntax), so `remote.files` names four files instead of +the shared three. No tracked version has that split, so none is made to load a file it does not +have; `remote.files` is declared once, in `index.json`, rather than in code. + +A future release that ships the same split (or any other file-set change) declares it the same +way: add a `files` list to that version's own entry under `versions`, naming exactly what it +ships. Omit it, and the version falls back to the shared default. A file a source's list names but +does not have is still a load error, not a silent skip - drift here is exactly what this is meant +to catch. + Nothing here is written at runtime. `--meta remote` fetches the unreleased `main` state into the user cache (`~/.cache/oold/meta/`, or `OOLD_CACHE_DIR`) and never touches this folder, so a released version cannot change meaning behind your back. diff --git a/src/oold/validation/meta/index.json b/src/oold/validation/meta/index.json index 8cf65df..6596a19 100644 --- a/src/oold/validation/meta/index.json +++ b/src/oold/validation/meta/index.json @@ -3,7 +3,14 @@ "source_repository": "https://github.com/OO-LD/oold-schema", "remote": { "ref": "refs/heads/main", - "base_url": "https://raw.githubusercontent.com/OO-LD/oold-schema/refs/heads/main/meta/" + "base_url": "https://raw.githubusercontent.com/OO-LD/oold-schema/refs/heads/main/meta/", + "$comment": "files overrides the top-level default for this source only. main split the dialect meta-schema into a wrapper (oold-meta-schema.json, document-level obligations) and a body ($ref'd from it, oold-meta-schema-base.json, the keyword syntax and $dynamicAnchor). No released version has that split, so it is declared here rather than in the shared default, which tracked versions still read unchanged.", + "files": [ + "oold-meta-schema.json", + "oold-meta-schema-base.json", + "oold-pattern-lint.schema.json", + "oold-ui-meta-schema.json" + ] }, "files": [ "oold-meta-schema.json", diff --git a/src/oold/validation/meta_store.py b/src/oold/validation/meta_store.py index 76dde75..6a7cc95 100644 --- a/src/oold/validation/meta_store.py +++ b/src/oold/validation/meta_store.py @@ -85,8 +85,24 @@ def load_index() -> dict[str, Any]: raise MetaSchemaError(f"meta-schema index is not valid JSON: {exc}") from exc -def meta_files() -> list[str]: +def meta_files(source: str | None = None) -> list[str]: + """The meta-schema file names to load for one source. + + ``source`` is a tracked version name, :data:`REMOTE`, or omitted for the shared default that + most tracked versions use. A source-specific ``files`` list in ``index.json`` - a version's + own entry, or ``remote.files`` - wins over that default, so a source whose file set changes + (the way unreleased ``main`` split the meta-schema into a wrapper and a base) can declare it + there without a code change, while tracked versions that do not override it keep loading + exactly the default three. + """ index = load_index() + override = None + if source == REMOTE: + override = (index.get("remote") or {}).get("files") + elif source is not None: + override = (index.get("versions", {}).get(source) or {}).get("files") + if isinstance(override, list) and override: + return list(override) files = index.get("files") if not isinstance(files, list) or not files: return [META_SCHEMA_FILE, PATTERN_LINT_FILE, UI_META_SCHEMA_FILE] @@ -243,8 +259,19 @@ def declared_keywords(self) -> list[str]: Used by the vocabulary-coverage cross-check, which fails when a keyword exists in the meta-schemas but no compliance fixture exercises it. + + Collected across every document in the bundle, not from the dialect wrapper alone. + Upstream split the dialect into a wrapper carrying the document-level obligations and a + body holding the keyword syntax, which moved most `x-oold-*` definitions out of the + wrapper: reading only that one lost 14 of 26 keywords on the split bundle, and the + coverage check went quietly vacuous over what remained rather than failing. """ - keywords = [key for key in (self.meta.get("properties") or {}) if key.startswith("x-oold-")] + keywords = [ + key + for document in self.documents.values() + for key in (document.get("properties") or {}) + if key.startswith("x-oold-") + ] ui_keywords = (self.ui_meta.get("$defs") or {}).get("keywords", {}).get("properties") or {} keywords.extend(ui_keywords) return sorted(set(keywords)) @@ -312,9 +339,9 @@ def _read_rules_schema(directory: Path) -> dict[str, Any] | None: return None -def _read_documents(directory: Path, label: str) -> dict[str, Any]: +def _read_documents(directory: Path, label: str, files: list[str]) -> dict[str, Any]: documents: dict[str, Any] = {} - for name in meta_files(): + for name in files: path = directory / name try: documents[name] = json.loads(path.read_text(encoding="utf-8")) @@ -331,7 +358,7 @@ def load_tracked(version: str) -> MetaBundle: if not directory.is_dir(): available = ", ".join(tracked_versions()) or "none" raise MetaSchemaError(f"meta-schema version {version!r} is not tracked (available: {available})") - documents = _read_documents(directory, f"meta-schema version {version}") + documents = _read_documents(directory, f"meta-schema version {version}", meta_files(version)) catalog, catalog_error = _read_rules(directory) return MetaBundle( version=version, @@ -352,12 +379,13 @@ def fetch_remote(force: bool = False, timeout: float = 10.0) -> Path: """Fetch the unreleased ``main`` meta-schemas into the user cache and return its path.""" target = remote_cache_dir() stamp = target / "fetched.json" - if not force and all((target / name).is_file() for name in meta_files()): + files = meta_files(REMOTE) + if not force and all((target / name).is_file() for name in files): return target base = remote_base_url() target.mkdir(parents=True, exist_ok=True) - for name in meta_files(): + for name in files: document = http_get_json(base + name, timeout=timeout) (target / name).write_text(json.dumps(document, indent=2), encoding="utf-8") try: @@ -371,7 +399,7 @@ def fetch_remote(force: bool = False, timeout: float = 10.0) -> Path: { "base_url": base, "fetched": datetime.now(timezone.utc).isoformat(timespec="seconds"), - "files": meta_files(), + "files": files, }, indent=2, ), @@ -383,7 +411,8 @@ def fetch_remote(force: bool = False, timeout: float = 10.0) -> Path: def load_remote(offline: bool = False, timeout: float = 10.0) -> MetaBundle: """Load the ``main`` meta-schemas, from the cache when offline.""" target = remote_cache_dir() - cached = all((target / name).is_file() for name in meta_files()) + files = meta_files(REMOTE) + cached = all((target / name).is_file() for name in files) if not cached: if offline: @@ -396,7 +425,7 @@ def load_remote(offline: bool = False, timeout: float = 10.0) -> MetaBundle: except SchemaResolutionError as exc: raise MetaSchemaError(f"could not fetch the remote meta-schemas: {exc}") from exc - documents = _read_documents(target, "the remote meta-schemas") + documents = _read_documents(target, "the remote meta-schemas", files) origin = str(target) stamp = target / "fetched.json" if stamp.is_file(): @@ -458,7 +487,7 @@ def describe_store() -> dict[str, Any]: index = load_index() versions = tracked_versions() cache = remote_cache_dir() - cached = all((cache / name).is_file() for name in meta_files()) + cached = all((cache / name).is_file() for name in meta_files(REMOTE)) fetched = None stamp = cache / "fetched.json" diff --git a/tests/test_validation/test_meta_store.py b/tests/test_validation/test_meta_store.py index f1e28c9..038d0f2 100644 --- a/tests/test_validation/test_meta_store.py +++ b/tests/test_validation/test_meta_store.py @@ -264,12 +264,28 @@ def test_remote_is_refused_offline_when_not_cached(isolated_cache): resolve_selection(["remote"], offline=True) +def _remote_documents() -> dict: + """What a fake upstream serves for ``--meta remote``: the tracked baseline, plus whatever + ``remote.files`` in ``index.json`` adds on top (e.g. a wrapper/base split no tracked version + has yet). A minimal, self-contained schema stands in for a file no tracked version ships. + """ + source = meta_store.meta_dir() / latest_version() + documents = {} + for name in meta_store.meta_files(meta_store.REMOTE): + path = source / name + if path.is_file(): + documents[name] = json.loads(path.read_text("utf-8")) + else: + documents[name] = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": f"https://example.invalid/{name}", + } + return documents + + def test_remote_fetch_writes_only_to_the_cache(isolated_cache, monkeypatch, tmp_path): """A remote fetch must never touch the tracked version history.""" - documents = { - name: json.loads((meta_store.meta_dir() / latest_version() / name).read_text("utf-8")) - for name in meta_store.meta_files() - } + documents = _remote_documents() before = {path: path.read_bytes() for path in meta_store.meta_dir().rglob("*.json")} def fake_get(uri, timeout=10.0): @@ -291,10 +307,7 @@ def fake_get(uri, timeout=10.0): def test_cached_remote_is_usable_offline(isolated_cache, monkeypatch): - documents = { - name: json.loads((meta_store.meta_dir() / latest_version() / name).read_text("utf-8")) - for name in meta_store.meta_files() - } + documents = _remote_documents() def fake_get(uri, timeout=10.0): name = uri.rsplit("/", 1)[-1] From bee7204df2b65240e9cf90add141c3eac8467968 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 14 Aug 2026 07:54:15 +0200 Subject: [PATCH 27/29] feat(validation): enforce two more rules, and leave the third alone - rule.context-reflects-refs (OOLD-CMP-b926) and rule.branch-context-conflict (OOLD-CMP-1d7e); unenforced count 10 to 8 - Both read the authored @context rather than the resolved view, the same exception rule.context-array-order already takes - rule.branch-context-conflict is narrowed via `entries`, distinguishing an authored override (dict) from a reflected conflict (string) - OOLD-INS-1df7 deliberately not implemented; it duplicates what rule.free-text-iri already checks - Both checks produce zero findings on the corpus and needed a broken fixture each to be reached --- src/oold/validation/check_registry.py | 118 +++++++++++++++++- tests/data/oold/README.md | 2 + tests/data/oold/broken/Gauge.schema.json | 15 +++ tests/data/oold/broken/Sensor.schema.json | 15 +++ .../branch_context_conflict.schema.json | 9 ++ .../broken/root_ref_not_reflected.schema.json | 9 ++ tests/test_validation/test_check_registry.py | 84 +++++++++++++ tests/test_validation/test_pipeline.py | 6 + 8 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 tests/data/oold/broken/Gauge.schema.json create mode 100644 tests/data/oold/broken/Sensor.schema.json create mode 100644 tests/data/oold/broken/branch_context_conflict.schema.json create mode 100644 tests/data/oold/broken/root_ref_not_reflected.schema.json diff --git a/src/oold/validation/check_registry.py b/src/oold/validation/check_registry.py index 5ac2660..7000780 100644 --- a/src/oold/validation/check_registry.py +++ b/src/oold/validation/check_registry.py @@ -4,13 +4,13 @@ in this package produced it, and the rule id (``OOLD-RT-08f2``) names the normative statement it enforces, when there is one. Rule ids come from the specification and are permanent; check ids are implementation-defined and follow this package's structure. A finding cites both, because -fourteen of the twenty-eight checks enforce no rule at all - `schema.meta` is definitional, +fourteen of the thirty-eight checks enforce no rule at all - `schema.meta` is definitional, `generate.satisfiable`, `variants` and the `roundtrip.*` checks are this validator's methodology, `coverage.*` are self-tests about the fixture suite, `meta.self-check` and `rule.checks` report on the run itself rather than on a schema, and `compliance.suite`/`compliance.*` are the deterministic fixture suite's own outcomes - and for those the check id is the only identifier a user has. -This module holds two things that used to live apart. The ten ``rule.*`` checks each enforce +This module holds two things that used to live apart. The twenty ``rule.*`` checks each enforce exactly one normative statement and are narrow enough to be self-contained predicates over an already-resolved :class:`ContextView`, so they are declared here and executed by :func:`run_rule_checks`. The other eighteen checks are driven by the phases in ``pipeline.py`` and @@ -490,6 +490,102 @@ def _version_not_in_schema_location(schema: dict[str, Any], context: ContextView return [f"x-oold-version {version!r} does not appear in the absolute $id {identifier!r}"] +def _root_ref_missing_from_context(schema: dict[str, Any], context: ContextView) -> list[str]: + """A schema's single root-level `allOf` `$ref` must be reflected in its own `@context`. + + Deliberate exception to "judge the resolved context" (see CLAUDE.md), for the same reason as + `rule.context-array-order`: whether a schema stays directly usable as a remote `@context` with + no further processing is a statement about its own, authored `@context`, not about what a term + means once inheritance is applied. So this predicate reads `schema["@context"]` and + `schema["allOf"]` literally, like that check does. + + `rule.context-array-order` (OOLD-CMP-e4a3) already covers two or more `allOf` `$ref`s and the + order they must appear in, including reporting one that is missing entirely; this is the + residue it never reaches, the single-`$ref` case, where there is no order to judge, only + presence. `rule.scoped-context` (OOLD-CMP-5266) covers a property-level `$ref` separately, so + only root-level composition is judged here. + """ + allof = schema.get("allOf") + if not isinstance(allof, list): + return [] + targets = [entry["$ref"] for entry in allof if isinstance(entry, dict) and isinstance(entry.get("$ref"), str)] + if len(targets) != 1: + return [] # 0: nothing to reflect; 2+: rule.context-array-order's job + + target = targets[0] + literal_context = schema.get("@context") + if isinstance(literal_context, list) and target in literal_context: + return [] + if isinstance(literal_context, str) and literal_context == target: + return [] + if isinstance(literal_context, dict) and literal_context.get("@import") == target: + return [] + return [ + f"allOf composes {target!r} as a remote context but @context does not reflect it, so this " + "schema would need further processing before it can be interpreted as a JSON-LD context" + ] + + +def _has_ref_branch(schema: dict[str, Any]) -> bool: + """Whether `oneOf`/`anyOf` composes at least one branch by `$ref`.""" + for keyword in ("oneOf", "anyOf"): + variants = schema.get(keyword) + if not isinstance(variants, list): + continue + for variant in variants: + if isinstance(variant, dict) and _ref_target(variant): + return True + return False + + +def _branch_context_conflict(schema: dict[str, Any], context: ContextView) -> list[str]: + """Reflected `oneOf`/`anyOf` branch contexts must not conflict at the root. + + Scoped to schemas that actually compose `oneOf`/`anyOf` branches by `$ref` - only those have + branch contexts that could be reflected at all; an inline branch (see `rule.free-text-iri`'s + value-form examples) has no remote context of its own to conflict. A JSON-LD processor merges + every `@context` array entry left to right with no notion of which branch an instance matched, + so a root-level conflict would be decided by array order rather than by which branch the data + actually conforms to. + + Only conflicts between *reflected* entries count. The specification separately allows a schema + to "append its own context object as the last array entry to override an inherited term", and + in the resolved view that override is indistinguishable from a conflict: the same term, two + IRIs, two entries. What tells them apart is how the entry was authored - a string is a remote + context reflected into the root, a dict is the schema's own object. `entries` keeps the + authored order and length, resolving a reference in place, so the two line up by position and + an override by the schema's own object is skipped rather than reported. + """ + if not _has_ref_branch(schema): + return [] + + authored = schema.get("@context") + authored = authored if isinstance(authored, list) else [authored] + reflected = [not isinstance(entry, dict) for entry in authored] + + seen: dict[str, str] = {} + problems: list[str] = [] + for position, entry in enumerate(context.entries): + if not isinstance(entry, dict): + continue + # An entry the schema wrote itself may override anything above it; only a reflected + # remote context can conflict in the sense this rule forbids. + if position >= len(reflected) or not reflected[position]: + continue + for term, definition in entry.items(): + if term.startswith("@"): + continue + target = definition.get("@id") if isinstance(definition, dict) else definition + if not isinstance(target, str): + continue + prior = seen.get(term) + if prior is None: + seen[term] = target + elif prior != target: + problems.append(f"{term!r} maps to both {prior!r} and {target!r} across the reflected @context") + return problems + + # ---------------------------------------------------------------------------- the registry @@ -656,7 +752,7 @@ class CheckInfo: per_version=True, default_status=SKIP, # Decided inline in pipeline.py's `_run_rule_checks`, which substitutes this one finding - # for the whole family rather than calling any of the ten predicates below. + # for the whole family rather than calling any of the predicates below. detects=None, ), CheckInfo( @@ -785,6 +881,20 @@ class CheckInfo: per_version=True, run=_version_not_in_schema_location, ), + CheckInfo( + "rule.context-reflects-refs", + "a single root-level allOf $ref is reflected in @context", + rule="OOLD-CMP-b926", + per_version=True, + run=_root_ref_missing_from_context, + ), + CheckInfo( + "rule.branch-context-conflict", + "reflected oneOf/anyOf branch contexts do not conflict at the root", + rule="OOLD-CMP-1d7e", + per_version=True, + run=_branch_context_conflict, + ), ) @@ -810,7 +920,7 @@ def rule_map() -> dict[str, str]: def catalog_gate(check: CheckInfo, catalog: dict[str, dict[str, Any]] | None) -> RuleFinding | None: """Whether ``check`` must be skipped against ``catalog``, or None to mean "run it". - This is the one place the presence/deprecation gating lives, shared by the ten self-contained + This is the one place the presence/deprecation gating lives, shared by the self-contained ``rule.*`` predicates (via :func:`run_rule_checks`) and the four checks that predate the catalogue, applied directly in ``pipeline.py``. A check with no ``rule`` is never gated: the question only makes sense for a check that names a normative statement. diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md index c3fd4b8..b33bd07 100644 --- a/tests/data/oold/README.md +++ b/tests/data/oold/README.md @@ -68,3 +68,5 @@ Each one exists to prove a specific check fires, rather than only that valid inp | `legacy_dialect` | `rule.dialect-version` - `$schema` names `draft-07`, not a 2020-12-based dialect | | `context_array_order_mismatch` | `rule.context-array-order` - `@context` lists two `allOf`-composed remote contexts out of order | | `versioned_id_missing_version` | `rule.versioned-id` (warns, does not fail) - `x-oold-version` does not appear in an absolute `$id` | +| `root_ref_not_reflected` | `rule.context-reflects-refs` - a single `allOf` `$ref` is not reflected anywhere in `@context` | +| `branch_context_conflict` | `rule.branch-context-conflict` - two `oneOf`-branch contexts map the same keyword to different IRIs at the root | diff --git a/tests/data/oold/broken/Gauge.schema.json b/tests/data/oold/broken/Gauge.schema.json new file mode 100644 index 0000000..2b905c9 --- /dev/null +++ b/tests/data/oold/broken/Gauge.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "Gauge.schema.json", + "@context": { + "schema": "http://schema.org/", + "reading": "schema:pressure" + }, + "title": "Gauge", + "type": "object", + "required": ["kind"], + "properties": { + "kind": { "type": "string", "const": "Gauge" }, + "reading": { "type": "number" } + } +} diff --git a/tests/data/oold/broken/Sensor.schema.json b/tests/data/oold/broken/Sensor.schema.json new file mode 100644 index 0000000..8931cc6 --- /dev/null +++ b/tests/data/oold/broken/Sensor.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "Sensor.schema.json", + "@context": { + "schema": "http://schema.org/", + "reading": "schema:temperature" + }, + "title": "Sensor", + "type": "object", + "required": ["kind"], + "properties": { + "kind": { "type": "string", "const": "Sensor" }, + "reading": { "type": "number" } + } +} diff --git a/tests/data/oold/broken/branch_context_conflict.schema.json b/tests/data/oold/broken/branch_context_conflict.schema.json new file mode 100644 index 0000000..5348c3d --- /dev/null +++ b/tests/data/oold/broken/branch_context_conflict.schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "branch_context_conflict.schema.json", + "title": "BranchContextConflict", + "@context": ["Sensor.schema.json", "Gauge.schema.json"], + "oneOf": [{ "$ref": "Sensor.schema.json" }, { "$ref": "Gauge.schema.json" }], + "type": "object", + "properties": {} +} diff --git a/tests/data/oold/broken/root_ref_not_reflected.schema.json b/tests/data/oold/broken/root_ref_not_reflected.schema.json new file mode 100644 index 0000000..78ed11a --- /dev/null +++ b/tests/data/oold/broken/root_ref_not_reflected.schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "root_ref_not_reflected.schema.json", + "title": "RootRefNotReflected", + "@context": { "ex": "https://example.org/" }, + "allOf": [{ "$ref": "../Thing.schema.json" }], + "type": "object", + "properties": {} +} diff --git a/tests/test_validation/test_check_registry.py b/tests/test_validation/test_check_registry.py index 17b8b72..c640a22 100644 --- a/tests/test_validation/test_check_registry.py +++ b/tests/test_validation/test_check_registry.py @@ -418,6 +418,90 @@ def test_versioned_id_is_not_judged_without_both_a_version_and_an_absolute_id(): assert outcome("rule.versioned-id", {"x-oold-version": "1.0.0", "$id": "Person.schema.json"}) == "ok" +# ------------------------------------------------------------------ OOLD-CMP-b926 + + +def test_a_single_root_ref_must_be_reflected_in_context(): + schema = {"allOf": [{"$ref": "Thing.schema.json"}], "@context": {"ex": "https://example.org/"}} + assert outcome("rule.context-reflects-refs", schema) == "fail" + assert "Thing.schema.json" in message("rule.context-reflects-refs", schema) + + +def test_a_single_root_ref_reflected_as_an_array_entry_is_fine(): + schema = { + "allOf": [{"$ref": "Thing.schema.json"}], + "@context": ["Thing.schema.json", {"ex": "https://example.org/"}], + } + assert outcome("rule.context-reflects-refs", schema) == "ok" + + +def test_a_single_root_ref_reflected_as_a_bare_string_context_is_fine(): + """A single $ref MAY be reflected by referencing it directly, with no array wrapper.""" + schema = {"allOf": [{"$ref": "Thing.schema.json"}], "@context": "Thing.schema.json"} + assert outcome("rule.context-reflects-refs", schema) == "ok" + + +def test_two_or_more_refs_are_left_to_context_array_order(): + """The >= 2 case, including a target missing entirely, is rule.context-array-order's job.""" + schema = {"allOf": [{"$ref": "A.schema.json"}, {"$ref": "B.schema.json"}], "@context": {"ex": "https://x/"}} + assert outcome("rule.context-reflects-refs", schema) == "ok" + + +def test_no_allof_ref_means_nothing_to_reflect(): + assert outcome("rule.context-reflects-refs", {}) == "ok" + assert outcome("rule.context-reflects-refs", {"allOf": [{"type": "object"}]}) == "ok" + + +# ------------------------------------------------------------------ OOLD-CMP-1d7e + + +#: Two branch contexts reflected into the root, as the rule describes them. Authored as strings, +#: because that is what tells a reflected context from the schema's own object. +_REFLECTED = ["Sensor.schema.json", "Gauge.schema.json"] + + +def test_branch_context_conflict_flags_a_root_level_keyword_conflict(): + schema = {"@context": _REFLECTED, "oneOf": [{"$ref": "Sensor.schema.json"}, {"$ref": "Gauge.schema.json"}]} + context = ContextView(entries=[{"reading": "ex:temperature"}, {"reading": "ex:pressure"}]) + assert outcome("rule.branch-context-conflict", schema, context) == "fail" + assert "reading" in message("rule.branch-context-conflict", schema, context) + + +def test_branch_context_conflict_accepts_branches_agreeing_on_a_term(): + schema = {"@context": _REFLECTED, "anyOf": [{"$ref": "Sensor.schema.json"}, {"$ref": "Gauge.schema.json"}]} + context = ContextView(entries=[{"reading": "ex:temperature"}, {"reading": "ex:temperature"}]) + assert outcome("rule.branch-context-conflict", schema, context) == "ok" + + +def test_branch_context_conflict_accepts_the_schema_overriding_an_inherited_term(): + """The specification allows a schema to append its own object to override a term it inherits. + + In the resolved view that is indistinguishable from a conflict - same term, two IRIs, two + entries - so the check reads how each entry was authored. A string is a reflected remote + context; a dict is the schema's own, and may override anything above it. + """ + schema = { + "@context": ["Sensor.schema.json", {"reading": "ex:pressure"}], + "oneOf": [{"$ref": "Sensor.schema.json"}, {"$ref": "Gauge.schema.json"}], + } + context = ContextView(entries=[{"reading": "ex:temperature"}, {"reading": "ex:pressure"}]) + assert outcome("rule.branch-context-conflict", schema, context) == "ok" + + +def test_branch_context_conflict_is_not_judged_without_ref_branches(): + """An inline branch, like the value-form pattern's, has no remote context to conflict.""" + schema = {"anyOf": [{"type": "string"}, {"type": "object"}]} + context = ContextView(entries=[{"reading": "ex:temperature"}, {"reading": "ex:pressure"}]) + assert outcome("rule.branch-context-conflict", schema, context) == "ok" + + +def test_branch_context_conflict_ignores_structural_keywords(): + """@version, @base and the like are JSON-LD machinery, not the "keyword" the rule means.""" + schema = {"oneOf": [{"$ref": "Sensor.schema.json"}, {"$ref": "Gauge.schema.json"}]} + context = ContextView(entries=[{"@version": 1.1}, {"@version": 1.1}]) + assert outcome("rule.branch-context-conflict", schema, context) == "ok" + + # ------------------------------------------------------------------ against the real corpus diff --git a/tests/test_validation/test_pipeline.py b/tests/test_validation/test_pipeline.py index 7117d7f..30e2a32 100644 --- a/tests/test_validation/test_pipeline.py +++ b/tests/test_validation/test_pipeline.py @@ -97,6 +97,12 @@ def test_a_context_chain_leaving_the_directory_resolves(remote_context_dir): # No other fixture combines x-oold-version with an absolute $id, so without this one # rule.versioned-id's comparison is never reached through the pipeline. ("versioned_id_missing_version.schema.json", "rule.versioned-id", WARN), + # Every other fixture with a single allOf $ref reflects it, so without this one + # rule.context-reflects-refs never reaches its failing branch through the pipeline. + ("root_ref_not_reflected.schema.json", "rule.context-reflects-refs", FAIL), + # No other fixture composes oneOf/anyOf branches by $ref, so without this one + # rule.branch-context-conflict's comparison is never reached through the pipeline. + ("branch_context_conflict.schema.json", "rule.branch-context-conflict", FAIL), ], ) def test_each_broken_fixture_fails_the_check_it_targets(broken_dir, fixture, check_id, status): From 13ade196c9ba8d6a9a9e8501e20ac213a688aaa6 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 14 Aug 2026 16:30:58 +0200 Subject: [PATCH 28/29] feat(validation): enforce narrow-only composition - rule.narrow-only enforces OOLD-CMP-f3c7; unenforced count 8 to 7 - CheckInfo gains `run_resolved`, letting a check receive the dereferenced schema instead of the authored one - Ten comparable keywords are checked per member: numeric bounds, length/item/property bounds, multipleOf, enum, const, type, uniqueItems, additionalProperties - `pattern` and `required` are deliberately excluded, with reasons recorded in the code - Zero findings across the corpus; parity holds at 6/6 --- src/oold/validation/check_registry.py | 306 +++++++++++++++++- src/oold/validation/pipeline.py | 13 +- tests/data/oold/README.md | 1 + tests/data/oold/broken/NarrowBase.schema.json | 13 + .../broken/narrow_only_relaxation.schema.json | 11 + tests/test_validation/test_check_registry.py | 150 ++++++++- tests/test_validation/test_pipeline.py | 4 + 7 files changed, 472 insertions(+), 26 deletions(-) create mode 100644 tests/data/oold/broken/NarrowBase.schema.json create mode 100644 tests/data/oold/broken/narrow_only_relaxation.schema.json diff --git a/src/oold/validation/check_registry.py b/src/oold/validation/check_registry.py index 7000780..b35471d 100644 --- a/src/oold/validation/check_registry.py +++ b/src/oold/validation/check_registry.py @@ -4,18 +4,20 @@ in this package produced it, and the rule id (``OOLD-RT-08f2``) names the normative statement it enforces, when there is one. Rule ids come from the specification and are permanent; check ids are implementation-defined and follow this package's structure. A finding cites both, because -fourteen of the thirty-eight checks enforce no rule at all - `schema.meta` is definitional, +fourteen of the thirty-nine checks enforce no rule at all - `schema.meta` is definitional, `generate.satisfiable`, `variants` and the `roundtrip.*` checks are this validator's methodology, `coverage.*` are self-tests about the fixture suite, `meta.self-check` and `rule.checks` report on the run itself rather than on a schema, and `compliance.suite`/`compliance.*` are the deterministic fixture suite's own outcomes - and for those the check id is the only identifier a user has. -This module holds two things that used to live apart. The twenty ``rule.*`` checks each enforce -exactly one normative statement and are narrow enough to be self-contained predicates over an -already-resolved :class:`ContextView`, so they are declared here and executed by -:func:`run_rule_checks`. The other eighteen checks are driven by the phases in ``pipeline.py`` and -leave :attr:`CheckInfo.run` empty; this module only records their metadata; ``detects`` points at -the function that actually decides the verdict, where one function is clearly responsible. +This module holds two things that used to live apart. The twenty-one ``rule.*`` checks each +enforce exactly one normative statement and are narrow enough to be self-contained predicates, +so they are declared here and executed by :func:`run_rule_checks`. Most take an already-resolved +:class:`ContextView` and the schema exactly as authored (:attr:`CheckInfo.run`); a few instead +need the dereferenced schema, to see through an ancestor's `$ref` (:attr:`CheckInfo.run_resolved`). +The other eighteen checks are driven by the phases in ``pipeline.py`` and leave both empty; this +module only records their metadata; ``detects`` points at the function that actually decides the +verdict, where one function is clearly responsible. Every check is written to avoid false positives in preference to catching every violation. A validator that cries wolf on valid schemas gets switched off, and an unenforced rule is already @@ -24,6 +26,7 @@ from __future__ import annotations +import math import re from collections.abc import Callable from dataclasses import dataclass, field @@ -586,6 +589,250 @@ def _branch_context_conflict(schema: dict[str, Any], context: ContextView) -> li return problems +#: `maximum`/`exclusiveMaximum` and the three `max*` size bounds: a wider derived value relaxes +#: what an ancestor declared. `minimum`/`exclusiveMinimum` and the three `min*` bounds are the +#: mirror image, so they share the same comparison with the inequality flipped. +_WIDER_IF_GREATER = ("maximum", "exclusiveMaximum", "maxLength", "maxItems", "maxProperties") +_WIDER_IF_LESSER = ("minimum", "exclusiveMinimum", "minLength", "minItems", "minProperties") + +#: Distinguishes "no node in the chain declares this keyword" from a legitimate `None`/`null`. +_MISSING = object() + + +def _numeric(value: Any) -> bool: + """True for an `int`/`float` that is not also a `bool` (Python's `bool` is an `int`).""" + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _chain_nodes(node: Any, seen: set[int] | None = None) -> Any: + """Preorder walk of a resolved schema and its `allOf`-composed ancestors, root first. + + Same precedence as `collect_composed_properties`: a node's own declaration outranks + anything reached through its `allOf`, and of several `allOf` entries the earlier one + outranks the later. `seen` guards a self-referential chain defensively; `bound_schema` + already cuts cycles before this ever runs, so in practice it never triggers. + """ + if seen is None: + seen = set() + if not isinstance(node, dict) or id(node) in seen: + return + seen.add(id(node)) + yield node + for sub in node.get("allOf") or []: + yield from _chain_nodes(sub, seen) + + +def _first_declared(nodes: list[Any], keyword: str) -> tuple[Any, list[Any]]: + """The most-derived value of `keyword` across `nodes`, and the nodes that follow it. + + `nodes` is ordered most-derived first, mirroring `_chain_nodes`. Under JSON Merge Patch a + member is resolved independently per key, so the most-derived value for one keyword can come + from a different chain position than another keyword on the very same property; this looks + at one keyword at a time rather than at a whole property object. Returns `(_MISSING, [])` + when no node declares the keyword at all. + """ + for index, node in enumerate(nodes): + if isinstance(node, dict) and keyword in node: + return node[keyword], nodes[index + 1 :] + return _MISSING, [] + + +def _bound_relaxations(label: str, nodes: list[Any]) -> list[str]: + """The two monotonic families: a derived bound must not be looser than an inherited one.""" + problems: list[str] = [] + for keyword in _WIDER_IF_GREATER: + derived, ancestors = _first_declared(nodes, keyword) + if derived is _MISSING or not _numeric(derived): + continue + for ancestor in ancestors: + if not isinstance(ancestor, dict) or keyword not in ancestor: + continue + value = ancestor[keyword] + if _numeric(value) and derived > value: + problems.append(f"{label} relaxes {keyword} from {value!r} (inherited) to {derived!r}") + for keyword in _WIDER_IF_LESSER: + derived, ancestors = _first_declared(nodes, keyword) + if derived is _MISSING or not _numeric(derived): + continue + for ancestor in ancestors: + if not isinstance(ancestor, dict) or keyword not in ancestor: + continue + value = ancestor[keyword] + if _numeric(value) and derived < value: + problems.append(f"{label} relaxes {keyword} from {value!r} (inherited) to {derived!r}") + return problems + + +def _multiple_of_relaxations(label: str, nodes: list[Any]) -> list[str]: + """A derived `multipleOf` must itself be a multiple of an inherited one.""" + derived, ancestors = _first_declared(nodes, "multipleOf") + if derived is _MISSING or not _numeric(derived) or derived <= 0: + return [] + problems: list[str] = [] + for ancestor in ancestors: + if not isinstance(ancestor, dict) or "multipleOf" not in ancestor: + continue + value = ancestor["multipleOf"] + if not _numeric(value) or value <= 0: + continue + ratio = derived / value + if not math.isclose(ratio, round(ratio), rel_tol=1e-9, abs_tol=1e-9): + problems.append( + f"{label} sets multipleOf {derived!r}, which is not itself a multiple of the inherited {value!r}" + ) + return problems + + +def _enum_relaxations(label: str, nodes: list[Any]) -> list[str]: + """A derived `enum` must not admit a value the inherited `enum` excluded.""" + derived, ancestors = _first_declared(nodes, "enum") + if derived is _MISSING or not isinstance(derived, list): + return [] + problems: list[str] = [] + for ancestor in ancestors: + if not isinstance(ancestor, dict) or not isinstance(ancestor.get("enum"), list): + continue + missing = [value for value in derived if value not in ancestor["enum"]] + if missing: + problems.append(f"{label} enum admits {missing!r}, absent from the inherited enum {ancestor['enum']!r}") + return problems + + +def _const_relaxations(label: str, nodes: list[Any]) -> list[str]: + """A derived `const` must agree with an inherited `const`, or fall inside an inherited `enum`.""" + derived, ancestors = _first_declared(nodes, "const") + if derived is _MISSING: + return [] + problems: list[str] = [] + for ancestor in ancestors: + if not isinstance(ancestor, dict): + continue + if "const" in ancestor: + if ancestor["const"] != derived: + problems.append(f"{label} const {derived!r} disagrees with the inherited const {ancestor['const']!r}") + elif isinstance(ancestor.get("enum"), list) and derived not in ancestor["enum"]: + problems.append(f"{label} const {derived!r} is absent from the inherited enum {ancestor['enum']!r}") + return problems + + +def _type_set(value: Any) -> set[str] | None: + """`type`, normalised to a set: a single string, or 2020-12's array-of-types form.""" + if isinstance(value, str): + return {value} + if isinstance(value, list) and value and all(isinstance(item, str) for item in value): + return set(value) + return None + + +def _type_relaxations(label: str, nodes: list[Any]) -> list[str]: + """A derived `type` must not admit a JSON type absent from an inherited `type`.""" + derived, ancestors = _first_declared(nodes, "type") + if derived is _MISSING: + return [] + derived_types = _type_set(derived) + if derived_types is None: + return [] + problems: list[str] = [] + for ancestor in ancestors: + if not isinstance(ancestor, dict) or "type" not in ancestor: + continue + ancestor_types = _type_set(ancestor["type"]) + if ancestor_types is None: + continue + stray = sorted(derived_types - ancestor_types) + if stray: + problems.append( + f"{label} admits type(s) {stray!r}, absent from the inherited type {sorted(ancestor_types)!r}" + ) + return problems + + +def _unique_items_relaxations(label: str, nodes: list[Any]) -> list[str]: + """A derived `uniqueItems: false` must not relax an inherited `uniqueItems: true`.""" + derived, ancestors = _first_declared(nodes, "uniqueItems") + if derived is not False: + return [] + for ancestor in ancestors: + if isinstance(ancestor, dict) and ancestor.get("uniqueItems") is True: + return [f"{label} sets uniqueItems: false, relaxing the inherited uniqueItems: true"] + return [] + + +def _additional_properties_relaxations(label: str, nodes: list[Any]) -> list[str]: + """A derived `additionalProperties: true` must not relax an inherited `additionalProperties: false`.""" + derived, ancestors = _first_declared(nodes, "additionalProperties") + if derived is not True: + return [] + for ancestor in ancestors: + if isinstance(ancestor, dict) and ancestor.get("additionalProperties") is False: + return [f"{label} sets additionalProperties: true, relaxing the inherited additionalProperties: false"] + return [] + + +def _relaxations(label: str, nodes: list[Any]) -> list[str]: + """Every narrow-only comparison this check makes, for one member position. + + `label` names that position in a finding (a property, or the schema itself); `nodes` is its + declarations across the chain, most-derived first. + """ + return [ + *_bound_relaxations(label, nodes), + *_multiple_of_relaxations(label, nodes), + *_enum_relaxations(label, nodes), + *_const_relaxations(label, nodes), + *_type_relaxations(label, nodes), + *_unique_items_relaxations(label, nodes), + *_additional_properties_relaxations(label, nodes), + ] + + +def _narrow_only_relaxations(schema: dict[str, Any], context: ContextView) -> list[str]: + """A derived schema's assertion-bearing keywords may only tighten an ancestor's, never relax + them: OOLD-CMP-f3c7. + + Takes the *resolved* schema (see `CheckInfo.run_resolved`): the raw, authored document + composes an ancestor with `allOf: [{"$ref": ...}]`, and the ancestor's own constraints are + not visible without resolving that reference first. After dereferencing, a subclass chain is + inlined as nested `allOf` entries each carrying the ancestor's own `properties` - see + `resolve.dereference`/`resolve.bound_schema` and `collect_composed_properties`'s docstring. + + Comparisons are per keyword rather than per whole property object, because that is how + OO-LD's own merge model (JSON Merge Patch, RFC 7396) resolves the chain: keyed by object + member, so `properties.foo.maximum` and `properties.foo.minimum` are each independently + overridden by the nearest declaration, and can come from different levels of the same chain. + The schema root itself is compared the same way, alongside each property, since a keyword + such as `additionalProperties` sits there rather than under `properties`. `type` is compared + as a set, since 2020-12 allows an array of types. + + Two keywords in the specification's own list are deliberately left out: + + - `pattern` - whether one regular expression is narrower than another is not decidable in + general, so any comparison here would be a guess, not a finding. + - `required` - an object-level keyword rather than an assertion on a single value. Under the + merge model a derived object can legitimately drop an inherited `required` entry (that key + simply stops being required), and the rule's own wording, about restricting a + "constraint", does not clearly cover this case either way. Left unchecked rather than + guessed. + + Only compares a keyword when both sides declare it with a comparable type - a `maximum` that + is a string on either side, for instance, is silently skipped rather than compared. + """ + nodes = list(_chain_nodes(schema)) + if len(nodes) < 2: + return [] # no ancestor at all: nothing could have been relaxed + + problems = _relaxations("the schema itself", nodes) + + names: dict[str, None] = {} + for node in nodes: + for name in node.get("properties") or {}: + names.setdefault(name, None) + for name in names: + property_nodes = [(node.get("properties") or {}).get(name) for node in nodes] + problems.extend(_relaxations(f"property {name!r}", property_nodes)) + return problems + + # ---------------------------------------------------------------------------- the registry @@ -609,6 +856,13 @@ class CheckInfo: because a rule minted after the catalogue cannot be attributed to a version that predates it. ``True`` runs it anyway, for the four checks whose requirement is older than the catalogue itself and would otherwise silently stop being enforced on those versions. + + ``run`` and ``run_resolved`` are mutually exclusive ways to be a self-contained rule check; + at most one is set. ``run`` receives the schema exactly as authored, which is what a check + reading its own literal ``@context``/``allOf`` needs (see ``rule.context-array-order`` and + friends). ``run_resolved`` instead receives the dereferenced, bounded schema - the same one + the pipeline already builds for generation and round-tripping - for a check that needs to see + through an ancestor's ``$ref`` rather than just its own authored document. """ id: str @@ -618,6 +872,7 @@ class CheckInfo: per_version: bool = False detects: Callable[..., Any] | None = None run: Predicate | None = None + run_resolved: Predicate | None = None predates_catalog: bool = False @@ -895,6 +1150,13 @@ class CheckInfo: per_version=True, run=_branch_context_conflict, ), + CheckInfo( + "rule.narrow-only", + "a derived schema's assertion-bearing keywords only tighten what an allOf ancestor declared", + rule="OOLD-CMP-f3c7", + per_version=True, + run_resolved=_narrow_only_relaxations, + ), ) @@ -965,6 +1227,7 @@ def run_rule_checks( schema: dict[str, Any], context: ContextView, catalog: dict[str, dict[str, Any]] | None = None, + resolved: dict[str, Any] | None = None, ) -> list[RuleFinding]: """Apply the rule checks that the selected specification version actually states. @@ -973,19 +1236,36 @@ def run_rule_checks( and enforcing it would report a violation of something the target does not require. A deprecated rule is skipped for the same reason from the other end. See :func:`catalog_gate`. - When ``catalog`` is None the version ships no catalogue at all, and every one of these ten - checks skips: none of them predates the catalogue (:attr:`CheckInfo.predates_catalog` is - False for all of them), so there is nothing pre-catalogue evidence could attribute the rule - to. + When ``catalog`` is None the version ships no catalogue at all, and every one of these checks + skips: none of them predates the catalogue (:attr:`CheckInfo.predates_catalog` is False for + all of them), so there is nothing pre-catalogue evidence could attribute the rule to. + + ``resolved`` is the dereferenced, bounded schema, for the checks declared with + :attr:`CheckInfo.run_resolved` rather than :attr:`CheckInfo.run`. When it is not available - + the default, for callers with nothing to offer - a ``run_resolved`` check is skipped rather + than guessing from the raw document or crashing on a missing argument. """ findings: list[RuleFinding] = [] - for check in (c for c in CHECKS if c.run): + for check in (c for c in CHECKS if c.run or c.run_resolved): gate = catalog_gate(check, catalog) if gate is not None: findings.append(gate) continue rule = (catalog or {}).get(check.rule) - problems = check.run(schema, context) + if check.run_resolved is not None: + if resolved is None: + findings.append( + RuleFinding( + check.id, + check.rule, + SKIP, + f"the dereferenced schema is not available in this context, so {check.rule} cannot be judged", + ) + ) + continue + problems = check.run_resolved(resolved, context) + else: + problems = check.run(schema, context) if not problems: findings.append(RuleFinding(check.id, check.rule, OK)) else: diff --git a/src/oold/validation/pipeline.py b/src/oold/validation/pipeline.py index 892f28e..bbcdc87 100644 --- a/src/oold/validation/pipeline.py +++ b/src/oold/validation/pipeline.py @@ -109,7 +109,7 @@ def catalog_gate(self, check_id: str, bundle: MetaBundle) -> RuleFinding | None: """The skip verdict for `check_id` against one meta-schema version, or None to run it. Delegates to `check_registry.catalog_gate`, the single place the presence/deprecation - gating lives - `run_rule_checks` applies the same function to the ten self-contained + gating lives - `run_rule_checks` applies the same function to the self-contained `rule.*` checks. This is what lets `lint.pattern`, `lint.container`, `lint.iri-format` and `context.predicates` (the four checks older than the catalogue, `CheckInfo.predates_catalog`) keep running against a version that ships no catalogue at @@ -397,7 +397,7 @@ def _check_predicates(run: _Run, name: str, raw, schema, sample) -> None: run.add("context.predicates", name, SKIP, "schema declares no @context") return - _run_rule_checks(run, name, raw, ContextView(terms=context.terms(), entries=list(context.context))) + _run_rule_checks(run, name, raw, schema, ContextView(terms=context.terms(), entries=list(context.context))) gate = run.catalog_gate("context.predicates", run.bundles[0]) if gate is not None: @@ -439,7 +439,7 @@ def _check_predicates(run: _Run, name: str, raw, schema, sample) -> None: # ---------------------------------------------------------------------------- instances -def _run_rule_checks(run: _Run, name: str, raw: dict[str, Any], context: ContextView) -> None: +def _run_rule_checks(run: _Run, name: str, raw: dict[str, Any], resolved: dict[str, Any], context: ContextView) -> None: """Report the narrow, single-rule checks for one schema, per meta-schema version. These are the only checks whose *applicability* depends on the version: each enforces one @@ -451,6 +451,11 @@ def _run_rule_checks(run: _Run, name: str, raw: dict[str, Any], context: Context assert requirements that version may never have stated, which is the same false-positive class as judging a schema on its literal rather than resolved `@context`. + ``resolved`` is the dereferenced, bounded schema this run already built for generation and + round-tripping (see `_Run.bounded`); it is passed on to `run_rule_checks` for the handful of + checks declared with `CheckInfo.run_resolved`, which need to see through an ancestor's `$ref` + rather than just this schema's own authored document. + A passing check is still recorded, so `--verbose` shows what was verified and the counts line up with what `oold rules list` claims is enforced. """ @@ -466,7 +471,7 @@ def _run_rule_checks(run: _Run, name: str, raw: dict[str, Any], context: Context ) continue catalog = {r["id"]: r for r in bundle.rules} - for finding in run_rule_checks(raw, context, catalog): + for finding in run_rule_checks(raw, context, catalog, resolved=resolved): run.add( finding.check_id, name, diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md index b33bd07..fc4e93f 100644 --- a/tests/data/oold/README.md +++ b/tests/data/oold/README.md @@ -70,3 +70,4 @@ Each one exists to prove a specific check fires, rather than only that valid inp | `versioned_id_missing_version` | `rule.versioned-id` (warns, does not fail) - `x-oold-version` does not appear in an absolute `$id` | | `root_ref_not_reflected` | `rule.context-reflects-refs` - a single `allOf` `$ref` is not reflected anywhere in `@context` | | `branch_context_conflict` | `rule.branch-context-conflict` - two `oneOf`-branch contexts map the same keyword to different IRIs at the root | +| `narrow_only_relaxation` | `rule.narrow-only` - an `allOf` ancestor's `maximum` is relaxed rather than tightened (`NarrowBase.schema.json` is its sibling ancestor) | diff --git a/tests/data/oold/broken/NarrowBase.schema.json b/tests/data/oold/broken/NarrowBase.schema.json new file mode 100644 index 0000000..9c2a529 --- /dev/null +++ b/tests/data/oold/broken/NarrowBase.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "NarrowBase.schema.json", + "title": "NarrowBase", + "@context": { + "schema": "http://schema.org/", + "reading": "schema:value" + }, + "type": "object", + "properties": { + "reading": { "type": "number", "minimum": 0, "maximum": 100 } + } +} diff --git a/tests/data/oold/broken/narrow_only_relaxation.schema.json b/tests/data/oold/broken/narrow_only_relaxation.schema.json new file mode 100644 index 0000000..63031d2 --- /dev/null +++ b/tests/data/oold/broken/narrow_only_relaxation.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "narrow_only_relaxation.schema.json", + "title": "NarrowOnlyRelaxation", + "@context": ["NarrowBase.schema.json"], + "allOf": [{ "$ref": "NarrowBase.schema.json" }], + "type": "object", + "properties": { + "reading": { "type": "number", "minimum": 0, "maximum": 1000 } + } +} diff --git a/tests/test_validation/test_check_registry.py b/tests/test_validation/test_check_registry.py index c640a22..52f1275 100644 --- a/tests/test_validation/test_check_registry.py +++ b/tests/test_validation/test_check_registry.py @@ -17,21 +17,23 @@ #: instead of silently disagreeing with the specification. CATALOG = {r["id"]: r for r in load_tracked(latest_version()).rules} -#: The ten self-contained rule checks, in the order they are declared - the same slice -#: `run_rule_checks` executes. -SELF_CONTAINED_CHECKS = tuple(c for c in CHECKS if c.run) +#: The twenty-one self-contained rule checks, in the order they are declared - the same slice +#: `run_rule_checks` executes. Most run against the schema exactly as authored (`CheckInfo.run`); +#: `rule.narrow-only` instead needs the dereferenced form (`CheckInfo.run_resolved`), supplied to +#: these helpers as `resolved` rather than as `schema`. +SELF_CONTAINED_CHECKS = tuple(c for c in CHECKS if c.run or c.run_resolved) -def _findings(schema: dict, context: ContextView | None = None): - return {f.check_id: f for f in run_rule_checks(schema, context or ContextView(), CATALOG)} +def _findings(schema: dict, context: ContextView | None = None, resolved: dict | None = None): + return {f.check_id: f for f in run_rule_checks(schema, context or ContextView(), CATALOG, resolved=resolved)} -def outcome(check_id: str, schema: dict, context: ContextView | None = None) -> str: - return _findings(schema, context)[check_id].status +def outcome(check_id: str, schema: dict, context: ContextView | None = None, resolved: dict | None = None) -> str: + return _findings(schema, context, resolved)[check_id].status -def message(check_id: str, schema: dict, context: ContextView | None = None) -> str: - return _findings(schema, context)[check_id].message +def message(check_id: str, schema: dict, context: ContextView | None = None, resolved: dict | None = None) -> str: + return _findings(schema, context, resolved)[check_id].message # ------------------------------------------------------------------ registry @@ -502,6 +504,136 @@ def test_branch_context_conflict_ignores_structural_keywords(): assert outcome("rule.branch-context-conflict", schema, context) == "ok" +# ------------------------------------------------------------------ OOLD-CMP-f3c7 + + +def test_narrow_only_skips_cleanly_without_a_resolved_schema(): + """`rule.narrow-only` needs `resolved`; with none supplied it must skip, not silently pass.""" + schema = {"properties": {"reading": {"maximum": 1000}}} + assert outcome("rule.narrow-only", schema) == "skip" + + +def test_narrow_only_ignores_the_raw_document(): + """This check is declared with `run_resolved`; `schema` itself is never consulted.""" + raw = {"properties": {"reading": {"maximum": 1000}}} + resolved = {"type": "object", "properties": {"reading": {"maximum": 50}}} + assert outcome("rule.narrow-only", raw, resolved=resolved) == "ok" + + +def test_narrow_only_accepts_a_tightened_bound(): + resolved = { + "type": "object", + "properties": {"reading": {"type": "number", "maximum": 50}}, + "allOf": [{"type": "object", "properties": {"reading": {"type": "number", "maximum": 100}}}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "ok" + + +def test_narrow_only_flags_a_relaxed_bound(): + resolved = { + "type": "object", + "properties": {"reading": {"type": "number", "maximum": 1000}}, + "allOf": [{"type": "object", "properties": {"reading": {"type": "number", "maximum": 100}}}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "fail" + assert "maximum" in message("rule.narrow-only", {}, resolved=resolved) + + +def test_narrow_only_is_not_judged_without_an_ancestor(): + """A schema with nothing in its `allOf` chain has nothing it could have relaxed.""" + resolved = {"type": "object", "properties": {"reading": {"maximum": 100}}} + assert outcome("rule.narrow-only", {}, resolved=resolved) == "ok" + + +def test_narrow_only_flags_a_multiple_of_that_is_not_itself_a_multiple(): + resolved = { + "type": "object", + "properties": {"amount": {"multipleOf": 6}}, + "allOf": [{"type": "object", "properties": {"amount": {"multipleOf": 4}}}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "fail" + assert "multipleOf" in message("rule.narrow-only", {}, resolved=resolved) + + +def test_narrow_only_accepts_a_multiple_of_the_inherited_multiple(): + resolved = { + "type": "object", + "properties": {"amount": {"multipleOf": 8}}, + "allOf": [{"type": "object", "properties": {"amount": {"multipleOf": 4}}}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "ok" + + +def test_narrow_only_flags_an_enum_admitting_a_value_outside_the_inherited_enum(): + resolved = { + "type": "object", + "properties": {"status": {"enum": ["open", "closed", "archived"]}}, + "allOf": [{"type": "object", "properties": {"status": {"enum": ["open", "closed"]}}}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "fail" + assert "archived" in message("rule.narrow-only", {}, resolved=resolved) + + +def test_narrow_only_accepts_an_enum_subset(): + resolved = { + "type": "object", + "properties": {"status": {"enum": ["open"]}}, + "allOf": [{"type": "object", "properties": {"status": {"enum": ["open", "closed"]}}}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "ok" + + +def test_narrow_only_flags_a_const_absent_from_the_inherited_enum(): + resolved = { + "type": "object", + "properties": {"status": {"const": "archived"}}, + "allOf": [{"type": "object", "properties": {"status": {"enum": ["open", "closed"]}}}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "fail" + assert "const" in message("rule.narrow-only", {}, resolved=resolved) + + +def test_narrow_only_flags_a_type_admitting_a_type_outside_the_inherited_type(): + resolved = { + "type": "object", + "properties": {"value": {"type": ["string", "number"]}}, + "allOf": [{"type": "object", "properties": {"value": {"type": "string"}}}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "fail" + assert "number" in message("rule.narrow-only", {}, resolved=resolved) + + +def test_narrow_only_accepts_a_type_subset(): + resolved = { + "type": "object", + "properties": {"value": {"type": "string"}}, + "allOf": [{"type": "object", "properties": {"value": {"type": ["string", "number"]}}}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "ok" + + +def test_narrow_only_flags_additional_properties_relaxed_at_the_schema_root(): + """`additionalProperties` sits on the schema itself, not under `properties`.""" + resolved = { + "type": "object", + "additionalProperties": True, + "allOf": [{"type": "object", "additionalProperties": False}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "fail" + assert "additionalProperties" in message("rule.narrow-only", {}, resolved=resolved) + + +def test_narrow_only_ignores_pattern_and_required(): + """`pattern` and `required` are deliberately excluded; see `_narrow_only_relaxations`.""" + resolved = { + "type": "object", + "required": [], + "properties": {"code": {"pattern": ".*"}}, + "allOf": [{"type": "object", "required": ["code"], "properties": {"code": {"pattern": "^[A-Z]+$"}}}], + } + assert outcome("rule.narrow-only", {}, resolved=resolved) == "ok" + + # ------------------------------------------------------------------ against the real corpus diff --git a/tests/test_validation/test_pipeline.py b/tests/test_validation/test_pipeline.py index 30e2a32..54dddc1 100644 --- a/tests/test_validation/test_pipeline.py +++ b/tests/test_validation/test_pipeline.py @@ -103,6 +103,10 @@ def test_a_context_chain_leaving_the_directory_resolves(remote_context_dir): # No other fixture composes oneOf/anyOf branches by $ref, so without this one # rule.branch-context-conflict's comparison is never reached through the pipeline. ("branch_context_conflict.schema.json", "rule.branch-context-conflict", FAIL), + # No fixture in the main corpus reuses a property name across an allOf ancestor chain + # (Researcher/Person/Thing never repeat one), so without this one rule.narrow-only's + # per-keyword comparison is never reached through the pipeline. + ("narrow_only_relaxation.schema.json", "rule.narrow-only", FAIL), ], ) def test_each_broken_fixture_fails_the_check_it_targets(broken_dir, fixture, check_id, status): From 5a6b6d77f9a98122328066ebb0abffcde219211b Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Sat, 15 Aug 2026 14:15:11 +0200 Subject: [PATCH 29/29] feat(validation): track the v1.0.0-rc.2 release - Vendor 1.0.0-rc.2 as its own entry; rc.1 keeps its pre-release copy - Declare its own four-file set: the dialect split into wrapper plus base - Close rc.1's rules_source.merged; refresh fixture slice and fixtures.tag - Unchecked rules 7 -> 15 of 40: 4 carried over, 11 newly released - Fix a UI cross-reference test that asserted on an undefined keyword --- .../1.0.0-rc.2/oold-meta-schema-base.json | 236 ++++ .../meta/1.0.0-rc.2/oold-meta-schema.json | 27 + .../1.0.0-rc.2/oold-pattern-lint.schema.json | 52 + .../meta/1.0.0-rc.2/oold-rules.json | 1011 +++++++++++++++++ .../meta/1.0.0-rc.2/oold-rules.schema.json | 170 +++ .../meta/1.0.0-rc.2/oold-ui-meta-schema.json | 93 ++ src/oold/validation/meta/README.md | 49 +- src/oold/validation/meta/index.json | 31 +- tests/data/oold/OwlOrganization.schema.json | 2 +- tests/data/oold/README.md | 10 +- tests/data/oold/RdfPerson.schema.json | 2 +- tests/data/oold/UiAnnotations.schema.json | 2 +- .../data/oold/compliance/jsonld-features.json | 26 +- tests/data/oold/compliance/oold-vocab.json | 358 +++++- .../oold/compliance/roundtrip-patterns.json | 362 +++++- .../test_check_registry_drift.py | 6 +- tests/test_validation/test_checks.py | 17 +- tests/test_validation/test_meta_store.py | 41 +- tests/test_validation/test_pipeline.py | 6 +- tests/test_validation/test_rules.py | 13 +- 20 files changed, 2357 insertions(+), 157 deletions(-) create mode 100644 src/oold/validation/meta/1.0.0-rc.2/oold-meta-schema-base.json create mode 100644 src/oold/validation/meta/1.0.0-rc.2/oold-meta-schema.json create mode 100644 src/oold/validation/meta/1.0.0-rc.2/oold-pattern-lint.schema.json create mode 100644 src/oold/validation/meta/1.0.0-rc.2/oold-rules.json create mode 100644 src/oold/validation/meta/1.0.0-rc.2/oold-rules.schema.json create mode 100644 src/oold/validation/meta/1.0.0-rc.2/oold-ui-meta-schema.json diff --git a/src/oold/validation/meta/1.0.0-rc.2/oold-meta-schema-base.json b/src/oold/validation/meta/1.0.0-rc.2/oold-meta-schema-base.json new file mode 100644 index 0000000..ea9d41b --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.2/oold-meta-schema-base.json @@ -0,0 +1,236 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-meta-schema-base.json", + "$dynamicAnchor": "meta", + "title": "OO-LD dialect meta-schema (body)", + "$comment": "The dialect body: the standard 2020-12 vocabularies plus the x-oold-* keyword syntax. It carries $dynamicAnchor: \"meta\", so nested subschemas (properties, $defs, x-oold-range, ...) recurse into THIS resource and are validated without the document-level obligations. oold-meta-schema.json wraps it and adds those root-only requirements; see that file.", + "allOf": [ + { + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + { + "$ref": "https://oo-ld.org/latest/meta/oold-ui-meta-schema.json#keywords" + } + ], + "properties": { + "@context": { + "description": "JSON-LD context for instances of this schema. The schema is consumed as a remote JSON-LD context; this entry is ignored by JSON-Schema validators. Only the outer shape is checked here - a context definition is a map, an IRI reference to a remote context, an array combining either, or null (JSON-LD 1.1, Context Definitions). Whether the term definitions inside are well-formed is decided by a JSON-LD processor, not by JSON Schema.", + "anyOf": [ + { + "type": "object" + }, + { + "type": "string", + "format": "iri-reference" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "string", + "format": "iri-reference" + }, + { + "type": "null" + } + ] + } + }, + { + "type": "null" + } + ] + }, + "x-oold-context": { + "description": "Extended term mappings (synonyms): an object keyed by term (a property, class, or value term), each holding a dict keyed by synonym IRI whose value is a JSON-LD term-definition fragment plus an optional strippable x-oold-sssom block. OO-LD tooling reads only two x-oold-sssom slots - predicate_id (a SKOS mapping predicate) and mapping_set_id (for profile-based selection); all other slots ride along and round-trip to SSSOM. Supports override under composition (most-derived-wins; null removes) and namespace/mapping-set selection. Promoted into @context by OO-LD-aware tooling; see the 'Term mappings and synonyms' section.", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": [ + "object", + "null" + ], + "description": "A JSON-LD term-definition fragment (@id, @type, @container, ...), promotable verbatim into @context, plus an optional x-oold-sssom block. null removes an inherited mapping under composition.", + "properties": { + "x-oold-sssom": { + "type": "object", + "description": "SSSOM mapping metadata (https://w3id.org/sssom/). OO-LD interprets predicate_id and mapping_set_id; all other SSSOM slots are preserved verbatim and round-trip to a SSSOM mapping set.", + "properties": { + "predicate_id": { + "description": "SKOS mapping predicate from the term's primary IRI (subject) to this synonym IRI (object); default skos:exactMatch when absent. Compared by expansion to an absolute IRI. Only exactMatch entries are co-emitted by default.", + "type": "string", + "default": "skos:exactMatch", + "examples": [ + "skos:exactMatch", + "skos:closeMatch", + "skos:broadMatch", + "skos:narrowMatch", + "skos:relatedMatch" + ] + }, + "mapping_set_id": { + "description": "The SSSOM mapping set(s) this entry belongs to, for profile-based selection. SSSOM defines mapping_set_id at set level; OO-LD records it inline per entry and an entry MAY belong to several sets.", + "oneOf": [ + { + "type": "string", + "format": "iri-reference" + }, + { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + } + } + ] + } + } + } + } + } + }, + "examples": [ + { + "name": { + "skos:prefLabel": { + "x-oold-sssom": { + "predicate_id": "skos:exactMatch", + "confidence": 0.95 + } + } + } + } + ] + }, + "x-oold-sssom": { + "description": "Schema-level ontology correspondences: an SSSOM mapping set whose subject is this schema, keyed by the object IRI of a resolvable resource, each value carrying SSSOM slots (predicate_id default skos:exactMatch, mapping_set_id, ...). The schema-level counterpart of the per-term x-oold-sssom used inside x-oold-context; it describes the schema itself, not its instances. See the 'Ontology correspondence' section.", + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "predicate_id": { + "type": "string", + "default": "skos:exactMatch", + "examples": [ + "skos:exactMatch", + "skos:closeMatch" + ] + }, + "mapping_set_id": { + "oneOf": [ + { + "type": "string", + "format": "iri-reference" + }, + { + "type": "array", + "items": { + "type": "string", + "format": "iri-reference" + } + } + ] + } + } + }, + "examples": [ + { + "https://schema.org/Person": { + "predicate_id": "skos:exactMatch" + } + } + ] + }, + "x-oold-uuid": { + "description": "Stable UUID identifying this schema across versions and locations.", + "type": "string", + "format": "uuid" + }, + "x-oold-version": { + "description": "Semantic version of this schema.", + "type": "string" + }, + "x-oold-prior-version": { + "description": "Identifier or version of the immediately preceding schema version.", + "type": "string" + }, + "x-oold-backward-compatible-with": { + "description": "URI of a prior schema version this schema is backward-compatible with.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-incompatible-with": { + "description": "URI of a prior schema version this schema is NOT compatible with.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-instance-rdf-type": { + "description": "The rdf:type(s) carried by instances of this schema, as a list of IRIs (e.g. [\"schema:Person\"]). OO-LD tooling materializes these as @type when exporting an instance to JSON-LD / RDF.", + "type": "array", + "items": { + "type": "string" + } + }, + "x-oold-ref": { + "description": "Reference to another OO-LD schema. Use x-oold-ref (not the standard $ref) for references that appear inside OO-LD custom keywords such as x-oold-range: there a plain $ref would be eagerly - and, for cyclic schema graphs, dangerously - dereferenced by generic JSON-Schema bundlers (the behaviour is undefined per Core section 9.4.2). Keep using the standard $ref for ordinary schema composition (allOf, properties, $defs), which bundlers are expected to resolve. x-oold-ref is resolved only by OO-LD-aware tools, lazily and with cycle handling.", + "type": "string", + "format": "uri-reference" + }, + "x-oold-range": { + "description": "Type constraint on the target of an IRI-valued property: an IRI string, an array of IRIs, or an OO-LD subschema (using x-oold-ref for references). See the 'Range of properties' section.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "$comment": "OO-LD subschema form; references inside it use x-oold-ref. The reverse-property keywords (x-oold-reverse-*) are intentionally not validated within a range subschema for now." + } + ] + }, + "x-oold-multilang-title": { + "description": "Language map of translated `title` values keyed by BCP-47 language code.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "x-oold-multilang-description": { + "description": "Language map of translated `description` values keyed by BCP-47 language code.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "x-oold-reverse-properties": { + "description": "Properties stored on the related object but editable from this side, mapped via JSON-LD @reverse.", + "type": "object" + }, + "x-oold-reverse-required": { + "description": "Names of reverse properties that are required.", + "type": "array", + "items": { + "type": "string" + } + }, + "x-oold-reverse-default-properties": { + "description": "Deprecated. Names of reverse properties shown by default in generated user interfaces. Like the object-level defaultProperties array this is extend-only under composition; prefer a per-reverse-property x-oold-ui-default-property boolean, which is overridable.", + "deprecated": true, + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/src/oold/validation/meta/1.0.0-rc.2/oold-meta-schema.json b/src/oold/validation/meta/1.0.0-rc.2/oold-meta-schema.json new file mode 100644 index 0000000..0776608 --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.2/oold-meta-schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "title": "OO-LD dialect meta-schema", + "$comment": "The $id uses the versioned hosting at oo-ld.org/ (the source keeps the /latest/ placeholder; each released copy is stamped per release). The OO-LD vocabulary is declared optional (false) so that generic JSON-Schema 2020-12 validators still process OO-LD schemas. Two-tier structure: this resource is what a schema's $schema points at, so it carries the DOCUMENT-level obligations (required: $id). The dialect body lives in oold-meta-schema-base.json, which holds $dynamicAnchor: \"meta\"; nested subschemas recurse into the base via the standard $dynamicRef and are therefore NOT required to carry $id - a fragment inside properties or $defs legitimately has none. The UI keyword definitions are included via the oold-ui-meta-schema #keywords anchor so a schema carrying x-oold-ui-* annotations validates in one pass. The @context below is the OO-LD meta-level prefix set against which x-oold-context / x-oold-sssom CURIEs (synonym keys, predicate_id, mapping_set_id) are expanded by OO-LD processors; it is not an instance context.", + "@context": { + "skos": "http://www.w3.org/2004/02/skos/core#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "owl": "http://www.w3.org/2002/07/owl#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "sssom": "https://w3id.org/sssom/" + }, + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://oo-ld.org/latest/vocab/oold": false + }, + "$ref": "https://oo-ld.org/latest/meta/oold-meta-schema-base.json", + "required": [ + "$id" + ] +} diff --git a/src/oold/validation/meta/1.0.0-rc.2/oold-pattern-lint.schema.json b/src/oold/validation/meta/1.0.0-rc.2/oold-pattern-lint.schema.json new file mode 100644 index 0000000..2291694 --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.2/oold-pattern-lint.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-pattern-lint.schema.json", + "title": "OO-LD round-trip pattern lint", + "description": "SHOULD-level constraints on a schema's @context that keep instances round-trip-safe, checkable by JSON Schema alone. This is distinct from oold-meta-schema.json, which asserts MUST-level well-formedness. It currently enforces that no term coerces a literal to a datatype JSON-LD produces by default from a native JSON value (xsd:string, xsd:boolean, xsd:integer, xsd:double): xsd:string is RDF's default datatype and is elided from plain literals, and the round-trip contract reconstructs boolean/integer/double literals as native JSON values (fromRDF with native types), so in both cases the value carries no @type and a term declaring one is never selected when the value is compacted back from RDF - the property returns under its full IRI and the round-trip is lossy (see the specification, Property value forms). JSON-LD derives the correct RDF datatype from the native JSON type, so these coercions are also redundant. Datatypes JSON-LD does not produce by default (xsd:date, xsd:dateTime, xsd:float, ... - the value carried as a JSON string) keep their @type through the round-trip and coerce fine. CURIEs are matched in their conventional xsd: form and as the full XSD IRI; a term that coerces through a non-standard prefix mapping is beyond what a single JSON Schema can resolve and is left to tooling.", + "type": "object", + "properties": { + "@context": { "$ref": "#/$defs/context" } + }, + "$defs": { + "context": { + "oneOf": [ + { "type": "null" }, + { "type": "string" }, + { "type": "array", "items": { "$ref": "#/$defs/context" } }, + { "$ref": "#/$defs/contextObject" } + ] + }, + "contextObject": { + "type": "object", + "patternProperties": { + "^@": true, + "^[^@]": { "$ref": "#/$defs/termValue" } + }, + "additionalProperties": { "$ref": "#/$defs/termValue" } + }, + "termValue": { + "oneOf": [ + { "type": "null" }, + { "type": "string" }, + { "$ref": "#/$defs/termDefinition" } + ] + }, + "termDefinition": { + "type": "object", + "properties": { + "@type": { "$ref": "#/$defs/notNativeJsonDatatype" }, + "@context": { "$ref": "#/$defs/context" } + } + }, + "notNativeJsonDatatype": { + "not": { + "enum": [ + "xsd:string", "http://www.w3.org/2001/XMLSchema#string", + "xsd:boolean", "http://www.w3.org/2001/XMLSchema#boolean", + "xsd:integer", "http://www.w3.org/2001/XMLSchema#integer", + "xsd:double", "http://www.w3.org/2001/XMLSchema#double" + ] + } + } + } +} diff --git a/src/oold/validation/meta/1.0.0-rc.2/oold-rules.json b/src/oold/validation/meta/1.0.0-rc.2/oold-rules.json new file mode 100644 index 0000000..a9a8f5c --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.2/oold-rules.json @@ -0,0 +1,1011 @@ +{ + "$schema": "https://oo-ld.org/latest/meta/oold-rules.schema.json", + "$comment": "Catalog of the normative statements in the OO-LD specification, generated from the :rule[...] markers in spec/sections/*.md by scripts/extract_rules.py. Do not edit by hand. Ids are immutable and never reused; see meta/RULES.md.", + "spec_version": "1.0.0-rc.2", + "areas": { + "CNF": "Serialization and conformance", + "SCH": "Schema well-formedness and the meta-schema", + "CMP": "Composition, merge and override", + "INS": "Instances: $schema, identity, semantic type, value forms", + "RT": "Projection to RDF and round-trip safety", + "VER": "Identification and versioning", + "EXT": "Standard extensions (JSON-LD and JSON Schema)" + }, + "applies_to": { + "document": "Checkable by validating a schema or instance document", + "implementation": "Constrains an OO-LD implementation; needs a library conformance suite", + "advisory": "Guidance; nothing verifies it automatically" + }, + "rules": [ + { + "id": "OOLD-CMP-1d7e", + "area": "CMP", + "level": "MUST NOT", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "Reflected oneOf/anyOf branch contexts must not map the same keyword to different IRIs at the root.", + "text": "The remote contexts of `oneOf` / `anyOf` branches MAY also be reflected into the `@context`, but they MUST NOT conflict at the root - they MUST NOT map the same keyword to different IRIs there.", + "text_sha256": "2bb32571967d138ce1b962cd0951dbf255af2de7c7bf2a2dbfd42718017dc18e", + "context": "`oneOf` / `anyOf`. The remote contexts of `oneOf` / `anyOf` branches MAY also be reflected into the `@context`, but they MUST NOT conflict at the root - they MUST NOT map the same keyword to different IRIs there. A JSON-LD processor merges all listed contexts (most-recently-wins) and has no notion of which branch a given instance matched, so a root-level conflict would be decided by context order rather than by the branch the data conforms to.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:49" + }, + { + "id": "OOLD-CMP-5266", + "area": "CMP", + "level": "SHOULD", + "applies_to": "document", + "section": "composition", + "summary": "An embedded object property should be reflected as that property's scoped JSON-LD context.", + "text": "An embedded object reached under an object-valued property - one whose value is an object, directly (`type: object`) or as the `items` of an array (`type: array`), whether inlined or brought in by `$ref` - SHOULD be reflected as that property's scoped JSON-LD context, so its terms resolve only under that property and cannot conflict with a same-named term elsewhere.", + "text_sha256": "62cca7366f12688f2708768b351e60662cb4704c3039f2c7f9243446ec9d21a6", + "context": "It MUST NOT be required to further process an OO-LD schema document in order to interpret it as a JSON-LD context. This implies that all occurrences of `$ref` in the schema are reflected in the JSON-LD context. An embedded object reached under an object-valued property - one whose value is an object, directly (`type: object`) or as the `items` of an array (`type: array`), whether inlined or brought in by `$ref` - SHOULD be reflected as that property's scoped JSON-LD context, so its terms resolve only under that property and cannot conflict with a same-named term elsewhere. That scoped context MAY reference the embedded schema remotely (by URL) or carry its terms inline. Where the embed graph is cyclic - a value type whose scoped context transitively references itself through remote schema files - JSON-LD processors cannot resolve the recursive remote contexts (see [](#round-trip)); breaking the cycle requires migrating the remote reference to a local (inline) context - inlining the term definitions so there is no remote hop to recurse - which MAY be flattened onto the root context as a shared vocabulary. Moving the remote reference to the root does not break the cycle; only replacing it with local definitions does. A `$ref` at the root level of the OO-LD schema is listed at the root of the JSON-LD context. (A scalar reference - a property whose value is an IRI string, not an embedded object - carries its target type in [`x-oold-range`](#range-of-properties), not a `$ref`, and so contributes no scoped context.) In case of multiple `$ref` within `allOf` the corresponding remote contexts are merged into an array-valued `@context` (see [](#merging-remote-contexts)). For `oneOf` / `anyOf` this requires care to avoid conflicts. At any time the importing OO-LD schema MAY define its own or override the imported JSON-LD context.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:5" + }, + { + "id": "OOLD-CMP-53bf", + "area": "CMP", + "level": "SHOULD", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "A schema's JSON Schema and JSON-LD base URIs should be aligned so a relative reference resolves the same under both.", + "text": "Relative references resolve against the schema's `$id` (the JSON Schema base URI) and, on the JSON-LD side, against `@base` / the retrieval URL; these base URIs SHOULD be aligned so a relative reference resolves to the same absolute URL under both.", + "text_sha256": "47730120a068a22fa8d75d91b9797166ad500f332b8b84e06f82476d15296511", + "context": "Independent references and base URIs. A JSON Schema `$ref` and a JSON-LD `@context` entry are independent references: they MAY point to the same document (the typical OO-LD case, where one document is both a schema and a context) or to different documents - for example a plain JSON Schema referenced via `$ref` together with a separate remote `@context` that supplies the semantics. Relative references resolve against the schema's `$id` (the JSON Schema base URI) and, on the JSON-LD side, against `@base` / the retrieval URL; these base URIs SHOULD be aligned so a relative reference resolves to the same absolute URL under both. `$id` MUST NOT contain a non-empty fragment (JSONSCHEMA §8.2.1).", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:74" + }, + { + "id": "OOLD-CMP-6d7b", + "area": "CMP", + "level": "MUST", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "Branch-specific mappings for the same keyword must be scoped rather than placed at the root.", + "text": "Where branches genuinely need different mappings for the same keyword, those mappings MUST be scoped with JSON-LD scoped contexts so each applies only where its branch applies, rather than placed at the root, since colliding root mappings are resolved by context order instead of by the branch the data conforms to: Type-scoped contexts when the branches are distinguished by `@type`. The", + "text_sha256": "20a163b888e4fc79898974b0e846bb25df1e73dec7bb5fd434a19934bef7e47a", + "context": "Where branches genuinely need different mappings for the same keyword, those mappings MUST be scoped with JSON-LD scoped contexts so each applies only where its branch applies, rather than placed at the root, since colliding root mappings are resolved by context order instead of by the branch the data conforms to:\n- Type-scoped contexts when the branches are distinguished by `@type`. The", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:51" + }, + { + "id": "OOLD-CMP-a05a", + "area": "CMP", + "level": "MUST", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "A scoped context that must apply only to the immediate node sets @propagate false; contexts in one array share it.", + "text": "Where a referenced context should apply only to the immediate node, the schema MUST set `\"@propagate\": false` on that scoped context.", + "text_sha256": "c9059a4861176c0efa21e2f497d557b096d3105ca0cf872497191c5ed80cc7e8", + "context": "Propagation (`@propagate`). A `$ref` inside a `type: object` property is reflected as a property-scoped context, which by default propagates into the whole subtree rooted at that property (\"By default ... contexts propagate across node objects, other than for type-scoped contexts, which default to false\"). Where a referenced context should apply only to the immediate node, the schema MUST set `\"@propagate\": false` on that scoped context.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:70" + }, + { + "id": "OOLD-CMP-b926", + "area": "CMP", + "level": "MUST NOT", + "applies_to": "document", + "section": "composition", + "summary": "A schema must be usable as a JSON-LD context with no further processing, so every $ref is reflected in the @context.", + "text": "It MUST NOT be required to further process an OO-LD schema document in order to interpret it as a JSON-LD context.", + "text_sha256": "fb9e604440b34e28343756729d47c1cf923d35a93bc11354181e4cd87b069a4c", + "context": "It MUST NOT be required to further process an OO-LD schema document in order to interpret it as a JSON-LD context. This implies that all occurrences of `$ref` in the schema are reflected in the JSON-LD context. An embedded object reached under an object-valued property - one whose value is an object, directly (`type: object`) or as the `items` of an array (`type: array`), whether inlined or brought in by `$ref` - SHOULD be reflected as that property's scoped JSON-LD context, so its terms resolve only under that property and cannot conflict with a same-named term elsewhere. That scoped context MAY reference the embedded schema remotely (by URL) or carry its terms inline. Where the embed graph is cyclic - a value type whose scoped context transitively references itself through remote schema files - JSON-LD processors cannot resolve the recursive remote contexts (see [](#round-trip)); breaking the cycle requires migrating the remote reference to a local (inline) context - inlining the term definitions so there is no remote hop to recurse - which MAY be flattened onto the root context as a shared vocabulary. Moving the remote reference to the root does not break the cycle; only replacing it with local definitions does. A `$ref` at the root level of the OO-LD schema is listed at the root of the JSON-LD context. (A scalar reference - a property whose value is an IRI string, not an embedded object - carries its target type in [`x-oold-range`](#range-of-properties), not a `$ref`, and so contributes no scoped context.) In case of multiple `$ref` within `allOf` the corresponding remote contexts are merged into an array-valued `@context` (see [](#merging-remote-contexts)). For `oneOf` / `anyOf` this requires care to avoid conflicts. At any time the importing OO-LD schema MAY define its own or override the imported JSON-LD context.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:5" + }, + { + "id": "OOLD-CMP-dd2b", + "area": "CMP", + "level": "MUST NOT", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "A schema $id must not contain a non-empty fragment.", + "text": "`$id` MUST NOT contain a non-empty fragment (JSONSCHEMA §8.2.1).", + "text_sha256": "bc845f8e67383d4802f512fbf76d46caa3873cae4b10e370cca35952b5d62c0f", + "context": "Independent references and base URIs. A JSON Schema `$ref` and a JSON-LD `@context` entry are independent references: they MAY point to the same document (the typical OO-LD case, where one document is both a schema and a context) or to different documents - for example a plain JSON Schema referenced via `$ref` together with a separate remote `@context` that supplies the semantics. Relative references resolve against the schema's `$id` (the JSON Schema base URI) and, on the JSON-LD side, against `@base` / the retrieval URL; these base URIs SHOULD be aligned so a relative reference resolves to the same absolute URL under both. `$id` MUST NOT contain a non-empty fragment (JSONSCHEMA §8.2.1).", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:74" + }, + { + "id": "OOLD-CMP-e4a3", + "area": "CMP", + "level": "MUST", + "applies_to": "document", + "section": "merging-remote-contexts", + "summary": "A schema with multiple $refs must list their remote contexts as an array, in allOf order.", + "text": "By the reflection rule above, the schema's own `@context` MUST list those remote contexts as an array, in the same order as the `allOf` members, so the schema stays usable as a context without further processing.", + "text_sha256": "7c113ef3179658fded82c089504e6f781d0c22a75a46be372fec0cf83b1748f7", + "context": "Multiple `$ref` (e.g. in `allOf`) each correspond to a remote context. By the reflection rule above, the schema's own `@context` MUST list those remote contexts as an array, in the same order as the `allOf` members, so the schema stays usable as a context without further processing. A JSON-LD processor then resolves that array in order, later entries overriding earlier ones - duplicate context terms are overridden using a most-recently-defined-wins mechanism (JSONLD11-API, Context Processing Algorithm). The schema MAY append its own context object as the last array entry to override an inherited term. The single-context `@import` keyword is an alternative only when exactly one remote context is wrapped and locally modified (it cannot contain a nested `@import`), so the array form is used for the multi-`$ref` case.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:47" + }, + { + "id": "OOLD-CMP-f3c7", + "area": "CMP", + "level": "MUST NOT", + "applies_to": "document", + "section": "merge-and-override-model", + "summary": "Composition is narrow-only: a derived schema may restrict a constraint but must not relax it.", + "text": "For assertion-bearing keywords the resolved view additionally honors narrow-only composition: a derived schema MAY restrict a constraint but MUST NOT relax it, matching how code generators let a subclass tighten - never loosen - a superclass property's validation.", + "text_sha256": "6e73492f741ebab68002a6728d628b42613c8f4d7adccee5e7ff92bdc61e0b84", + "context": "When such a merge is required, OO-LD resolves the `allOf` chain by applying JSON Merge Patch (RFC7396) semantics: keyed by object member, most-recently-defined (most-derived) wins, and a `null` value removes a key. For the `@context` this coincides with JSON-LD's own override rule. For assertion-bearing keywords the resolved view additionally honors narrow-only composition: a derived schema MAY restrict a constraint but MUST NOT relax it, matching how code generators let a subclass tighten - never loosen - a superclass property's validation.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "06-composition.md:80" + }, + { + "id": "OOLD-CNF-1120", + "area": "CNF", + "level": "MUST", + "applies_to": "document", + "section": "notation", + "summary": "A conforming schema or instance must be interchangeable as JSON, canonicalized per RFC 8785.", + "text": "JSON (RFC8259) is the canonical serialization: a conforming OO-LD schema or instance MUST be interchangeable as JSON, and the canonical form used for identity and integrity (for example content-hashing a versioned schema) is its JSON Canonicalization Scheme (RFC8785) serialization.", + "text_sha256": "a9cdf1bd1358785e00667c7d0ce7baf6dea8e8c1ad3736385ee92243ed38a2e6", + "context": "The normative data model of OO-LD is the JSON data model shared by JSONSCHEMA and JSON-LD11. JSON (RFC8259) is the canonical serialization: a conforming OO-LD schema or instance MUST be interchangeable as JSON, and the canonical form used for identity and integrity (for example content-hashing a versioned schema) is its JSON Canonicalization Scheme (RFC8785) serialization.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "03-conformance.md:9" + }, + { + "id": "OOLD-CNF-22d3", + "area": "CNF", + "level": "MUST NOT", + "applies_to": "document", + "section": "notation", + "summary": "A YAML serialization outside the JSON-compatible subset is not a conforming OO-LD serialization.", + "text": "A YAML document outside this subset, including one relying on the features YAML-LD admits only in its Extended profile, MUST NOT be treated as a conforming OO-LD serialization.", + "text_sha256": "b4e844d40d00838c2dbbc8d722aa9f35f85211917d61d5b453ea1ecafbf5a2a0", + "context": "A document MAY additionally be authored or served as YAML, provided it stays within the JSON-compatible subset of [YAML 1.2](https://yaml.org/spec/1.2.2/): no tags, anchors, aliases, or merge keys; a single document; and no implicit typing beyond what JSON expresses. Within this subset - which coincides with the Basic profile of [YAML-LD](https://github.com/w3c/yaml-ld) - a YAML document maps one-to-one onto the JSON data model and converts to the canonical JSON without loss. A YAML document outside this subset, including one relying on the features YAML-LD admits only in its Extended profile, MUST NOT be treated as a conforming OO-LD serialization.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "03-conformance.md:11" + }, + { + "id": "OOLD-CNF-d71d", + "area": "CNF", + "level": "MUST", + "applies_to": "document", + "section": "notation", + "summary": "Where a YAML form and its canonical JSON disagree, the JSON form is authoritative.", + "text": "Where the two forms disagree, the JSON form MUST be treated as authoritative.", + "text_sha256": "bbaaecdeec3c9419d4056db8917d92c8b14f3bd0af5c6d9c765c55814f3976b9", + "context": "Authors using YAML should be aware that YAML comments and implicit type coercions (for example the strings `NO` or `1.10` read as a boolean or a truncated number by some parsers) do not survive conversion to the canonical JSON. Where the two forms disagree, the JSON form MUST be treated as authoritative. Examples in this specification are shown as JSON, with an equivalent YAML rendering available under \"View as YAML\".", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "03-conformance.md:13" + }, + { + "id": "OOLD-EXT-1dc8", + "area": "EXT", + "level": "MUST NOT", + "applies_to": "document", + "section": "synonyms", + "summary": "A predicate_id must not be a bare local name.", + "text": "A bare local name (`exactMatch`) MUST NOT be used as a `predicate_id`.", + "text_sha256": "8874d4e1da929ee9f25b4925a9820211469c1bc397b80760fdf25be82cf1d49b", + "context": "`predicate_id` is a [SKOS](https://www.w3.org/TR/skos-reference/) mapping predicate - `skos:exactMatch` (the default when the slot is absent), `skos:closeMatch`, `skos:broadMatch`, `skos:narrowMatch` or `skos:relatedMatch` - relating the term's primary IRI (subject) to the synonym IRI (object); it decides which entries denote equivalence. It MUST be written as a full IRI or a CURIE and compared by expansion to an absolute IRI, the same rule the synonym keys follow, so `skos:exactMatch` and `http://www.w3.org/2004/02/skos/core#exactMatch` are one predicate. `x-oold-context` is a schema-level keyword consumed by OO-LD processors (it is promoted into a clean `@context` before any generic JSON-LD processor runs), so its CURIEs - the synonym keys and the `predicate_id` / `mapping_set_id` values alike - are expanded not against the instance `@context` but against a fixed well-known prefix set the meta-schema defines (`skos`, `rdfs`, `owl`, `xsd`, `sssom`), reached through the schema's `$schema`. The contract therefore holds without the author redeclaring those prefixes in the data context. A bare local name (`exactMatch`) MUST NOT be used as a `predicate_id`.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:58" + }, + { + "id": "OOLD-EXT-1f92", + "area": "EXT", + "level": "RECOMMENDED", + "applies_to": "document", + "section": "range-reference-form", + "summary": "iri-reference is the recommended default format for an IRI-valued property.", + "text": "By RFC3987 this accepts absolute IRIs, compact IRIs (`ex:alice`, `schema:Person`) and context-relative references alike - the forms OO-LD instances routinely use - so it is the RECOMMENDED default.", + "text_sha256": "5d4c4a4f84533b109985d2562ad3459f3c9ce3dcd42d66aebeca8ca6c182e815", + "context": "- Any IRI reference - `\"format\": \"iri-reference\"`. By RFC3987 this accepts absolute IRIs, compact IRIs (`ex:alice`, `schema:Person`) and context-relative references alike - the forms OO-LD instances routinely use - so it is the RECOMMENDED default. It also accepts a bare term such as `alice`, expanded against the context's `@base` / `@vocab`.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:326" + }, + { + "id": "OOLD-EXT-2542", + "area": "EXT", + "level": "SHOULD", + "applies_to": "document", + "section": "value-term-aliases", + "summary": "Value terms should not collide with JSON-LD keyword aliases or other context terms.", + "text": "The value terms SHOULD also be kept from colliding with JSON-LD keyword aliases (`id`, `type`) or other context terms, since a value term shares the context's global term namespace - a term added for a value would otherwise also rewrite a property or keyword of the same name.", + "text_sha256": "e004b9449c9c6f9b84588bb7bf7660d81665e99f0a1040320bce572ae2f7fb45", + "context": "Because `@vocab` expands an unmatched string against the vocabulary - concatenating it onto the default vocabulary base when one is set (minting a new IRI), or leaving it a relative IRI when none is - a typo silently becomes a stray IRI rather than an error. A property coerced `\"@type\": \"@vocab\"` therefore SHOULD constrain its values with an `enum` of the value terms (optionally named with `x-enum-varnames`) or with `x-oold-range`, so only intended individuals are accepted. The value terms SHOULD also be kept from colliding with JSON-LD keyword aliases (`id`, `type`) or other context terms, since a value term shares the context's global term namespace - a term added for a value would otherwise also rewrite a property or keyword of the same name. Confining the value terms to the property's own scoped `@context` keeps them out of that shared namespace, since they then resolve only for that property's values; naming them with opaque identifiers such as UUIDs avoids the clash where readability is not required.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:335" + }, + { + "id": "OOLD-EXT-2b61", + "area": "EXT", + "level": "MUST", + "applies_to": "document", + "section": "range-reference-form", + "summary": "A compact-IRI prefix used by a property must be defined in the @context.", + "text": "Compact form specifically - a `\"pattern\"` such as `\"^[A-Za-z_][\\\\w.-]:(?!//)\\\\S$\"`, which accepts `ex:alice` and `schema:Person` while rejecting `http://…`; the prefix MUST be defined in the `@context`.", + "text_sha256": "9ed092ee23b716d012311740effff835e67e883d41215e2fd27f66107a383708", + "context": "- Stricter, ASCII only - `\"format\": \"uri\"` or `\"uri-reference\"`, where values are known not to use internationalized (non-ASCII) IRIs.\n- Compact form specifically - a `\"pattern\"` such as `\"^[A-Za-z_][\\\\w.-]:(?!//)\\\\S$\"`, which accepts `ex:alice` and `schema:Person` while rejecting `http://…`; the prefix MUST be defined in the `@context`.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:329" + }, + { + "id": "OOLD-EXT-391e", + "area": "EXT", + "level": "MUST", + "applies_to": "implementation", + "section": "range-of-properties", + "summary": "A loader that dereferences a target validates it against the declared range and does not assume the target conforms.", + "text": "A loader that dereferences a target MUST validate it against that range before treating it as a member, and MUST NOT assume the target conforms, since the target is a separate document that may change independently of the reference.", + "text_sha256": "b0caa6b770adbdec9bcdb37cfb4d365c87eed6817f55c8f40574ec0d6587077d", + "context": "An `x-oold-range` value is a reference: the property holds the target's IRI, and an OO-LD-aware loader MAY dereference that IRI to obtain the target document itself, so a large or shared object can live in a separate document and be pulled in on demand (data bundling). A published reference SHOULD point at a target that validates against the property's declared range. A loader that dereferences a target MUST validate it against that range before treating it as a member, and MUST NOT assume the target conforms, since the target is a separate document that may change independently of the reference. This holds whether the reference is written as a bare IRI string or as a `{ \"@id\": … }` object; generic tooling leaves it unresolved, exactly as it leaves `x-oold-ref` (see [](#why-x-oold-ref)).", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:320" + }, + { + "id": "OOLD-EXT-3fe9", + "area": "EXT", + "level": "MUST", + "applies_to": "document", + "section": "range-of-properties", + "summary": "References inside x-oold-range must use x-oold-ref, never $ref.", + "text": "References to other schemas inside `x-oold-range` MUST use `x-oold-ref`, never `$ref` (see below).", + "text_sha256": "6626132bb2c6a394d430fb9e4db9a559c26b71914acdc07ed8fb3a4b06c88d75", + "context": "3. An OO-LD subschema, the most expressive form. Unions (`anyOf` / `oneOf`), intersections (`allOf`) and inline constraints can be combined to describe an anonymous subclass. References to other schemas inside `x-oold-range` MUST use `x-oold-ref`, never `$ref` (see below). The single-IRI form (1) is a shorthand for `{ \"allOf\": [ { \"x-oold-ref\": \"Organization.schema.json\" } ] }`:", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:298" + }, + { + "id": "OOLD-EXT-436a", + "area": "EXT", + "level": "SHOULD", + "applies_to": "implementation", + "section": "semantic-delivery", + "summary": "For OpenAPI 3.0, deliver the context and type per class as vendor extensions.", + "text": "For OpenAPI 3.0, which rejects unprefixed keywords in a Schema Object (and typically bundles several classes with no document root to host one `@context`), the context and type SHOULD be delivered per class as `x-jsonld-context` and `x-jsonld-type` following [REST API Linked Data Keywords](https://datatracker.ietf.org/doc/html/draft-polli-restapi-ld-keywords-08): `@context` maps to `x-jsonld-context` and `x-oold-instance-rdf-type` to `x-jsonld-type`.", + "text_sha256": "90622f946111378db3b4fed08c6981f87e452e982f9f208a562ada1ade8122da", + "context": "- For OpenAPI 3.0, which rejects unprefixed keywords in a Schema Object (and typically bundles several classes with no document root to host one `@context`), the context and type SHOULD be delivered per class as `x-jsonld-context` and `x-jsonld-type` following [REST API Linked Data Keywords](https://datatracker.ietf.org/doc/html/draft-polli-restapi-ld-keywords-08): `@context` maps to `x-jsonld-context` and `x-oold-instance-rdf-type` to `x-jsonld-type`. That draft requires references inside these keywords not to be dereferenced automatically, consistent with the `x-oold-ref` rule (see [](#why-x-oold-ref)). The mapping is reversible, so such an export can be read back into an OO-LD schema.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:429" + }, + { + "id": "OOLD-EXT-4966", + "area": "EXT", + "level": "MUST", + "applies_to": "implementation", + "section": "synonyms", + "summary": "Promotion selects one synonym per term for the target profile, writes it as the term definition and drops the x-oold-sssom blocks.", + "text": "To promote `x-oold-context` into a real `@context`, a preprocessor MUST select one synonym per term for a target profile, write `{ \"@id\": , ...fragment without x-oold-sssom }` as that term's definition, and drop the `x-oold-sssom` blocks, so standard JSON-LD tools then run on a clean context.", + "text_sha256": "fbd527aa776fd794bb6d90aa255b8c2752e6164878e9aa1d9d957bece9ae658a", + "context": "Selection. To promote `x-oold-context` into a real `@context`, a preprocessor MUST select one synonym per term for a target profile, write `{ \"@id\": , ...fragment without x-oold-sssom }` as that term's definition, and drop the `x-oold-sssom` blocks, so standard JSON-LD tools then run on a clean context. A profile is expressed either as an ordered list of IRI namespaces (ontology-family priority - `schema:` before `bfo:` before `emmo:`) or as one or more `mapping_set_id`s (a set may span namespaces, e.g. a PMDco profile of `pmd:` plus reused `obo:` terms). A term with no synonym matching the target keeps its default `@context` IRI. Selection MUST NOT use a synonym from outside the target profile; where the profile is an ordered list, the highest-priority match wins and a lower-priority entry is selected only where no higher one matches.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:62" + }, + { + "id": "OOLD-EXT-5184", + "area": "EXT", + "level": "SHOULD", + "applies_to": "document", + "section": "jsonschema-extensions", + "summary": "A schema should declare the OO-LD dialect meta-schema as its $schema.", + "text": "An OO-LD schema SHOULD declare the OO-LD dialect meta-schema (which extends 2020-12) as its `$schema`, e.g. `\"$schema\": \"https://oo-ld.org/latest/meta/oold-meta-schema.json\"` - pinning a specific version (e.g. `.../0.4.0/meta/oold-meta-schema.json`) for reproducibility.", + "text_sha256": "128c5740185a075885f4ff1aeddf90f73f951ed978e47ef6e8f1187d1e9b5753", + "context": "OO-LD targets JSONSCHEMA (2020-12) as its normative dialect. An OO-LD schema SHOULD declare the OO-LD dialect meta-schema (which extends 2020-12) as its `$schema`, e.g. `\"$schema\": \"https://oo-ld.org/latest/meta/oold-meta-schema.json\"` - pinning a specific version (e.g. `.../0.4.0/meta/oold-meta-schema.json`) for reproducibility. Declaring the plain 2020-12 meta-schema (`https://json-schema.org/draft/2020-12/schema`) remains valid for tools that only understand standard JSON Schema.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:185" + }, + { + "id": "OOLD-EXT-557e", + "area": "EXT", + "level": "MUST", + "applies_to": "implementation", + "section": "synonyms", + "summary": "A predicate_id is written as a full IRI or CURIE and compared by expansion to an absolute IRI.", + "text": "It MUST be written as a full IRI or a CURIE and compared by expansion to an absolute IRI, the same rule the synonym keys follow, so `skos:exactMatch` and `http://www.w3.org/2004/02/skos/core#exactMatch` are one predicate.", + "text_sha256": "47866999c5fcf3c61427192149b560a2002a174311a64b04a2be36f2e3383916", + "context": "`predicate_id` is a [SKOS](https://www.w3.org/TR/skos-reference/) mapping predicate - `skos:exactMatch` (the default when the slot is absent), `skos:closeMatch`, `skos:broadMatch`, `skos:narrowMatch` or `skos:relatedMatch` - relating the term's primary IRI (subject) to the synonym IRI (object); it decides which entries denote equivalence. It MUST be written as a full IRI or a CURIE and compared by expansion to an absolute IRI, the same rule the synonym keys follow, so `skos:exactMatch` and `http://www.w3.org/2004/02/skos/core#exactMatch` are one predicate. `x-oold-context` is a schema-level keyword consumed by OO-LD processors (it is promoted into a clean `@context` before any generic JSON-LD processor runs), so its CURIEs - the synonym keys and the `predicate_id` / `mapping_set_id` values alike - are expanded not against the instance `@context` but against a fixed well-known prefix set the meta-schema defines (`skos`, `rdfs`, `owl`, `xsd`, `sssom`), reached through the schema's `$schema`. The contract therefore holds without the author redeclaring those prefixes in the data context. A bare local name (`exactMatch`) MUST NOT be used as a `predicate_id`.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:58" + }, + { + "id": "OOLD-EXT-6007", + "area": "EXT", + "level": "SHOULD", + "applies_to": "implementation", + "section": "why-x-oold-ref", + "summary": "An OO-LD-aware tool resolves x-oold-ref lazily and handles a cyclic reference graph by terminating rather than recursing indefinitely.", + "text": "An OO-LD-aware tool SHOULD resolve `x-oold-ref` lazily, and MUST handle a cyclic reference graph - terminating and returning the references it has already resolved, rather than recursing indefinitely - since the graph it opts into may be unbounded or self-referential.", + "text_sha256": "933cab8c00e5318aab051baa733bee926e95deec92971645c6281457c0bdbe1f", + "context": "`x-oold-ref` avoids this. Generic tools only follow the standard `$ref` keyword, so they leave `x-oold-ref` untouched. An OO-LD-aware tool SHOULD resolve `x-oold-ref` lazily, and MUST handle a cyclic reference graph - terminating and returning the references it has already resolved, rather than recursing indefinitely - since the graph it opts into may be unbounded or self-referential. The standard `$ref` continues to be used for ordinary schema composition (`allOf`, `properties`, `$defs`), which bundlers are expected to resolve. Because the only difference is the keyword name, the mapping is reversible: an OO-LD-aware tool can mechanically replace `x-oold-ref` with `$ref` to obtain a plain, fully-resolvable JSON Schema - the explicit opt-in to resolving the (possibly cyclic) graph.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:341" + }, + { + "id": "OOLD-EXT-61aa", + "area": "EXT", + "level": "SHOULD", + "applies_to": "implementation", + "section": "semantic-delivery", + "summary": "A consumer accepting arbitrary JSON Schema keywords should receive the native form unchanged.", + "text": "A consumer that accepts arbitrary JSON Schema keywords SHOULD receive the native form unchanged.", + "text_sha256": "aa1601aa9ebbaf66046ee3c18acd7690ee8da151843196a42f3d4c1958ed42cd", + "context": "- A consumer that accepts arbitrary JSON Schema keywords SHOULD receive the native form unchanged. This covers plain JSON Schema 2020-12 validators, OpenAPI 3.1, and - because they place no restriction on `@context` - Model Context Protocol tool schemas (`inputSchema` / `outputSchema`) as well as LLM tool-use and structured-output APIs, which carry the context through and can use it as grounding.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:428" + }, + { + "id": "OOLD-EXT-6312", + "area": "EXT", + "level": "MUST NOT", + "applies_to": "document", + "section": "localizing-instance-values", + "summary": "The multilang keywords must not be used to localize an instance value; the standard JSON-LD mechanism is used instead.", + "text": "To localize a value of an instance - a translatable string in the data that should round-trip to language-tagged RDF literals - the keywords above MUST NOT be used; the standard JSON-LD mechanism MUST be used instead.", + "text_sha256": "24f4abaced11e4203298124cfbc2642efc2b6b0fc4c9a3369e0ff4c8d16ce1b8", + "context": "To localize a value of an instance - a translatable string in the data that should round-trip to language-tagged RDF literals - the keywords above MUST NOT be used; the standard JSON-LD mechanism MUST be used instead. There are two equivalent JSON-LD-native ways to carry such a value, both producing the same language-tagged literals.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:210" + }, + { + "id": "OOLD-EXT-6ea3", + "area": "EXT", + "level": "SHOULD", + "applies_to": "document", + "section": "range-reference-form", + "summary": "An IRI-valued property should constrain its lexical form with an IRI/URI-family format.", + "text": "Its lexical form SHOULD be constrained with an IRI/URI-family `format` so that malformed values are rejected; the choices, from most to least permissive:", + "text_sha256": "a1fd73169ee2f84b0a897e74e5495e9019dd4a8ac1c763a108fc8cb02a0afe9e", + "context": "The value of an IRI-valued property is a JSON string. Its role as a reference comes from the `@context` (`\"@type\": \"@id\"`) and its class from `x-oold-range`. Its lexical form SHOULD be constrained with an IRI/URI-family `format` so that malformed values are rejected; the choices, from most to least permissive:\n- Any IRI reference - `\"format\": \"iri-reference\"`. By RFC3987 this accepts absolute IRIs, compact IRIs (`ex:alice`, `schema:Person`) and context-relative references alike - the forms OO-LD instances routinely use - so it is the RECOMMENDED default. It also accepts a bare term such as `alice`, expanded against the context's `@base` / `@vocab`.\n- Absolute IRIs only - `\"format\": \"iri\"`. A compact IRI is itself a valid absolute IRI (scheme `ex`, path `alice`), so `iri` accepts `ex:alice`; choose it to additionally forbid relative references.\n- Stricter, ASCII only - `\"format\": \"uri\"` or `\"uri-reference\"`, where values are known not to use internationalized (non-ASCII) IRIs.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:324" + }, + { + "id": "OOLD-EXT-7256", + "area": "EXT", + "level": "SHOULD NOT", + "applies_to": "implementation", + "section": "synonyms", + "summary": "Entries that are not exactMatch should not be co-emitted unless a consumer explicitly requests it.", + "text": "By default a converter co-emits only `skos:exactMatch` entries; entries whose `predicate_id` is `skos:closeMatch`/`broadMatch`/`narrowMatch`/`relatedMatch` SHOULD NOT be co-emitted unless a consumer explicitly requests it, since such a triple asserts a broader, narrower or merely related relation, not that the value holds under the synonym property, so the requester takes responsibility for that reading.", + "text_sha256": "1d8752aa41f9ee074a8b56e0e957c837218642db3fe32699d62cd0e5a669456b", + "context": "Co-emission. Selection yields one IRI per term; for interoperability a converter MAY additionally co-emit the instance value under other synonyms' IRIs. This is a pragmatic interoperability aid, not a logical entailment: `skos:exactMatch` records that two terms are interchangeable across a wide range of applications, but it is not `owl:equivalentProperty` / `owl:equivalentClass` and licenses no reasoner inference - which is exactly why the mapping predicates are SKOS (reasoner-safe) rather than OWL. By default a converter co-emits only `skos:exactMatch` entries; entries whose `predicate_id` is `skos:closeMatch`/`broadMatch`/`narrowMatch`/`relatedMatch` SHOULD NOT be co-emitted unless a consumer explicitly requests it, since such a triple asserts a broader, narrower or merely related relation, not that the value holds under the synonym property, so the requester takes responsibility for that reading.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:64" + }, + { + "id": "OOLD-EXT-7c5d", + "area": "EXT", + "level": "MUST", + "applies_to": "implementation", + "section": "synonyms", + "summary": "A conforming mapping processor reads exactly the synonym IRI, the term-definition fragment, predicate_id and mapping_set_id from an entry.", + "text": "A conforming OO-LD mapping processor MUST read exactly four things from each entry: the synonym IRI (the key), the promotable term-definition fragment, and two `x-oold-sssom` slots - `predicate_id` and `mapping_set_id`.", + "text_sha256": "c427f66d6c7aff32fe62825b744d49ce754281ed070248a0d31324295985cf1e", + "context": "Processing contract. A conforming OO-LD mapping processor MUST read exactly four things from each entry: the synonym IRI (the key), the promotable term-definition fragment, and two `x-oold-sssom` slots - `predicate_id` and `mapping_set_id`. Conformance MUST NOT depend on anything else an entry carries (the rest of `x-oold-sssom`, any further fragment keys); a processor MAY interpret such keys as its own extension, and MUST carry them through unchanged where it rewrites an entry rather than promoting it - promotion deliberately drops the `x-oold-sssom` blocks, as Selection describes below. Those two slots, over the SKOS predicate vocabulary, are the whole stable contract an implementation depends on.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:56" + }, + { + "id": "OOLD-EXT-8f62", + "area": "EXT", + "level": "MUST NOT", + "applies_to": "implementation", + "section": "synonyms", + "summary": "Selection must not use a synonym outside the target profile; within an ordered profile the highest-priority match wins.", + "text": "Selection MUST NOT use a synonym from outside the target profile; where the profile is an ordered list, the highest-priority match wins and a lower-priority entry is selected only where no higher one matches.", + "text_sha256": "6c1a14a9767b73741bc1b264af5d472191cb51e19cf4c4daa824ba35c814efc3", + "context": "Selection. To promote `x-oold-context` into a real `@context`, a preprocessor MUST select one synonym per term for a target profile, write `{ \"@id\": , ...fragment without x-oold-sssom }` as that term's definition, and drop the `x-oold-sssom` blocks, so standard JSON-LD tools then run on a clean context. A profile is expressed either as an ordered list of IRI namespaces (ontology-family priority - `schema:` before `bfo:` before `emmo:`) or as one or more `mapping_set_id`s (a set may span namespaces, e.g. a PMDco profile of `pmd:` plus reused `obo:` terms). A term with no synonym matching the target keeps its default `@context` IRI. Selection MUST NOT use a synonym from outside the target profile; where the profile is an ordered list, the highest-priority match wins and a lower-priority entry is selected only where no higher one matches.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:62" + }, + { + "id": "OOLD-EXT-adcc", + "area": "EXT", + "level": "MUST NOT", + "applies_to": "implementation", + "section": "synonyms", + "summary": "Conformance must not depend on anything an entry carries beyond the four contract members.", + "text": "Conformance MUST NOT depend on anything else an entry carries (the rest of `x-oold-sssom`, any further fragment keys); a processor MAY interpret such keys as its own extension, and MUST carry them through unchanged where it rewrites an entry rather than promoting it - promotion deliberately drops the `x-oold-sssom` blocks, as Selection describes below.", + "text_sha256": "8396100be9d471ae2bf7a2767ad2b384b737e72888ccc960b9dcaf00616f13ac", + "context": "Processing contract. A conforming OO-LD mapping processor MUST read exactly four things from each entry: the synonym IRI (the key), the promotable term-definition fragment, and two `x-oold-sssom` slots - `predicate_id` and `mapping_set_id`. Conformance MUST NOT depend on anything else an entry carries (the rest of `x-oold-sssom`, any further fragment keys); a processor MAY interpret such keys as its own extension, and MUST carry them through unchanged where it rewrites an entry rather than promoting it - promotion deliberately drops the `x-oold-sssom` blocks, as Selection describes below. Those two slots, over the SKOS predicate vocabulary, are the whole stable contract an implementation depends on.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:56" + }, + { + "id": "OOLD-EXT-af50", + "area": "EXT", + "level": "REQUIRED", + "applies_to": "document", + "section": "jsonschema-extensions", + "summary": "JSON Schema 2020-12 is required as the dialect, because composition places $ref alongside sibling keywords.", + "text": "2020-12 is REQUIRED, not merely preferred: OO-LD's composition places `$ref` alongside sibling keywords (e.g. a property carrying `type`, `x-oold-range` and `@context`, or `allOf: [{$ref: ...}]` next to `properties`).", + "text_sha256": "72547da1a2d09844c2095de159112d47feea6881c50f48ad7d968f755330748b", + "context": "2020-12 is REQUIRED, not merely preferred: OO-LD's composition places `$ref` alongside sibling keywords (e.g. a property carrying `type`, `x-oold-range` and `@context`, or `allOf: [{$ref: ...}]` next to `properties`). Keywords adjacent to `$ref` are only evaluated from JSON Schema 2019-09 onward; in Draft 4 and Draft 7 they are ignored (JSONSCHEMA §8.2.3.1). Keywords such as `const` (used throughout this document) are likewise only available from draft-06 onward. Migration from the earlier Draft-4-style notation: rename `definitions` to `$defs`, `id` to `$id`, and use the numeric form of `exclusiveMinimum`/`exclusiveMaximum` instead of the boolean form.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:187" + }, + { + "id": "OOLD-EXT-dd76", + "area": "EXT", + "level": "SHOULD", + "applies_to": "document", + "section": "localizing-schema-annotations", + "summary": "A schema using multilingual annotations should still provide a default title and description.", + "text": "A schema SHOULD still provide a default `title` / `description`; a consumer that has no entry for the requested language falls back to that default.", + "text_sha256": "bbbf0172cbf0c77fbe39625c8df206a570aa41f405c9e9a213152154d1e4486d", + "context": "The JSON Schema annotation keywords `title` and `description` carry a single, default human-readable string used by tooling (for example for UI generation). To provide localized variants, OO-LD adds the keywords `x-oold-multilang-title` and `x-oold-multilang-description`. Their value MUST be an object whose keys are [BCP 47](https://www.rfc-editor.org/info/bcp47) language tags (e.g. `en`, `de`, `en-GB`) and whose values are the translated strings. A schema SHOULD still provide a default `title` / `description`; a consumer that has no entry for the requested language falls back to that default. These keywords localize the schema's own labels and are not interpreted as JSON-LD.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:195" + }, + { + "id": "OOLD-EXT-ddda", + "area": "EXT", + "level": "SHOULD", + "applies_to": "document", + "section": "processing-mode", + "summary": "A generated context should declare @version 1.1 as a JSON number.", + "text": "Generated OO-LD contexts SHOULD therefore declare `\"@version\": 1.1` (the JSON number `1.1`, not the string `\"1.1\"`).", + "text_sha256": "d4435d679d95a50a4cb65f720ee08a3c8637db3652d2b8310d1d08cac0de0946", + "context": "Generated OO-LD contexts SHOULD therefore declare `\"@version\": 1.1` (the JSON number `1.1`, not the string `\"1.1\"`). Modern processors default to the 1.1 processing mode, so this is a guard rather than a strict requirement: it prevents a JSON-LD 1.0 processor from silently mis-processing a 1.1 document (JSON-LD11 §4.1.1). Because the first encountered `@version` entry determines the processing mode, it is sufficient to declare `\"@version\": 1.1` once in the base context of a composition (for example a root `Thing` schema).", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:13" + }, + { + "id": "OOLD-EXT-ece0", + "area": "EXT", + "level": "SHOULD", + "applies_to": "document", + "section": "range-of-properties", + "summary": "A published reference should point at a target that validates against the property's declared range.", + "text": "A published reference SHOULD point at a target that validates against the property's declared range.", + "text_sha256": "261731e44c8056793242380d5f01d4bade80a43f9db81cbc6c04296d2eeeffd7", + "context": "An `x-oold-range` value is a reference: the property holds the target's IRI, and an OO-LD-aware loader MAY dereference that IRI to obtain the target document itself, so a large or shared object can live in a separate document and be pulled in on demand (data bundling). A published reference SHOULD point at a target that validates against the property's declared range. A loader that dereferences a target MUST validate it against that range before treating it as a member, and MUST NOT assume the target conforms, since the target is a separate document that may change independently of the reference. This holds whether the reference is written as a bare IRI string or as a `{ \"@id\": … }` object; generic tooling leaves it unresolved, exactly as it leaves `x-oold-ref` (see [](#why-x-oold-ref)).", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:320" + }, + { + "id": "OOLD-EXT-ef09", + "area": "EXT", + "level": "MUST", + "applies_to": "document", + "section": "localizing-schema-annotations", + "summary": "x-oold-multilang-title/description must map BCP 47 language tags to translated strings.", + "text": "Their value MUST be an object whose keys are [BCP 47](https://www.rfc-editor.org/info/bcp47) language tags (e.g. `en`, `de`, `en-GB`) and whose values are the translated strings.", + "text_sha256": "f283baf414a7d41c21181aab27a32af73c618fa651fb20cb8d9ad80392063cf6", + "context": "The JSON Schema annotation keywords `title` and `description` carry a single, default human-readable string used by tooling (for example for UI generation). To provide localized variants, OO-LD adds the keywords `x-oold-multilang-title` and `x-oold-multilang-description`. Their value MUST be an object whose keys are [BCP 47](https://www.rfc-editor.org/info/bcp47) language tags (e.g. `en`, `de`, `en-GB`) and whose values are the translated strings. A schema SHOULD still provide a default `title` / `description`; a consumer that has no entry for the requested language falls back to that default. These keywords localize the schema's own labels and are not interpreted as JSON-LD.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:195" + }, + { + "id": "OOLD-EXT-fdd8", + "area": "EXT", + "level": "SHOULD", + "applies_to": "document", + "section": "value-term-aliases", + "summary": "A property coerced @type @vocab should constrain its values with an enum or x-oold-range.", + "text": "A property coerced `\"@type\": \"@vocab\"` therefore SHOULD constrain its values with an `enum` of the value terms (optionally named with `x-enum-varnames`) or with `x-oold-range`, so only intended individuals are accepted.", + "text_sha256": "c4bcd0af638b79ed0dc4ec4e25a597d38f2b3a957fab6ec77075e728b5887712", + "context": "Because `@vocab` expands an unmatched string against the vocabulary - concatenating it onto the default vocabulary base when one is set (minting a new IRI), or leaving it a relative IRI when none is - a typo silently becomes a stray IRI rather than an error. A property coerced `\"@type\": \"@vocab\"` therefore SHOULD constrain its values with an `enum` of the value terms (optionally named with `x-enum-varnames`) or with `x-oold-range`, so only intended individuals are accepted. The value terms SHOULD also be kept from colliding with JSON-LD keyword aliases (`id`, `type`) or other context terms, since a value term shares the context's global term namespace - a term added for a value would otherwise also rewrite a property or keyword of the same name. Confining the value terms to the property's own scoped `@context` keeps them out of that shared namespace, since they then resolve only for that property's values; naming them with opaque identifiers such as UUIDs avoids the clash where readability is not required.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "09-extensions.md:335" + }, + { + "id": "OOLD-INS-1d33", + "area": "INS", + "level": "MUST", + "applies_to": "implementation", + "section": "identity", + "summary": "An exported identifiable entity must carry an IRI.", + "text": "An implementation MAY use a non-IRI identifier internally, but when it exports an identifiable entity (to JSON-LD / RDF) it MUST assign an `@id` (or the aliased `id`).", + "text_sha256": "0ca31c621870dd1904ce0662b98d9c3633da1d2fa2eb149d4d7c592407a26ba3", + "context": "An implementation MAY use a non-IRI identifier internally, but when it exports an identifiable entity (to JSON-LD / RDF) it MUST assign an `@id` (or the aliased `id`). The `@id` SHOULD be resolvable, and it is RECOMMENDED to mint it from an autogenerated UUID - mirroring the schema's `x-oold-uuid` - e.g. `https://example.org/a1b2c3d4-1234-...`.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:62" + }, + { + "id": "OOLD-INS-1df7", + "area": "INS", + "level": "MUST NOT", + "applies_to": "document", + "section": "value-forms", + "summary": "Under the value-form pattern a reference is written as an object and its term must not carry @type.", + "text": "References are written as objects, and the term MUST NOT carry `@type`.", + "text_sha256": "7ed52efe5f63156ec8bd6abec09bae6117bf64fead052cf226687b15def1f02c", + "context": "1. Value-form - a single plain term (no `@type: \"@id\"`); the value shape alone disambiguates: a bare scalar is a literal, `{ \"id\": ... }` is a reference, a typed object is embedded. References are written as objects, and the term MUST NOT carry `@type`.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:138" + }, + { + "id": "OOLD-INS-27aa", + "area": "INS", + "level": "SHOULD NOT", + "applies_to": "implementation", + "section": "referencing-schema", + "summary": "A consumer should not blindly trust the schema an instance declares for itself.", + "text": "JSON Schema deliberately does not standardize `$schema` on instances, partly over a self-validation concern: a consumer SHOULD NOT blindly trust the schema an instance declares for itself (a crafted instance could point at a permissive schema) and remains responsible for validating against a schema it trusts.", + "text_sha256": "5fe40a0c4116c197a46477bc16e26da36e667f9c1f2cdccc812a20078affaf30", + "context": "`@context` already provides a JSON-LD-native link to the schema (resolution case 2 above), so `$schema` is kept primarily for compatibility with the widespread editor and CI convention, not as a second authoritative mechanism. JSON Schema deliberately does not standardize `$schema` on instances, partly over a self-validation concern: a consumer SHOULD NOT blindly trust the schema an instance declares for itself (a crafted instance could point at a permissive schema) and remains responsible for validating against a schema it trusts.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:39" + }, + { + "id": "OOLD-INS-2b3f", + "area": "INS", + "level": "SHOULD", + "applies_to": "document", + "section": "identity", + "summary": "Schemas should expose @id through an aliased id property.", + "text": "To keep instance keys variable-name-friendly, schemas SHOULD expose `@id` through an aliased `id` property (as with `type` -> `@type`):", + "text_sha256": "4f852db64a6532e49924b789cb0fdc636d349b6990b28a6b417d10ed49e564e0", + "context": "To keep instance keys variable-name-friendly, schemas SHOULD expose `@id` through an aliased `id` property (as with `type` -> `@type`):", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:45" + }, + { + "id": "OOLD-INS-2e5d", + "area": "INS", + "level": "MUST NOT", + "applies_to": "document", + "section": "value-forms", + "summary": "A property whose range includes free text must not use @type @id.", + "text": "A property whose range is references only therefore uses `@type: \"@id\"` and MAY be written as a bare IRI string; a property whose range includes free text MUST NOT use `@type: \"@id\"`.", + "text_sha256": "a25ac65edb99dd6ad5c5c7eb84e9c8c31eae9bc323fe5f241c7d8b0dfefa8948", + "context": "A single `@context` term cannot interpret a bare string as both a literal and an IRI: `@type: \"@id\"` coerces every string value to an IRI (so free text becomes an - often invalid, then dropped - IRI), while a plain term keeps every string a literal. A property whose range is references only therefore uses `@type: \"@id\"` and MAY be written as a bare IRI string; a property whose range includes free text MUST NOT use `@type: \"@id\"`.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:134" + }, + { + "id": "OOLD-INS-4b5c", + "area": "INS", + "level": "MUST", + "applies_to": "document", + "section": "semantic-type", + "summary": "An inline type must be consistent with the schema's x-oold-instance-rdf-type.", + "text": "If an inline `type` is present it MUST be consistent with the schema's `x-oold-instance-rdf-type`.", + "text_sha256": "e299166560553f9f4cc299e1da6315d09fbd878673eecdf78fce526bf6ae2f1b", + "context": "If an inline `type` is present it MUST be consistent with the schema's `x-oold-instance-rdf-type`. Note that `@type` alone lets a consumer locate the schema (case 3 above) only when one of the type IRIs resolves to an OO-LD schema.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:107" + }, + { + "id": "OOLD-INS-559f", + "area": "INS", + "level": "MUST", + "applies_to": "document", + "section": "semantic-type", + "summary": "The nearest x-oold-instance-rdf-type declaration in an allOf chain replaces a base class's value rather than appending to it.", + "text": "The nearest declaration in the `allOf` chain is authoritative and MUST replace a base class's value rather than append to it.", + "text_sha256": "39b70037901e41876545b50e5cca0c1f9d2d1bab77069b3edfe024538891aed3", + "context": "Under composition, `x-oold-instance-rdf-type` follows the same most-derived-wins rule as the rest of the schema (see [](#composition)). The nearest declaration in the `allOf` chain is authoritative and MUST replace a base class's value rather than append to it. Superclass types are recoverable by ontology inference (`rdfs:subClassOf`) and so need not be materialized; a schema that wants a supertype carried in the data lists it explicitly (e.g. `[\"schema:Researcher\", \"schema:Person\"]`).", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:109" + }, + { + "id": "OOLD-INS-75c6", + "area": "INS", + "level": "MUST", + "applies_to": "implementation", + "section": "semantic-type", + "summary": "Tooling exporting an instance must materialize the schema-declared rdf:type(s) as @type.", + "text": "Therefore, when OO-LD tooling exports an instance (to JSON-LD / RDF), it MUST materialize the declared `rdf:type`(s) as an `@type` on the instance, so that the type reaches RDF without access to the schema or to a type registry.", + "text_sha256": "3363473f627d54f6a732c599af8d3692ac0ba421a6b2770dff6347173934402c", + "context": "These types live in the schema, not in the instance data, so a JSON-LD-only processor - which sees only the instance and its `@context` - cannot derive them. Therefore, when OO-LD tooling exports an instance (to JSON-LD / RDF), it MUST materialize the declared `rdf:type`(s) as an `@type` on the instance, so that the type reaches RDF without access to the schema or to a type registry.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:78" + }, + { + "id": "OOLD-INS-9416", + "area": "INS", + "level": "SHOULD", + "applies_to": "document", + "section": "schema-instances", + "summary": "Instances should reference a versioned schema URL.", + "text": "Instances SHOULD use a versioned schema URL so that it is unambiguous which schema version they conform to.", + "text_sha256": "e9929e4d9ba01bc251bf092b05c886ff703d9432859d20c4c7db8dcdc6e8244d", + "context": "The two SHOULD point at the same schema URL, so that the context an instance is read with and the schema it is validated against are the same document. Instances SHOULD use a versioned schema URL so that it is unambiguous which schema version they conform to.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:17" + }, + { + "id": "OOLD-INS-ba9e", + "area": "INS", + "level": "MUST", + "applies_to": "document", + "section": "referencing-schema", + "summary": "A schema closing its objects must still permit the $schema and @context members.", + "text": "Because an instance carries `$schema` and `@context` as ordinary members, an OO-LD schema that closes its objects with `additionalProperties: false` or `unevaluatedProperties: false` MUST permit these two members, or conforming instances would fail validation.", + "text_sha256": "abd2bf2e989b2e2aea4cd7af0f6f7a3d69f802e6722467da727bd915940f1b68", + "context": "Because an instance carries `$schema` and `@context` as ordinary members, an OO-LD schema that closes its objects with `additionalProperties: false` or `unevaluatedProperties: false` MUST permit these two members, or conforming instances would fail validation.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:37" + }, + { + "id": "OOLD-INS-cb1a", + "area": "INS", + "level": "SHOULD", + "applies_to": "document", + "section": "schema-instances", + "summary": "An instance's @context and $schema should point at the same schema URL.", + "text": "The two SHOULD point at the same schema URL, so that the context an instance is read with and the schema it is validated against are the same document.", + "text_sha256": "523bf44316cca4e0b580834e1c588fb24e88d3720b86285bcea4ce7b4beb3990", + "context": "The two SHOULD point at the same schema URL, so that the context an instance is read with and the schema it is validated against are the same document. Instances SHOULD use a versioned schema URL so that it is unambiguous which schema version they conform to.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:17" + }, + { + "id": "OOLD-INS-cd80", + "area": "INS", + "level": "SHOULD", + "applies_to": "document", + "section": "identity", + "summary": "An instance @id should be resolvable, and is recommended to be minted from an autogenerated UUID.", + "text": "The `@id` SHOULD be resolvable, and it is RECOMMENDED to mint it from an autogenerated UUID - mirroring the schema's `x-oold-uuid` - e.g. `https://example.org/a1b2c3d4-1234-...`.", + "text_sha256": "c6075abdc17f9bb557fca8e7cc0d67b4ebc4af17797cb1ce28c4b6f8035cc89f", + "context": "An implementation MAY use a non-IRI identifier internally, but when it exports an identifiable entity (to JSON-LD / RDF) it MUST assign an `@id` (or the aliased `id`). The `@id` SHOULD be resolvable, and it is RECOMMENDED to mint it from an autogenerated UUID - mirroring the schema's `x-oold-uuid` - e.g. `https://example.org/a1b2c3d4-1234-...`.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:62" + }, + { + "id": "OOLD-INS-da1a", + "area": "INS", + "level": "SHOULD", + "applies_to": "advisory", + "section": "value-forms", + "summary": "A model ecosystem should adopt one of the two ambiguous-range patterns consistently.", + "text": "For a property whose range mixes free text with references and/or embedded objects (for example `Text | PostalAddress | Place`), two patterns keep the instance round-trippable (see [](#round-trip)); a model ecosystem SHOULD adopt one of them consistently:", + "text_sha256": "fd5834cb52f2ba7a41d5919db177c636bb08de85e3d8484c246a8c70e88fe921", + "context": "For a property whose range mixes free text with references and/or embedded objects (for example `Text | PostalAddress | Place`), two patterns keep the instance round-trippable (see [](#round-trip)); a model ecosystem SHOULD adopt one of them consistently:\n1. Value-form - a single plain term (no `@type: \"@id\"`); the value shape alone disambiguates: a bare scalar is a literal, `{ \"id\": ... }` is a reference, a typed object is embedded. References are written as objects, and the term MUST NOT carry `@type`.\n2. Separate keys - a canonical term `p` with `@type: \"@id\"` (a bare IRI string reference, plus embedded objects via a scoped `@context`) and a companion `p_text` that is a plain term for the literal.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:136" + }, + { + "id": "OOLD-INS-f010", + "area": "INS", + "level": "MUST NOT", + "applies_to": "implementation", + "section": "referencing-schema", + "summary": "A consuming side must not be assumed to hold an rdf:type-to-schema registry; exports are self-sufficient.", + "text": "An implementation MAY additionally maintain a registry mapping `rdf:type` IRIs to OO-LD schemas to resolve case 3, but such a registry MUST NOT be assumed to exist on the consuming side - so exports must be self-sufficient (see below).", + "text_sha256": "1f3874f3ae2f46ff72ae03f0f5b715296e7b637357bb3d8ac6eec656688fc17b", + "context": "An implementation MAY additionally maintain a registry mapping `rdf:type` IRIs to OO-LD schemas to resolve case 3, but such a registry MUST NOT be assumed to exist on the consuming side - so exports must be self-sufficient (see below).", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:35" + }, + { + "id": "OOLD-RT-08f2", + "area": "RT", + "level": "MUST", + "applies_to": "document", + "section": "round-trip", + "summary": "A strictly array-typed property must declare @container @set or @list.", + "text": "Because the reconstruction MUST re-validate, a property that is strictly an array (JSON Schema `type: \"array\"`) MUST declare `@container: \"@set\"` (or `\"@list\"`): without it a single-element array returns as a scalar and violates the `array` type.", + "text_sha256": "cccd90d1135476689792616dac8db9b85956567b4d689637621b77cdbec356f5", + "context": "- Multi-valued properties are set-valued in RDF: order is not preserved, duplicates are removed, and a single value compacts to a scalar. Because the reconstruction MUST re-validate, a property that is strictly an array (JSON Schema `type: \"array\"`) MUST declare `@container: \"@set\"` (or `\"@list\"`): without it a single-element array returns as a scalar and violates the `array` type. A property that also permits a scalar (an `anyOf`/`oneOf` of a literal and an array) MAY declare it for a stable array shape, but need not - the scalar form still validates, and a single value and a one-element array are JSON-LD-equivalent. Round-trip equality is set equality. A property SHOULD NOT declare `@list` unless the order of its values is significant, since ordering costs merge and query ergonomics.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:180" + }, + { + "id": "OOLD-RT-2028", + "area": "RT", + "level": "SHOULD NOT", + "applies_to": "document", + "section": "round-trip", + "summary": "A property should not declare @list unless the order of its values is significant.", + "text": "A property SHOULD NOT declare `@list` unless the order of its values is significant, since ordering costs merge and query ergonomics.", + "text_sha256": "a3db6b5a849249ccab57c724863d0b6460bc36cff7876ceb00a8c44b23ff746c", + "context": "- Multi-valued properties are set-valued in RDF: order is not preserved, duplicates are removed, and a single value compacts to a scalar. Because the reconstruction MUST re-validate, a property that is strictly an array (JSON Schema `type: \"array\"`) MUST declare `@container: \"@set\"` (or `\"@list\"`): without it a single-element array returns as a scalar and violates the `array` type. A property that also permits a scalar (an `anyOf`/`oneOf` of a literal and an array) MAY declare it for a stable array shape, but need not - the scalar form still validates, and a single value and a one-element array are JSON-LD-equivalent. Round-trip equality is set equality. A property SHOULD NOT declare `@list` unless the order of its values is significant, since ordering costs merge and query ergonomics.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:180" + }, + { + "id": "OOLD-RT-a12c", + "area": "RT", + "level": "MUST", + "applies_to": "document", + "section": "round-trip", + "summary": "An embedded object must carry an explicit type where the scoped context is type-scoped or the reconstruction frame matches on @type.", + "text": "The embedded object MUST carry an explicit `type` where the property's scoped `@context` is type-scoped - keyed by the value's `@type` to distinguish several embedded types (or to stamp the node's `rdf:type`) - or where the frame used to reconstruct it matches on `@type`, as a frame derived from the schema's class type does (see [](#framing)).", + "text_sha256": "064ffda87f246a6a69473c3f5fb31e58fcb9dc5c57f1f2d3583df3c402395f48", + "context": "- Embedded objects are flattened in RDF, and compaction does not re-nest a flat graph, so reconstructing the tree requires [Framing](#framing) - the frame can be as small as `{ \"\": {} }`. The embedded object MUST carry an explicit `type` where the property's scoped `@context` is type-scoped - keyed by the value's `@type` to distinguish several embedded types (or to stamp the node's `rdf:type`) - or where the frame used to reconstruct it matches on `@type`, as a frame derived from the schema's class type does (see [](#framing)). A flat scoped context reconstructed through a property-matching frame needs none. Where required, tooling materializes the type on export (see [](#semantic-type)).", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:179" + }, + { + "id": "OOLD-RT-ad63", + "area": "RT", + "level": "SHOULD NOT", + "applies_to": "document", + "section": "value-forms", + "summary": "A numeric property should not be coerced to a narrower datatype unless the exact RDF datatype matters.", + "text": "A numeric property SHOULD NOT be coerced to a narrower datatype, and its values SHOULD be left as native JSON numbers, unless the exact RDF datatype matters.", + "text_sha256": "0d9c417a64efe4f181ead588ab43f10d045c69269e15bac78256775442cbf94d", + "context": "Datatypes JSON-LD does not produce by default are the ones to declare with `@type`: the date/time family (`xsd:date`, `xsd:dateTime`, `xsd:time`, `xsd:duration`), `xsd:anyURI`, and every numeric refinement outside the two native ones (`xsd:float`, `xsd:decimal`, `xsd:long`, `xsd:int`, `xsd:unsignedByte`, ...). These stay explicit on the literal through the round-trip and compact back onto the term, but JSON has no native syntax for them, so their value is carried as a JSON string. The consequence is worth stating plainly: a JSON-native number can only ever be `xsd:integer` or `xsd:double`; any narrower or more specific numeric datatype is reached by writing the value as a string under an `@type` coercion (a bare JSON number under, say, `@type: \"xsd:float\"` keeps the term but comes back as its canonical string form). A numeric property SHOULD NOT be coerced to a narrower datatype, and its values SHOULD be left as native JSON numbers, unless the exact RDF datatype matters.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:166" + }, + { + "id": "OOLD-RT-d376", + "area": "RT", + "level": "SHOULD", + "applies_to": "document", + "section": "round-trip", + "summary": "The embed graph formed by scoped @contexts should be acyclic.", + "text": "These scoped contexts form an embed graph between schemas, and that graph SHOULD be acyclic: model a property whose value is an independent entity, or whose type would close an embed cycle (a type embedding itself, or two types embedding each other), as a reference - `@type: \"@id\"` plus `x-oold-range`, with no scoped `@context` - rather than an embed.", + "text_sha256": "c8d004af368510bff41449b8c6432b829dcf78f4966aff541cbe7c508a60ed52", + "context": "An embedded object is mapped by a scoped `@context` on its property (referencing the embedded type's own context). These scoped contexts form an embed graph between schemas, and that graph SHOULD be acyclic: model a property whose value is an independent entity, or whose type would close an embed cycle (a type embedding itself, or two types embedding each other), as a reference - `@type: \"@id\"` plus `x-oold-range`, with no scoped `@context` - rather than an embed. This is the linked-data analog of using a pointer instead of inlining a recursive data structure. A self-reference through the top-level `@context` (a property that nests the same type but carries no scoped context, so the global context maps the nested keys - e.g. a `Process` with sub-`Process`es) is not part of this graph and round-trips normally, bounded by the instance's actual depth.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:187" + }, + { + "id": "OOLD-RT-d9bd", + "area": "RT", + "level": "MUST NOT", + "applies_to": "document", + "section": "value-forms", + "summary": "A term must not coerce a literal to a datatype JSON-LD produces by default from a native JSON value (xsd:string, xsd:boolean, xsd:integer, xsd:double).", + "text": "A term MUST NOT declare `@type` with a datatype that JSON-LD produces by default from a native JSON value: `xsd:string` (from a string), `xsd:boolean` (from a boolean), `xsd:integer` (from an integer number), and `xsd:double` (from a fractional number).", + "text_sha256": "b32b762e810a70e38f25329563c43687ce6402c92f464eb8764ccaceb9376c54", + "context": "A term MUST NOT declare `@type` with a datatype that JSON-LD produces by default from a native JSON value: `xsd:string` (from a string), `xsd:boolean` (from a boolean), `xsd:integer` (from an integer number), and `xsd:double` (from a fractional number). These are exactly the datatypes reconstruction converts back to native JSON values without an `@type` (JSONLD11-API, RDF to Object Conversion; see [](#round-trip)): the value arrives from RDF with no datatype, and a term is never selected against a conflicting or absent type mapping (JSONLD11-API, Term Selection), so the value reappears under the full predicate IRI instead. Coercing to one of these is redundant and lossy - a native JSON number already round-trips as `xsd:integer` or `xsd:double` with no coercion at all, and a boolean/string likewise. This is inherent to the compaction algorithm, not a tooling limitation; such terms are left plain (no `@type`), and the projection to RDF still yields the correct datatype from the native JSON type (JSONLD11-API, Data Round Tripping). The behaviour assumes reconstruction with native types (`useNativeTypes`), the mainstream default: a processor that instead keeps every literal as a typed value object would select the coerced term, but then plain native numbers and booleans no longer return as native JSON either (they come back as `{ \"@value\": ..., \"@type\": ... }` objects), which defeats the structural model - so native-type reconstruction is assumed throughout.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "07-schema-instances.md:164" + }, + { + "id": "OOLD-SCH-a9ee", + "area": "SCH", + "level": "MUST NOT", + "applies_to": "implementation", + "section": "basic-concepts", + "summary": "An OO-LD schema document must not be interpreted as a JSON-LD document.", + "text": "OO-LD schema documents MUST NOT be interpreted as JSON-LD documents, because that would apply the schema's own `@context` to the schema itself and produce incorrect triples.", + "text_sha256": "635a77aac991bbe8295616c5465c2963e0d6a51618ed49c2d835d448dc53bfca", + "context": "- An OO-LD schema is consumed as a JSON-LD remote context (referenced by its URL from an instance's `@context`), never as a JSON-LD document. OO-LD schema documents MUST NOT be interpreted as JSON-LD documents, because that would apply the schema's own `@context` to the schema itself and produce incorrect triples.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "05-basic-concepts.md:11" + }, + { + "id": "OOLD-VER-2e63", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "identification", + "summary": "A schema should be resolvable via its $id.", + "text": "The schema SHOULD be resolvable via this URI.", + "text_sha256": "fd7f4ef994bb7fea2782f7c30ee8f8c2a3f9b8161c61ced1f2f038a64f106c2c", + "context": "OO-LD schemas MUST have a `$id` (JSONSCHEMA §8.2.1) which works as a global and unique identifier of the schema. The value of `$id` MAY be an absolute URI (details below). The schema SHOULD be resolvable via this URI. The schema SHOULD have an annotation `x-oold-uuid` with a UUID value.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:5" + }, + { + "id": "OOLD-VER-3662", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "versioning", + "summary": "A schema version should be stated with x-oold-version.", + "text": "The schema version SHOULD be indicated by `x-oold-version`; a prior version MAY be indicated with `x-oold-prior-version`:", + "text_sha256": "7d62b2cbfa7d78f02f91d1e08fa0ac97e07385088b1b5da426e9f46dea77961a", + "context": "The schema version SHOULD be indicated by `x-oold-version`; a prior version MAY be indicated with `x-oold-prior-version`:", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:51" + }, + { + "id": "OOLD-VER-3b96", + "area": "VER", + "level": "MUST", + "applies_to": "document", + "section": "identification", + "summary": "A schema must have a $id serving as its global unique identifier.", + "text": "OO-LD schemas MUST have a `$id` (JSONSCHEMA §8.2.1) which works as a global and unique identifier of the schema.", + "text_sha256": "0489dab8d39ad1fbe8057598def7a10fa00816ece4c6482b9e0de23145c82a3b", + "context": "OO-LD schemas MUST have a `$id` (JSONSCHEMA §8.2.1) which works as a global and unique identifier of the schema. The value of `$id` MAY be an absolute URI (details below). The schema SHOULD be resolvable via this URI. The schema SHOULD have an annotation `x-oold-uuid` with a UUID value.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:5" + }, + { + "id": "OOLD-VER-4261", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "versioning", + "summary": "Under schema-package versioning, the package version should be prepended before the schema id.", + "text": "For schema-package versioning (recommended), the version of the package SHOULD be prepended before the schema's ID, e.g. `https://example.org/my-package/2.0.0/b5203131-7321-46bb-8a11-acb3d1015840.schema.json`.", + "text_sha256": "4e2e5321caa2b8551df124896634f32ecdcd314d40dadde918a6310f297acf1b", + "context": "- For schema-package versioning (recommended), the version of the package SHOULD be prepended before the schema's ID, e.g. `https://example.org/my-package/2.0.0/b5203131-7321-46bb-8a11-acb3d1015840.schema.json`. Since a package combines multiple schemas, the package version does in general not match the individual schema version.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:68" + }, + { + "id": "OOLD-VER-534a", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "versioning", + "summary": "The schema version should be part of the schema location URL.", + "text": "The version SHOULD be part of the schema's location:", + "text_sha256": "9e4671a42c0df7845c72b1cb55c9723532573150183495259835ccfab7f4d6e2", + "context": "The version SHOULD be part of the schema's location:\n- For single-schema versioning, the version SHOULD be appended after the schema name, e.g. `https://example.org/b5203131-7321-46bb-8a11-acb3d1015840.schema.json/1.1.0`.\n- For schema-package versioning (recommended), the version of the package SHOULD be prepended before the schema's ID, e.g. `https://example.org/my-package/2.0.0/b5203131-7321-46bb-8a11-acb3d1015840.schema.json`. Since a package combines multiple schemas, the package version does in general not match the individual schema version.\n- or a release tag on a code-hosting service, e.g. `https://raw.githubusercontent.com/MyOrg/my-package/refs/tags/2.0.0/b5203131-7321-46bb-8a11-acb3d1015840.schema.json`. Such a location SHOULD name an immutable ref: a branch name identifies a moving target, whose content changes with every push, rather than a fixed version.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:65" + }, + { + "id": "OOLD-VER-befc", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "versioning", + "summary": "Under single-schema versioning, the version should be appended after the schema name in the $id.", + "text": "For single-schema versioning, the version SHOULD be appended after the schema name, e.g. `https://example.org/b5203131-7321-46bb-8a11-acb3d1015840.schema.json/1.1.0`.", + "text_sha256": "3e485bd18653ac3e879efe279ac5938bf1e8657575a547e99f5084e3f1cb9089", + "context": "- For single-schema versioning, the version SHOULD be appended after the schema name, e.g. `https://example.org/b5203131-7321-46bb-8a11-acb3d1015840.schema.json/1.1.0`.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:67" + }, + { + "id": "OOLD-VER-c92e", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "versioning", + "summary": "A schema published for long-term reuse should be identified by a persistent identifier that resolves to its current hosting.", + "text": "A raw hosting URL is convenient, but it binds the identifier to one host and one repository layout and carries no persistence guarantee, so a schema published for long-term reuse SHOULD be identified by a persistent identifier - a [w3id.org](https://w3id.org/) or [PURL](https://purl.archive.org/) redirect, or a DOI for a released package - that resolves to wherever the schema is currently hosted.", + "text_sha256": "f7d5ceea2f5d25ad799d9be7b2deadfd850969955652e4225f504a6d8ab1efaf", + "context": "A raw hosting URL is convenient, but it binds the identifier to one host and one repository layout and carries no persistence guarantee, so a schema published for long-term reuse SHOULD be identified by a persistent identifier - a [w3id.org](https://w3id.org/) or [PURL](https://purl.archive.org/) redirect, or a DOI for a released package - that resolves to wherever the schema is currently hosted. The persistent identifier is then the `$id`, and the raw URL is only where it happens to resolve today, so the schema survives a move between hosts without changing identity.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:71" + }, + { + "id": "OOLD-VER-d826", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "versioning", + "summary": "A version carried in a schema location should be pinned to an immutable ref, not a mutable branch.", + "text": "Such a location SHOULD name an immutable ref: a branch name identifies a moving target, whose content changes with every push, rather than a fixed version.", + "text_sha256": "639ac34295b99014677516da3066defd8cf90756ab98b1ccdd0945716b9a72cd", + "context": "- or a release tag on a code-hosting service, e.g. `https://raw.githubusercontent.com/MyOrg/my-package/refs/tags/2.0.0/b5203131-7321-46bb-8a11-acb3d1015840.schema.json`. Such a location SHOULD name an immutable ref: a branch name identifies a moving target, whose content changes with every push, rather than a fixed version.", + "machine_checkable": false, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:69" + }, + { + "id": "OOLD-VER-edb9", + "area": "VER", + "level": "SHOULD", + "applies_to": "document", + "section": "identification", + "summary": "A schema should carry an x-oold-uuid annotation holding a UUID value.", + "text": "The schema SHOULD have an annotation `x-oold-uuid` with a UUID value.", + "text_sha256": "0d1901b754364d33a17411f33fe61ba98d469d1df7389adab8f0a44c9276f355", + "context": "OO-LD schemas MUST have a `$id` (JSONSCHEMA §8.2.1) which works as a global and unique identifier of the schema. The value of `$id` MAY be an absolute URI (details below). The schema SHOULD be resolvable via this URI. The schema SHOULD have an annotation `x-oold-uuid` with a UUID value.", + "machine_checkable": true, + "since": "1.0.0-rc.1", + "deprecated": false, + "source": "08-identification-versioning.md:5" + } + ] +} diff --git a/src/oold/validation/meta/1.0.0-rc.2/oold-rules.schema.json b/src/oold/validation/meta/1.0.0-rc.2/oold-rules.schema.json new file mode 100644 index 0000000..755d6dc --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.2/oold-rules.schema.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-rules.schema.json", + "title": "OO-LD rule catalog", + "$comment": "Describes meta/oold-rules.json, which scripts/extract_rules.py generates from the :rule[...] markers in spec/sections/*.md. The catalog is data that downstream validators read to decide which requirements exist and how hard a violation lands, so a truncated or malformed copy does not fail loudly on its own: it just looks like a specification with fewer rules. This schema is what turns that into an error.", + "type": "object", + "required": [ + "spec_version", + "rules" + ], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "format": "iri-reference", + "description": "This document's schema. Released copies stamp their version in place of `latest`." + }, + "$comment": { + "type": "string" + }, + "spec_version": { + "$ref": "#/$defs/version", + "description": "The specification release this catalog was generated from. Moves with every tag, unlike a rule's `since`." + }, + "areas": { + "type": "object", + "description": "Area code to human-readable scope. Every rule's `area` is one of these keys.", + "propertyNames": { + "$ref": "#/$defs/area" + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "applies_to": { + "type": "object", + "description": "Binding to what enforcing it would take. Every rule's `applies_to` is one of these keys.", + "propertyNames": { + "$ref": "#/$defs/binding" + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/rule" + } + } + }, + "$defs": { + "area": { + "enum": [ + "CNF", + "SCH", + "CMP", + "INS", + "RT", + "VER", + "EXT" + ] + }, + "binding": { + "enum": [ + "document", + "implementation", + "advisory" + ] + }, + "version": { + "type": "string", + "minLength": 1 + }, + "rule": { + "type": "object", + "required": [ + "id", + "area", + "level", + "applies_to", + "section", + "summary", + "text", + "text_sha256", + "machine_checkable", + "since", + "deprecated", + "source" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^OOLD-(CNF|SCH|CMP|INS|RT|VER|EXT)-[0-9a-f]{4}$", + "description": "Permanent and never reused. The suffix is a minted hex value, not a sequential number, so a deprecated rule leaves no visible gap. Downstream checks cite the id, so the pattern is asserted rather than assumed; see meta/RULES.md." + }, + "area": { + "$ref": "#/$defs/area" + }, + "level": { + "enum": [ + "MUST", + "MUST NOT", + "SHALL", + "SHALL NOT", + "SHOULD", + "SHOULD NOT", + "REQUIRED", + "RECOMMENDED" + ], + "description": "The RFC 2119 keyword in the marked prose. A validator reads this to decide whether a violation fails or warns, and never hardcodes it." + }, + "applies_to": { + "$ref": "#/$defs/binding" + }, + "section": { + "type": "string", + "minLength": 1 + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "The normative prose itself, cleaned of markup." + }, + "text_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "sha256 of `text`. meta/rules-baseline.json compares against this to catch a rule whose meaning changed under an unchanged id, so its shape is asserted here." + }, + "context": { + "type": "string", + "minLength": 1, + "description": "The containing block `text` was taken from, for display. May equal `text` when the rule's sentence is the whole block. Not hashed, and not part of the baseline comparison." + }, + "machine_checkable": { + "type": "boolean", + "description": "Whether the requirement is mechanically decidable by inspecting a document. Defaults to true for `document` rules only. This says nothing about whether any given validator actually enforces it - that is a separate, downstream fact." + }, + "since": { + "$ref": "#/$defs/version", + "description": "The release that first stated this rule. Carried forward once recorded; only an unseen id takes the current tag." + }, + "deprecated": { + "type": "boolean" + }, + "superseded_by": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^OOLD-(CNF|SCH|CMP|INS|RT|VER|EXT)-[0-9a-f]{4}$" + }, + "description": "Present only on a deprecated rule, naming what replaced it." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Where the marker sits, as `
:`. Regenerated on every run, so it is provenance rather than a stable reference." + } + } + } + } +} diff --git a/src/oold/validation/meta/1.0.0-rc.2/oold-ui-meta-schema.json b/src/oold/validation/meta/1.0.0-rc.2/oold-ui-meta-schema.json new file mode 100644 index 0000000..1d5e981 --- /dev/null +++ b/src/oold/validation/meta/1.0.0-rc.2/oold-ui-meta-schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oo-ld.org/latest/meta/oold-ui-meta-schema.json", + "$dynamicAnchor": "meta", + "title": "OO-LD UI dialect meta-schema", + "$comment": "The $id uses the versioned hosting at oo-ld.org/ (the source keeps the /latest/ placeholder; each released copy is stamped per release). The oold-ui vocabulary is declared optional (false) so that generic JSON-Schema 2020-12 validators still process the schema. The x-oold-ui-* keyword definitions live in $defs.keywords (plain anchor #keywords) so the main OO-LD meta-schema can include just them, without re-introducing the 2020-12 reference or a second dynamic anchor. Each keyword carries a description and an example so the vocabulary can be rendered into documentation. As with the core dialect, this meta-schema only validates that the keywords are well-formed; the behaviour is supplied by OO-LD-aware form generators (for example jedison).", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://oo-ld.org/latest/vocab/oold-ui": false + }, + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + { "$ref": "#keywords" } + ], + "$defs": { + "keywords": { + "$anchor": "keywords", + "properties": { + "x-oold-ui-widget": { + "description": "Widget hint for a value whose intended widget is not a registered JSON Schema format (for example table, tabs, grid, autocomplete, textarea, checkbox, markdown, color). Registered formats (date, uri, uuid, ...) stay in `format`. Maps to jedison `x-format`.", + "type": "string", + "examples": ["table", "autocomplete", "markdown"] + }, + "x-oold-ui-property-order": { + "description": "Display order of this property within its object or group; lower sorts first. Maps to jedison `x-categoryOrder`.", + "type": "integer", + "examples": [1] + }, + "x-oold-ui-property-group": { + "description": "Name of the group, tab or category this property belongs to. Maps to jedison `x-category` / `x-propGroup`.", + "type": "string", + "examples": ["General", "Contact"] + }, + "x-oold-ui-form-hidden": { + "description": "Hide this property in the editing form. Maps to jedison `x-hidden`.", + "type": "boolean", + "examples": [true] + }, + "x-oold-ui-render-hidden": { + "description": "Hide this property in the rendered (read) view.", + "type": "boolean", + "examples": [true] + }, + "x-oold-ui-enum-titles": { + "description": "Human display labels for the default language, aligned positionally with `enum`: the Nth label is the title of the Nth enum value. For `enum: [\"pi\", \"postdoc\", \"phd\"]` the value `[\"Principal investigator\", \"Postdoc\", \"PhD student\"]` labels each option. Localize with `x-oold-multilang-ui-enum-titles`. Distinct from the identifier-safe code names in `x-enum-varnames`. Maps to jedison `x-enumTitles`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["Principal investigator", "Postdoc", "PhD student"]] + }, + "x-oold-multilang-ui-enum-titles": { + "description": "BCP-47 language map of `x-oold-ui-enum-titles` arrays (mirrors `x-oold-multilang-title`); each array aligns positionally with `enum`. For `enum: [\"pi\", \"postdoc\", \"phd\"]`: {\"en\": [\"Principal investigator\", \"Postdoc\", \"PhD student\"], \"de\": [\"Projektleitung\", \"Postdoc\", \"Doktorand\"]}.", + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "string" } }, + "examples": [{ "en": ["Principal investigator", "Postdoc", "PhD student"], "de": ["Projektleitung", "Postdoc", "Doktorand"] }] + }, + "x-oold-ui-hint": { + "description": "Short help text shown with the field, in the default language. Localize with `x-oold-multilang-ui-hint`. Maps to jedison `x-info`.", + "type": "string", + "examples": ["Full name"] + }, + "x-oold-multilang-ui-hint": { + "description": "BCP-47 language map of the `x-oold-ui-hint` text (mirrors `x-oold-multilang-title`).", + "type": "object", + "additionalProperties": { "type": "string" }, + "examples": [{ "en": "Full name", "de": "Vollständiger Name" }] + }, + "x-oold-ui-default-property": { + "description": "Whether this optional property is shown by default in generated user interfaces. Replaces the object-level `defaultProperties` array: a per-property boolean is overridable under composition (most-derived-wins), so a derived schema can set it false, whereas the merged array form was extend-only.", + "type": "boolean", + "examples": [true] + }, + "x-enum-varnames": { + "description": "Identifier-safe code names aligned positionally with `enum`, for code generation. For `enum: [\"m\", \"s\"]` the value `[\"metre\", \"second\"]` names each option (so a generator can emit `Unit.metre` instead of `Unit.m`). An established vendor extension (OpenAPI Generator; NSwag uses the camelCase `x-enumNames`). Kept as-is; distinct from the human labels in `x-oold-ui-enum-titles`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["metre", "second"]] + }, + "x-enum-descriptions": { + "description": "Per-value descriptions aligned positionally with `enum`, the established companion of `x-enum-varnames`. For `enum: [\"m\", \"s\"]`: `[\"SI base unit of length\", \"SI base unit of time\"]`.", + "type": "array", + "items": { "type": "string" }, + "examples": [["SI base unit of length", "SI base unit of time"]] + } + } + } + } +} diff --git a/src/oold/validation/meta/README.md b/src/oold/validation/meta/README.md index 8ccd9ef..e7dab54 100644 --- a/src/oold/validation/meta/README.md +++ b/src/oold/validation/meta/README.md @@ -10,6 +10,7 @@ meta/ ├── 0.7.0/ oold-meta-schema.json, oold-pattern-lint.schema.json, oold-ui-meta-schema.json ├── 0.8.0/ the same three files ├── 1.0.0-rc.1/ those three, plus oold-rules.json and the oold-rules.schema.json describing it +├── 1.0.0-rc.2/ those five, plus oold-meta-schema-base.json, the body the dialect now $refs └── / ``` @@ -19,20 +20,19 @@ a derived fact only rots: this line used to name 0.8.0 and was still naming it t ## The file list is per source, not global -`index.json`'s top-level `files` is the *shared default* file set - the three meta-schemas every -tracked version ships today. `meta_files(source)` reads it for a tracked version, or for `remote`, -but a source can override it with its own `files` entry when its set actually differs. `remote` -already does: unreleased `main` split the dialect meta-schema into a wrapper -(`oold-meta-schema.json`, document-level obligations) and a body it `$ref`s -(`oold-meta-schema-base.json`, the keyword syntax), so `remote.files` names four files instead of -the shared three. No tracked version has that split, so none is made to load a file it does not -have; `remote.files` is declared once, in `index.json`, rather than in code. - -A future release that ships the same split (or any other file-set change) declares it the same -way: add a `files` list to that version's own entry under `versions`, naming exactly what it -ships. Omit it, and the version falls back to the shared default. A file a source's list names but -does not have is still a load error, not a silent skip - drift here is exactly what this is meant -to catch. +`index.json`'s top-level `files` is the *shared default* file set - the three meta-schemas the +older tracked versions ship. `meta_files(source)` reads it for a tracked version, or for `remote`, +but a source can override it with its own `files` entry when its set actually differs. Two sources +do. `1.0.0-rc.2` split the dialect meta-schema into a wrapper (`oold-meta-schema.json`, +document-level obligations) and a body it `$ref`s (`oold-meta-schema-base.json`, the keyword +syntax), so both that version and `remote` name four files instead of the shared three. The older +versions predate the split and are not made to load a file they do not have. Each list is declared +once, in `index.json`, rather than in code. + +Any further file-set change is declared the same way: add a `files` list to that version's own +entry under `versions`, naming exactly what it ships. Omit it, and the version falls back to the +shared default. A file a source's list names but does not have is still a load error, not a silent +skip - drift here is exactly what this is meant to catch. Nothing here is written at runtime. `--meta remote` fetches the unreleased `main` state into the user cache (`~/.cache/oold/meta/`, or `OOLD_CACHE_DIR`) and never touches this folder, so a released @@ -45,19 +45,26 @@ When oold-schema cuts a release, from a checkout of it: ```bash V=1.0.0 mkdir -p src/oold/validation/meta/$V -for f in oold-meta-schema oold-pattern-lint.schema oold-ui-meta-schema oold-rules oold-rules.schema; do - git -C ../oold-schema show v$V:meta/$f.json > src/oold/validation/meta/$V/$f.json +for f in oold-meta-schema oold-meta-schema-base oold-pattern-lint.schema oold-ui-meta-schema oold-rules oold-rules.schema; do + git -C ../oold-schema cat-file blob v$V:meta/$f.json > src/oold/validation/meta/$V/$f.json done sha256sum src/oold/validation/meta/$V/*.json git -C ../oold-schema rev-parse v$V git -C ../oold-schema log -1 --format=%cI v$V ``` -**Five files, not three.** `oold-rules.json` is the catalogue of normative statements and -`oold-rules.schema.json` describes it; both arrived in 1.0.0-rc.1. A version predating them ships -only the first three, so drop the last two from the loop for such a version. Listing only the three -meta-schemas here once cost a vendoring the catalogue entirely, which is silent: findings simply -stop citing rules and every `rule.*` check skips as though the version had stated nothing. +`cat-file blob`, not `show`: `show` applies the checkout's end-of-line conversion, so on Windows it +writes CRLF, which changes every digest and fails only once it reaches Linux CI. + +**Check what the release actually ships before running the loop.** The set has grown twice. +`oold-rules.json`, the catalogue of normative statements, and `oold-rules.schema.json`, which +describes it, arrived in 1.0.0-rc.1; `oold-meta-schema-base.json` arrived in 1.0.0-rc.2, when the +dialect split into a wrapper and the body it `$ref`s. Drop from the loop whatever a given version +predates, and name the set in that version's own `files` entry when it differs from the shared +default. Listing only the three meta-schemas here once cost a vendoring the catalogue entirely, +which is silent: findings simply stop citing rules and every `rule.*` check skips as though the +version had stated nothing. Omitting the base is not silent, but it fails obscurely, as an +unresolvable `$ref` rather than a missing file. Extract from the **tag**, not from the working tree. The two diverge: at the time 0.7.0 was added, `main` had already changed all three files, including the canonical `$id` domain. diff --git a/src/oold/validation/meta/index.json b/src/oold/validation/meta/index.json index 6596a19..06c91d0 100644 --- a/src/oold/validation/meta/index.json +++ b/src/oold/validation/meta/index.json @@ -4,7 +4,7 @@ "remote": { "ref": "refs/heads/main", "base_url": "https://raw.githubusercontent.com/OO-LD/oold-schema/refs/heads/main/meta/", - "$comment": "files overrides the top-level default for this source only. main split the dialect meta-schema into a wrapper (oold-meta-schema.json, document-level obligations) and a body ($ref'd from it, oold-meta-schema-base.json, the keyword syntax and $dynamicAnchor). No released version has that split, so it is declared here rather than in the shared default, which tracked versions still read unchanged.", + "$comment": "files overrides the top-level default for this source only. main split the dialect meta-schema into a wrapper (oold-meta-schema.json, document-level obligations) and a body ($ref'd from it, oold-meta-schema-base.json, the keyword syntax and $dynamicAnchor). v1.0.0-rc.2 released that split and declares the same four files in its own entry; the shared default below stays at three, for the versions that predate it.", "files": [ "oold-meta-schema.json", "oold-meta-schema-base.json", @@ -50,14 +50,14 @@ "added": "2026-08-04", "id_base": "https://oo-ld.org/latest/meta/", "prerelease": true, - "notes": "First version to carry oold-rules.json, the catalogue of normative statements, and oold-rules.schema.json, which describes it. The three meta-schemas are verbatim from the v1.0.0-rc.1 tag. The catalogue is provisional: no release has shipped one yet, so it comes from an unmerged oold-schema branch and rules_source records which commit. This refresh brought sentence-scoped `text` with the surrounding block kept as `context`, the `checkable` to `machine_checkable` rename, and the rules split out of lead-in lists. The v1.0.0-rc.1 tag itself will never gain a catalogue, so there is nothing here to refresh from a tag. When a release does ship one, vendor that version as its own entry rather than back-filling this one.", + "notes": "First version to carry oold-rules.json, the catalogue of normative statements, and oold-rules.schema.json, which describes it. The three meta-schemas are verbatim from the v1.0.0-rc.1 tag. The catalogue is provisional: no release has shipped one yet, so it comes from an unmerged oold-schema branch and rules_source records which commit. This refresh brought sentence-scoped `text` with the surrounding block kept as `context`, the `checkable` to `machine_checkable` rename, and the rules split out of lead-in lists. The v1.0.0-rc.1 tag itself will never gain a catalogue, so there is nothing here to refresh from a tag. When a release does ship one, vendor that version as its own entry rather than back-filling this one. v1.0.0-rc.2 did, and is vendored below, so this copy is now a record of the catalogue as it stood before that release rather than the one to reach for.", "rules_source": { "$comment": "Where this catalogue was taken from. `pr` is the durable reference: oold-schema rebases a branch when it merges, so `commit` names a pre-merge SHA that stops existing on main - the entry this replaced pointed at exactly such an orphan. Once the pull request merges, set `merged` to its commit on main and leave `pr` as the record of where it came from.", "pr": 124, "repository": "https://github.com/OO-LD/oold-schema", "branch": "feat/rule-list-scope", "commit": "30534ecbb3956afb1022e09c99d2aeec0737ea3c", - "merged": null, + "merged": "b0a1235a2f1c54a3231d2001be749469c5775b42", "released": false }, "sha256": { @@ -67,11 +67,34 @@ "oold-rules.json": "4c96768fec8ee16cc9337eeb055fd775f31af6c4c3d3c1c86caa75b0e4880bca", "oold-rules.schema.json": "71e0d2e437d05a0a718612ed273993c3e216681c6c4cd426a6b3c1018f07e07a" } + }, + "1.0.0-rc.2": { + "tag": "v1.0.0-rc.2", + "commit": "84c70446d94227b7e626f14adeb62c8c27050276", + "committed": "2026-08-15T10:58:56+02:00", + "added": "2026-08-15", + "id_base": "https://oo-ld.org/latest/meta/", + "prerelease": true, + "notes": "First released version to carry the rule catalogue. The entry above holds a pre-release copy taken from a branch, because the v1.0.0-rc.1 tag never gained one; every file here is verbatim from the tag, so this entry needs no rules_source. Also the first release to split the dialect meta-schema: oold-meta-schema.json is now a wrapper holding the document-level obligations, and it $refs oold-meta-schema-base.json, which holds the keyword syntax and the $dynamicAnchor that nested subschemas reach through $dynamicRef. A document must therefore carry $id while the subschemas inside it need not, which the single-file form could not express, and that is why this entry declares its own files list. The catalogue grew from 43 rules to 66 with no id reused and none retired; of the 43 already vendored, none changed level, applies or text, and three (OOLD-VER-befc, OOLD-VER-4261, OOLD-EXT-1f92) changed machine_checkable to false after being found undecidable. oold-pattern-lint.schema.json and oold-ui-meta-schema.json are byte-identical to v1.0.0-rc.1.", + "files": [ + "oold-meta-schema.json", + "oold-meta-schema-base.json", + "oold-pattern-lint.schema.json", + "oold-ui-meta-schema.json" + ], + "sha256": { + "oold-meta-schema.json": "7960a4508f8688b74b3096d7ac0828c9fe74089692d370c756eeef58b8785606", + "oold-meta-schema-base.json": "d6a42296f13dd5c52e9f4873234901b077e2d9f9f082bb48d1837b8b63e52ef3", + "oold-pattern-lint.schema.json": "d89fce19cd2fd42fa740d92968fcf61a1764ea25e741ed5cd4e72040a45c9a86", + "oold-ui-meta-schema.json": "dd389d13a5e03268d4a4ff845dec7f4f28238f7edbd9fe0992399b37ac358212", + "oold-rules.json": "bf79dffda89063865ff8b7763576fd880655dd636e8720701db3545f27759536", + "oold-rules.schema.json": "71e0d2e437d05a0a718612ed273993c3e216681c6c4cd426a6b3c1018f07e07a" + } } }, "fixtures": { "$comment": "Provenance of the fixture slice in tests/data/oold/, which is a copy of the upstream examples/ directory. Recorded here rather than stated in that folder's README, because a tag is data: the README claimed v0.8.0 for a full release after the slice had moved to v1.0.0-rc.1, and nothing noticed. `tag` must name the newest entry in `versions`, so that fixtures and meta-schemas always come from one release; a test asserts it. The locally authored fixtures under broken/ and remote_context/ are not part of this slice and no refresh touches them.", - "tag": "v1.0.0-rc.1", + "tag": "v1.0.0-rc.2", "source": "examples/", "destination": "tests/data/oold/" } diff --git a/tests/data/oold/OwlOrganization.schema.json b/tests/data/oold/OwlOrganization.schema.json index ead6bee..8bb9b61 100644 --- a/tests/data/oold/OwlOrganization.schema.json +++ b/tests/data/oold/OwlOrganization.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "OwlOrganization.schema.json", - "x-sssom": { "schema:Organization": { "predicate_id": "skos:exactMatch" } }, + "x-oold-sssom": { "schema:Organization": { "predicate_id": "skos:exactMatch" } }, "@context": { "id": "@id", "type": "@type", diff --git a/tests/data/oold/README.md b/tests/data/oold/README.md index fc4e93f..c7a444e 100644 --- a/tests/data/oold/README.md +++ b/tests/data/oold/README.md @@ -40,14 +40,20 @@ the *same tag* so the two stay in step, then record that tag as `fixtures.tag` i V=$(uv run python -c "from oold.validation.meta_store import latest_version; print(latest_version())") DEST=tests/data/oold for f in $(git -C ../oold-schema ls-tree --name-only v$V examples/ | grep '\.json$'); do - git -C ../oold-schema show "v$V:$f" > "$DEST/$(basename $f)" + git -C ../oold-schema cat-file blob "v$V:$f" > "$DEST/$(basename $f)" done for f in $(git -C ../oold-schema ls-tree --name-only v$V examples/compliance/); do - git -C ../oold-schema show "v$V:$f" > "$DEST/compliance/$(basename $f)" + git -C ../oold-schema cat-file blob "v$V:$f" > "$DEST/compliance/$(basename $f)" done make validate ``` +`cat-file blob` rather than `show`, for the same reason the meta-schemas use it: `show` applies the +checkout's end-of-line conversion and writes CRLF on Windows. + +Upstream `examples/` also has a `spec/` subdirectory. It is deliberately outside this slice, which +is the top level plus `compliance/`, so the loops above do not descend into it. + ## Broken fixtures Each one exists to prove a specific check fires, rather than only that valid input passes. diff --git a/tests/data/oold/RdfPerson.schema.json b/tests/data/oold/RdfPerson.schema.json index 1d4222e..3877704 100644 --- a/tests/data/oold/RdfPerson.schema.json +++ b/tests/data/oold/RdfPerson.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "$id": "RdfPerson.schema.json", - "x-sssom": { "schema:Person": { "predicate_id": "skos:exactMatch" } }, + "x-oold-sssom": { "schema:Person": { "predicate_id": "skos:exactMatch" } }, "@context": { "id": "@id", "type": "@type", diff --git a/tests/data/oold/UiAnnotations.schema.json b/tests/data/oold/UiAnnotations.schema.json index 9fa8952..69a5fb8 100644 --- a/tests/data/oold/UiAnnotations.schema.json +++ b/tests/data/oold/UiAnnotations.schema.json @@ -15,7 +15,7 @@ "x-oold-uuid": "b1e7a0c2-2d4f-4a1e-9c3a-7f0e5d2b6a11", "title": "Researcher", "x-oold-multilang-title": { "en": "Researcher", "de": "Forschende Person" }, - "x-sssom": { "schema:Person": { "predicate_id": "skos:exactMatch" } }, + "x-oold-sssom": { "schema:Person": { "predicate_id": "skos:exactMatch" } }, "type": "object", "properties": { "name": { diff --git a/tests/data/oold/compliance/jsonld-features.json b/tests/data/oold/compliance/jsonld-features.json index a4ded92..211a47f 100644 --- a/tests/data/oold/compliance/jsonld-features.json +++ b/tests/data/oold/compliance/jsonld-features.json @@ -6,11 +6,19 @@ "tests": [ { "description": "name (from Thing), works_for (from Person) and affiliation (from Researcher) all resolve via the inherited contexts", - "data": { "@context": "Researcher.schema.json", "$schema": "Researcher.schema.json", "id": "https://example.org/alice", "name": "Alice", "works_for": "https://example.org/acme", "affiliation": "https://example.org/uni" }, + "data": { + "@context": "Researcher.schema.json", + "$schema": "Researcher.schema.json", + "id": "https://example.org/alice", + "name": "Alice", + "works_for": "https://example.org/acme", + "affiliation": "https://example.org/uni" + }, "valid": true, "expectRdf": " .\n \"Alice\" .\n .\n" } - ] + ], + "rule": "OOLD-CMP-e4a3" }, { "feature": "composition: a property $ref pulls in the referenced schema's @context as a property-scoped context (Organization.address -> Address)", @@ -18,10 +26,20 @@ "tests": [ { "description": "the nested address expands with Address's scoped context (country -> schema:addressCountry, streetAddress -> schema:streetAddress)", - "data": { "@context": "Organization.schema.json", "$schema": "Organization.schema.json", "id": "https://example.org/acme", "name": "ACME", "address": { "country": "DE", "streetAddress": "Main St 1" } }, + "data": { + "@context": "Organization.schema.json", + "$schema": "Organization.schema.json", + "id": "https://example.org/acme", + "name": "ACME", + "address": { + "country": "DE", + "streetAddress": "Main St 1" + } + }, "valid": true, "expectRdf": " _:b0 .\n \"ACME\" .\n_:b0 \"DE\" .\n_:b0 \"Main St 1\" .\n" } - ] + ], + "rule": "OOLD-CMP-b926" } ] diff --git a/tests/data/oold/compliance/oold-vocab.json b/tests/data/oold/compliance/oold-vocab.json index 99c254a..823e03c 100644 --- a/tests/data/oold/compliance/oold-vocab.json +++ b/tests/data/oold/compliance/oold-vocab.json @@ -8,37 +8,95 @@ "valid": true, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-0-0.schema.json", + "@context": { + "schema": "http://schema.org/", + "name": "schema:name" + }, "x-oold-uuid": "b5203131-7321-46bb-8a11-acb3d1015840", "x-oold-version": "1.0.0", "x-oold-prior-version": "0.9.0", "x-oold-backward-compatible-with": "0.9.0/Person.schema.json", "x-oold-incompatible-with": "0.8.0/Person.schema.json", - "x-sssom": { "schema:Person": { "predicate_id": "skos:exactMatch" } }, - "x-oold-instance-rdf-type": ["schema:Person"], - "x-oold-multilang-title": { "en": "Person", "de": "Person" }, - "x-oold-multilang-description": { "en": "A person", "de": "Eine Person" }, - "x-oold-context": { "name": { "skos:prefLabel": {} } }, - "x-oold-reverse-properties": { "employees": { "type": "array", "title": "Employees" } }, - "x-oold-reverse-required": ["employees"], - "x-oold-reverse-default-properties": ["employees"], + "x-oold-sssom": { + "schema:Person": { + "predicate_id": "skos:exactMatch" + } + }, + "x-oold-instance-rdf-type": [ + "schema:Person" + ], + "x-oold-multilang-title": { + "en": "Person", + "de": "Person" + }, + "x-oold-multilang-description": { + "en": "A person", + "de": "Eine Person" + }, + "x-oold-context": { + "name": { + "skos:prefLabel": {} + } + }, + "x-oold-reverse-properties": { + "employees": { + "type": "array", + "title": "Employees" + } + }, + "x-oold-reverse-required": [ + "employees" + ], + "x-oold-reverse-default-properties": [ + "employees" + ], "type": "object", "properties": { - "ref": { "type": "string", "x-oold-range": { "allOf": [{ "x-oold-ref": "Person.schema.json" }] } }, + "ref": { + "type": "string", + "x-oold-range": { + "allOf": [ + { + "x-oold-ref": "Person.schema.json" + } + ] + } + }, "role": { "type": "string", - "enum": ["pi", "postdoc"], + "enum": [ + "pi", + "postdoc" + ], "x-oold-ui-widget": "select", "x-oold-ui-property-order": 1, "x-oold-ui-property-group": "General", "x-oold-ui-form-hidden": false, "x-oold-ui-render-hidden": false, - "x-oold-ui-enum-titles": ["PI", "Postdoc"], - "x-oold-multilang-ui-enum-titles": { "en": ["PI", "Postdoc"] }, + "x-oold-ui-enum-titles": [ + "PI", + "Postdoc" + ], + "x-oold-multilang-ui-enum-titles": { + "en": [ + "PI", + "Postdoc" + ] + }, "x-oold-ui-hint": "Role in the project", - "x-oold-multilang-ui-hint": { "en": "Role in the project" }, + "x-oold-multilang-ui-hint": { + "en": "Role in the project" + }, "x-oold-ui-default-property": true, - "x-enum-varnames": ["PrincipalInvestigator", "PostDoc"], - "x-enum-descriptions": ["Leads the project", "Holds a doctorate"] + "x-enum-varnames": [ + "PrincipalInvestigator", + "PostDoc" + ], + "x-enum-descriptions": [ + "Leads the project", + "Holds a doctorate" + ] } } } @@ -48,38 +106,254 @@ { "description": "core x-oold-* keywords reject malformed values", "schemas": [ - { "description": "x-oold-uuid not a uuid", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-uuid": "nope" } }, - { "description": "x-oold-version not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-version": 1 } }, - { "description": "x-oold-prior-version not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-prior-version": 1 } }, - { "description": "x-oold-backward-compatible-with not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-backward-compatible-with": 1 } }, - { "description": "x-oold-incompatible-with not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-incompatible-with": 1 } }, - { "description": "x-sssom not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-sssom": 1 } }, - { "description": "x-oold-instance-rdf-type not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-instance-rdf-type": "schema:Person" } }, - { "description": "x-oold-ref not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ref": 1 } }, - { "description": "x-oold-range as a number", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-range": 42 } }, - { "description": "x-oold-multilang-title not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-multilang-title": "Person" } }, - { "description": "x-oold-multilang-description not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-multilang-description": "a person" } }, - { "description": "x-oold-context not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-context": "x" } }, - { "description": "x-oold-reverse-properties not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-reverse-properties": "x" } }, - { "description": "x-oold-reverse-required not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-reverse-required": "x" } }, - { "description": "x-oold-reverse-default-properties not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-reverse-default-properties": "x" } } + { + "description": "x-oold-uuid not a uuid", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-0.schema.json", + "x-oold-uuid": "nope" + } + }, + { + "description": "x-oold-version not a string", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-1.schema.json", + "x-oold-version": 1 + } + }, + { + "description": "x-oold-prior-version not a string", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-2.schema.json", + "x-oold-prior-version": 1 + } + }, + { + "description": "x-oold-backward-compatible-with not a string", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-3.schema.json", + "x-oold-backward-compatible-with": 1 + } + }, + { + "description": "x-oold-incompatible-with not a string", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-4.schema.json", + "x-oold-incompatible-with": 1 + } + }, + { + "description": "x-oold-sssom not an object", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-5.schema.json", + "x-oold-sssom": 1 + } + }, + { + "description": "x-oold-instance-rdf-type not an array", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-6.schema.json", + "x-oold-instance-rdf-type": "schema:Person" + } + }, + { + "description": "x-oold-ref not a string", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-7.schema.json", + "x-oold-ref": 1 + } + }, + { + "description": "x-oold-range as a number", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-8.schema.json", + "x-oold-range": 42 + } + }, + { + "description": "x-oold-multilang-title not an object", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-9.schema.json", + "x-oold-multilang-title": "Person" + } + }, + { + "description": "x-oold-multilang-description not an object", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-10.schema.json", + "x-oold-multilang-description": "a person" + } + }, + { + "description": "x-oold-context not an object", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-11.schema.json", + "x-oold-context": "x" + } + }, + { + "description": "x-oold-reverse-properties not an object", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-12.schema.json", + "x-oold-reverse-properties": "x" + } + }, + { + "description": "x-oold-reverse-required not an array", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-13.schema.json", + "x-oold-reverse-required": "x" + } + }, + { + "description": "x-oold-reverse-default-properties not an array", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-1-14.schema.json", + "x-oold-reverse-default-properties": "x" + } + } ] }, { "description": "UI x-oold-ui-* / x-enum-* keywords reject malformed values", "schemas": [ - { "description": "x-oold-ui-widget not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-widget": 1 } }, - { "description": "x-oold-ui-property-order not an integer", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-property-order": "first" } }, - { "description": "x-oold-ui-property-group not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-property-group": 1 } }, - { "description": "x-oold-ui-form-hidden not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-form-hidden": "x" } }, - { "description": "x-oold-ui-render-hidden not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-render-hidden": "x" } }, - { "description": "x-oold-ui-enum-titles not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-enum-titles": "x" } }, - { "description": "x-oold-multilang-ui-enum-titles not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-multilang-ui-enum-titles": "x" } }, - { "description": "x-oold-ui-hint not a string", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-hint": 1 } }, - { "description": "x-oold-multilang-ui-hint not an object", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-multilang-ui-hint": 1 } }, - { "description": "x-oold-ui-default-property not a boolean", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-oold-ui-default-property": "x" } }, - { "description": "x-enum-varnames not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-enum-varnames": "x" } }, - { "description": "x-enum-descriptions not an array", "valid": false, "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", "x-enum-descriptions": "x" } } + { + "description": "x-oold-ui-widget not a string", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-0.schema.json", + "x-oold-ui-widget": 1 + } + }, + { + "description": "x-oold-ui-property-order not an integer", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-1.schema.json", + "x-oold-ui-property-order": "first" + } + }, + { + "description": "x-oold-ui-property-group not a string", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-2.schema.json", + "x-oold-ui-property-group": 1 + } + }, + { + "description": "x-oold-ui-form-hidden not a boolean", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-3.schema.json", + "x-oold-ui-form-hidden": "x" + } + }, + { + "description": "x-oold-ui-render-hidden not a boolean", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-4.schema.json", + "x-oold-ui-render-hidden": "x" + } + }, + { + "description": "x-oold-ui-enum-titles not an array", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-5.schema.json", + "x-oold-ui-enum-titles": "x" + } + }, + { + "description": "x-oold-multilang-ui-enum-titles not an object", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-6.schema.json", + "x-oold-multilang-ui-enum-titles": "x" + } + }, + { + "description": "x-oold-ui-hint not a string", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-7.schema.json", + "x-oold-ui-hint": 1 + } + }, + { + "description": "x-oold-multilang-ui-hint not an object", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-8.schema.json", + "x-oold-multilang-ui-hint": 1 + } + }, + { + "description": "x-oold-ui-default-property not a boolean", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-9.schema.json", + "x-oold-ui-default-property": "x" + } + }, + { + "description": "x-enum-varnames not an array", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-10.schema.json", + "x-enum-varnames": "x" + } + }, + { + "description": "x-enum-descriptions not an array", + "valid": false, + "schema": { + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "https://oo-ld.test/compliance/vocab-2-11.schema.json", + "x-enum-descriptions": "x" + } + } ] } ] diff --git a/tests/data/oold/compliance/roundtrip-patterns.json b/tests/data/oold/compliance/roundtrip-patterns.json index e1df254..4c9ad88 100644 --- a/tests/data/oold/compliance/roundtrip-patterns.json +++ b/tests/data/oold/compliance/roundtrip-patterns.json @@ -9,7 +9,9 @@ "schema": { "@context": { "schema": "http://schema.org/", - "address": { "@id": "schema:address" } + "address": { + "@id": "schema:address" + } } } }, @@ -20,7 +22,10 @@ "@context": { "schema": "http://schema.org/", "xsd": "http://www.w3.org/2001/XMLSchema#", - "founded": { "@id": "schema:foundingDate", "@type": "xsd:date" } + "founded": { + "@id": "schema:foundingDate", + "@type": "xsd:date" + } } } }, @@ -31,7 +36,10 @@ "@context": { "schema": "http://schema.org/", "xsd": "http://www.w3.org/2001/XMLSchema#", - "weight": { "@id": "schema:weight", "@type": "xsd:float" } + "weight": { + "@id": "schema:weight", + "@type": "xsd:float" + } } } }, @@ -42,7 +50,10 @@ "@context": { "schema": "http://schema.org/", "xsd": "http://www.w3.org/2001/XMLSchema#", - "address": { "@id": "schema:address", "@type": "xsd:string" } + "address": { + "@id": "schema:address", + "@type": "xsd:string" + } } } }, @@ -55,7 +66,10 @@ "address": { "@id": "schema:address", "@context": { - "streetAddress": { "@id": "schema:streetAddress", "@type": "http://www.w3.org/2001/XMLSchema#string" } + "streetAddress": { + "@id": "schema:streetAddress", + "@type": "http://www.w3.org/2001/XMLSchema#string" + } } } } @@ -68,7 +82,10 @@ "@context": { "schema": "http://schema.org/", "xsd": "http://www.w3.org/2001/XMLSchema#", - "userInteractionCount": { "@id": "schema:userInteractionCount", "@type": "xsd:integer" } + "userInteractionCount": { + "@id": "schema:userInteractionCount", + "@type": "xsd:integer" + } } } }, @@ -79,63 +96,108 @@ "@context": { "schema": "http://schema.org/", "xsd": "http://www.w3.org/2001/XMLSchema#", - "isAccessibleForFree": { "@id": "schema:isAccessibleForFree", "@type": "xsd:boolean" }, - "longitude": { "@id": "schema:longitude", "@type": "xsd:double" } + "isAccessibleForFree": { + "@id": "schema:isAccessibleForFree", + "@type": "xsd:boolean" + }, + "longitude": { + "@id": "schema:longitude", + "@type": "xsd:double" + } } } } - ] + ], + "rule": "OOLD-RT-d9bd" }, { "feature": "native literals: plain terms (no @type coercion) round-trip boolean and numeric JSON values, deriving the RDF datatype from the native JSON type", "$comment": "The complement of the lint above: numbers and booleans MUST be mapped by plain terms. JSON-LD projects them to xsd:integer/xsd:double/xsd:boolean literals from the native JSON type alone, and reconstruction (fromRDF with native types) returns untyped native values, which only a plain term compacts back to its key.", "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "x-oold-instance-rdf-type": ["schema:InteractionCounter"], + "$id": "https://oo-ld.test/compliance/native-literals-plain-terms-no-type-coercion-rou.schema.json", + "x-oold-instance-rdf-type": [ + "schema:InteractionCounter" + ], "@context": { "schema": "http://schema.org/", "id": "@id", "type": "@type", - "userInteractionCount": { "@id": "schema:userInteractionCount" }, - "longitude": { "@id": "schema:longitude" }, - "isAccessibleForFree": { "@id": "schema:isAccessibleForFree" } + "userInteractionCount": { + "@id": "schema:userInteractionCount" + }, + "longitude": { + "@id": "schema:longitude" + }, + "isAccessibleForFree": { + "@id": "schema:isAccessibleForFree" + } }, "type": "object", "properties": { - "id": { "type": "string" }, - "type": { "type": ["string", "array"] }, - "userInteractionCount": { "type": "integer" }, - "longitude": { "type": "number" }, - "isAccessibleForFree": { "type": "boolean" } + "id": { + "type": "string" + }, + "type": { + "type": [ + "string", + "array" + ] + }, + "userInteractionCount": { + "type": "integer" + }, + "longitude": { + "type": "number" + }, + "isAccessibleForFree": { + "type": "boolean" + } } }, "tests": [ { "description": "an integer projects to an xsd:integer literal from the native JSON type", - "data": { "id": "https://example.org/counter", "type": "schema:InteractionCounter", "userInteractionCount": 5 }, + "data": { + "id": "https://example.org/counter", + "type": "schema:InteractionCounter", + "userInteractionCount": 5 + }, "valid": true, "expectRdf": " \"5\"^^ .\n .\n", "roundtrip": true }, { "description": "a fractional number projects to xsd:double and reconstructs as a native number", - "data": { "id": "https://example.org/counter", "type": "schema:InteractionCounter", "longitude": 13.4 }, + "data": { + "id": "https://example.org/counter", + "type": "schema:InteractionCounter", + "longitude": 13.4 + }, "valid": true, "roundtrip": true }, { "description": "a boolean projects to xsd:boolean and reconstructs as a native boolean", - "data": { "id": "https://example.org/counter", "type": "schema:InteractionCounter", "isAccessibleForFree": true }, + "data": { + "id": "https://example.org/counter", + "type": "schema:InteractionCounter", + "isAccessibleForFree": true + }, "valid": true, "roundtrip": true } - ] + ], + "rule": "OOLD-RT-d9bd" }, { "feature": "value-form: one plain `address` term projects a literal, a reference, and an embedded object to distinct RDF shapes", "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "x-oold-instance-rdf-type": ["schema:Organization"], + "$id": "https://oo-ld.test/compliance/value-form-one-plain-address-term-projects-a-lit.schema.json", + "x-oold-instance-rdf-type": [ + "schema:Organization" + ], "@context": { "schema": "http://schema.org/", "id": "@id", @@ -151,13 +213,42 @@ }, "type": "object", "properties": { - "id": { "type": "string" }, + "id": { + "type": "string" + }, "address": { - "x-oold-range": ["schema:Text", "schema:PostalAddress", "schema:Place"], + "x-oold-range": [ + "schema:Text", + "schema:PostalAddress", + "schema:Place" + ], "anyOf": [ - { "type": "string" }, - { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } } }, - { "type": "object", "properties": { "type": {}, "streetAddress": { "type": "string" }, "postalCode": { "type": "string" } } } + { + "type": "string" + }, + { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "type": {}, + "streetAddress": { + "type": "string" + }, + "postalCode": { + "type": "string" + } + } + } ] } } @@ -165,29 +256,48 @@ "tests": [ { "description": "a bare string value becomes a plain literal", - "data": { "id": "https://example.org/acme", "address": "Mainstreet 1, 10115 Example City" }, + "data": { + "id": "https://example.org/acme", + "address": "Mainstreet 1, 10115 Example City" + }, "valid": true, "expectRdf": " \"Mainstreet 1, 10115 Example City\" .\n" }, { "description": "an object carrying only id becomes an IRI reference", - "data": { "id": "https://example.org/acme", "address": { "id": "https://example.org/address/A1" } }, + "data": { + "id": "https://example.org/acme", + "address": { + "id": "https://example.org/address/A1" + } + }, "valid": true, "expectRdf": " .\n" }, { "description": "a typed object becomes a blank node with rdf:type and its own properties", - "data": { "id": "https://example.org/acme", "address": { "type": "PostalAddress", "streetAddress": "Mainstreet 1", "postalCode": "10115" } }, + "data": { + "id": "https://example.org/acme", + "address": { + "type": "PostalAddress", + "streetAddress": "Mainstreet 1", + "postalCode": "10115" + } + }, "valid": true, "expectRdf": "_:b0 \"10115\" .\n_:b0 \"Mainstreet 1\" .\n_:b0 .\n _:b0 .\n" } - ] + ], + "rule": "OOLD-INS-1df7" }, { "feature": "separate-keys: a canonical @type:@id `address` term (reference or embedded object) plus a plain `address_text` companion (literal), all projecting to schema:address", "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "x-oold-instance-rdf-type": ["schema:Organization"], + "$id": "https://oo-ld.test/compliance/separate-keys-a-canonical-type-id-address-term-r.schema.json", + "x-oold-instance-rdf-type": [ + "schema:Organization" + ], "@context": { "schema": "http://schema.org/", "id": "@id", @@ -201,62 +311,111 @@ "postalCode": "schema:postalCode" } }, - "address_text": { "@id": "schema:address" } + "address_text": { + "@id": "schema:address" + } }, "type": "object", "properties": { - "id": { "type": "string" }, + "id": { + "type": "string" + }, "address": { - "x-oold-range": ["schema:PostalAddress", "schema:Place"], + "x-oold-range": [ + "schema:PostalAddress", + "schema:Place" + ], "anyOf": [ - { "type": "string" }, - { "type": "object", "properties": { "type": {}, "streetAddress": { "type": "string" }, "postalCode": { "type": "string" } } } + { + "type": "string" + }, + { + "type": "object", + "properties": { + "type": {}, + "streetAddress": { + "type": "string" + }, + "postalCode": { + "type": "string" + } + } + } ] }, - "address_text": { "type": "string" } + "address_text": { + "type": "string" + } } }, "tests": [ { "description": "the literal is written under the plain address_text companion", - "data": { "id": "https://example.org/acme", "address_text": "Mainstreet 1, 10115 Example City" }, + "data": { + "id": "https://example.org/acme", + "address_text": "Mainstreet 1, 10115 Example City" + }, "valid": true, "expectRdf": " \"Mainstreet 1, 10115 Example City\" .\n" }, { "description": "a bare IRI string under the @type:@id address term is a reference", - "data": { "id": "https://example.org/acme", "address": "https://example.org/address/A1" }, + "data": { + "id": "https://example.org/acme", + "address": "https://example.org/address/A1" + }, "valid": true, "expectRdf": " .\n" }, { "description": "a typed object under the address term is still an embedded blank node", - "data": { "id": "https://example.org/acme", "address": { "type": "PostalAddress", "streetAddress": "Mainstreet 1", "postalCode": "10115" } }, + "data": { + "id": "https://example.org/acme", + "address": { + "type": "PostalAddress", + "streetAddress": "Mainstreet 1", + "postalCode": "10115" + } + }, "valid": true, "expectRdf": "_:b0 \"10115\" .\n_:b0 \"Mainstreet 1\" .\n_:b0 .\n _:b0 .\n" } - ] + ], + "rule": "OOLD-INS-2e5d" }, { "feature": "language-tagged text: an @language term projects a string to a language-tagged literal", "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "x-oold-instance-rdf-type": ["schema:Organization"], + "$id": "https://oo-ld.test/compliance/language-tagged-text-an-language-term-projects-a.schema.json", + "x-oold-instance-rdf-type": [ + "schema:Organization" + ], "@context": { "schema": "http://schema.org/", "id": "@id", - "name": { "@id": "schema:name", "@language": "de" } + "name": { + "@id": "schema:name", + "@language": "de" + } }, "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" } + "id": { + "type": "string" + }, + "name": { + "type": "string" + } } }, "tests": [ { "description": "the name string carries the term's language tag in RDF", - "data": { "id": "https://example.org/acme", "name": "ACME GmbH" }, + "data": { + "id": "https://example.org/acme", + "name": "ACME GmbH" + }, "valid": true, "expectRdf": " \"ACME GmbH\"@de .\n" } @@ -267,7 +426,10 @@ "$comment": "The instance carries its materialized root type (schema:Organization), as a compliant export must, so the schema-derived frame can pick it as the frame root and nest the embedded object beneath it. Reconstruction uses scripts/schema_to_frame.mjs; [roundtrip] asserts instance == reconstruction after canonicalization.", "schema": { "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", - "x-oold-instance-rdf-type": ["schema:Organization"], + "$id": "https://oo-ld.test/compliance/reverse-round-trip-an-exported-instance-reconstr.schema.json", + "x-oold-instance-rdf-type": [ + "schema:Organization" + ], "@context": { "schema": "http://schema.org/", "id": "@id", @@ -280,54 +442,128 @@ "postalCode": "schema:postalCode" } }, - "keywords": { "@id": "schema:keywords", "@container": "@set" } + "keywords": { + "@id": "schema:keywords", + "@container": "@set" + } }, "type": "object", "properties": { - "id": { "type": "string" }, - "type": { "type": ["string", "array"] }, + "id": { + "type": "string" + }, + "type": { + "type": [ + "string", + "array" + ] + }, "address": { - "x-oold-range": ["schema:Text", "schema:PostalAddress", "schema:Place"], + "x-oold-range": [ + "schema:Text", + "schema:PostalAddress", + "schema:Place" + ], "anyOf": [ - { "type": "string" }, - { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } } }, - { "type": "object", "properties": { "type": {}, "streetAddress": { "type": "string" }, "postalCode": { "type": "string" } } } + { + "type": "string" + }, + { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "type": {}, + "streetAddress": { + "type": "string" + }, + "postalCode": { + "type": "string" + } + } + } ] }, - "keywords": { "type": "array", "items": { "type": "string" } } + "keywords": { + "type": "array", + "items": { + "type": "string" + } + } } }, "tests": [ { "description": "a literal address reconstructs by compaction", - "data": { "id": "https://example.org/acme", "type": "schema:Organization", "address": "Mainstreet 1, 10115 Example City" }, + "data": { + "id": "https://example.org/acme", + "type": "schema:Organization", + "address": "Mainstreet 1, 10115 Example City" + }, "valid": true, "roundtrip": true }, { "description": "a reference address reconstructs as { id } (the target has no local triples)", - "data": { "id": "https://example.org/acme", "type": "schema:Organization", "address": { "id": "https://example.org/address/A1" } }, + "data": { + "id": "https://example.org/acme", + "type": "schema:Organization", + "address": { + "id": "https://example.org/address/A1" + } + }, "valid": true, "roundtrip": true }, { "description": "an embedded address reconstructs as a nested object through the frame", - "data": { "id": "https://example.org/acme", "type": "schema:Organization", "address": { "type": "PostalAddress", "streetAddress": "Mainstreet 1", "postalCode": "10115" } }, + "data": { + "id": "https://example.org/acme", + "type": "schema:Organization", + "address": { + "type": "PostalAddress", + "streetAddress": "Mainstreet 1", + "postalCode": "10115" + } + }, "valid": true, "roundtrip": true }, { "description": "a multi-valued keywords array is kept as a set", - "data": { "id": "https://example.org/acme", "type": "schema:Organization", "keywords": ["a", "b"] }, + "data": { + "id": "https://example.org/acme", + "type": "schema:Organization", + "keywords": [ + "a", + "b" + ] + }, "valid": true, "roundtrip": true }, { "description": "a single-element keywords array stays an array (would collapse to a scalar without @container:@set)", - "data": { "id": "https://example.org/acme", "type": "schema:Organization", "keywords": ["x"] }, + "data": { + "id": "https://example.org/acme", + "type": "schema:Organization", + "keywords": [ + "x" + ] + }, "valid": true, "roundtrip": true } - ] + ], + "rule": "OOLD-RT-08f2" } ] diff --git a/tests/test_validation/test_check_registry_drift.py b/tests/test_validation/test_check_registry_drift.py index 94cff5f..0dbaf19 100644 --- a/tests/test_validation/test_check_registry_drift.py +++ b/tests/test_validation/test_check_registry_drift.py @@ -17,8 +17,10 @@ #: One version with no rule catalogue and the newest tracked one, which has one. That is enough #: to surface every id the pipeline can emit; adding more tracked versions would only slow the -#: suite down for no extra coverage. -_CORPUS_META = ("0.7.0", "1.0.0-rc.1") +#: suite down for no extra coverage. The newest is read rather than named: written out, it stops +#: being the newest the next time a version is vendored, and the drift these tests exist to catch +#: would then be measured against a stale catalogue without anything saying so. +_CORPUS_META = ("0.7.0", tracked_versions()[-1]) #: These two fire only on an infrastructure failure path the corpus cannot exercise without #: corrupting something every other test relies on: a broken vendored meta-schema diff --git a/tests/test_validation/test_checks.py b/tests/test_validation/test_checks.py index 38d6014..31b983b 100644 --- a/tests/test_validation/test_checks.py +++ b/tests/test_validation/test_checks.py @@ -32,13 +32,24 @@ def test_jsonld_keywords_are_tolerated_as_annotations(bundle): JSON Schema 2020-12 tolerates unknown keywords as annotations. If it did not, `@context` at a schema root would make every OO-LD document invalid. """ - result = validate_against_meta({"@context": {"ex": "https://example.org/"}, "@id": "x", "type": "object"}, bundle) - assert result.valid + document = { + # From 1.0.0-rc.2 the dialect requires $id of a document, so a probe without one is + # rejected before the keyword under test is ever reached. + "$id": "https://example.org/probe.schema.json", + "@context": {"ex": "https://example.org/"}, + "@id": "x", + "type": "object", + } + result = validate_against_meta(document, bundle) + assert result.valid, result.errors assert "@context" in result.jsonld_keywords_found def test_a_malformed_keyword_is_rejected(bundle): - result = validate_against_meta({"x-oold-instance-rdf-type": "not-an-array"}, bundle) + # $id so the keyword is the only thing wrong: from 1.0.0-rc.2 a document without one is + # rejected anyway, which would make this pass without asserting anything. + document = {"$id": "https://example.org/probe.schema.json", "x-oold-instance-rdf-type": "not-an-array"} + result = validate_against_meta(document, bundle) assert not result.valid diff --git a/tests/test_validation/test_meta_store.py b/tests/test_validation/test_meta_store.py index 038d0f2..1935328 100644 --- a/tests/test_validation/test_meta_store.py +++ b/tests/test_validation/test_meta_store.py @@ -168,11 +168,14 @@ def test_the_fixture_slice_records_the_release_it_came_from(): ) -def test_bundle_exposes_the_three_documents(): +def test_bundle_exposes_every_document_the_version_ships(): bundle = load_tracked(latest_version()) assert bundle.meta["$id"] assert bundle.ui_meta["$id"] assert bundle.pattern_lint["$id"] + # Not three since 1.0.0-rc.2: the dialect is a wrapper plus the base it $refs, and the base + # has no accessor of its own because nothing reaches it except through that $ref. + assert set(bundle.documents) == set(meta_store.meta_files(latest_version())) def test_declared_keywords_are_found(): @@ -221,9 +224,17 @@ def test_registry_resolves_the_ui_meta_schema_cross_reference(): """The core meta-schema $refs the UI one, so a bad registry silently stops asserting.""" bundle = load_tracked(latest_version()) validator = bundle.meta_validator() - # x-oold-ui-* keywords are defined only in the UI meta-schema. - assert validator.is_valid({"x-oold-ui-title": "ok"}) - assert not validator.is_valid({"x-oold-instance-rdf-type": "must-be-an-array"}) + # Every probe carries $id because the dialect requires one of a document from 1.0.0-rc.2 on. + # Without it these assertions pass or fail for a reason that has nothing to do with the + # registry. + probe = {"$id": "https://example.org/probe.schema.json"} + # The keyword has to be one the UI meta-schema constrains, and the probe has to violate that + # constraint. Merely naming an x-oold-ui-* keyword proves nothing: 2020-12 tolerates an + # unreached keyword as an annotation, so such a probe is valid whether or not the + # cross-reference resolved. x-oold-ui-form-hidden is declared boolean, and only there. + assert validator.is_valid(probe | {"x-oold-ui-form-hidden": True}) + assert not validator.is_valid(probe | {"x-oold-ui-form-hidden": "not-a-boolean"}) + assert not validator.is_valid(probe | {"x-oold-instance-rdf-type": "must-be-an-array"}) def test_registry_resolves_by_file_name_when_the_id_domain_differs(tmp_path, monkeypatch): @@ -233,10 +244,14 @@ def test_registry_resolves_by_file_name_when_the_id_domain_differs(tmp_path, mon particular URL. This rewrites the ids and asserts validation still works. """ version = latest_version() + # The file set is read from the version being copied, not from the shared default. Since + # 1.0.0-rc.2 the dialect is two files, and copying three would leave the wrapper's $ref + # dangling - which is a fault in this fixture, not in the resolution being tested. + files = meta_store.meta_files(version) source = meta_store.meta_dir() / version target = tmp_path / "meta" / "9.9.9" target.mkdir(parents=True) - for name in meta_store.meta_files(): + for name in files: document = json.loads((source / name).read_text(encoding="utf-8")) if "$id" in document: document["$id"] = ( @@ -245,11 +260,21 @@ def test_registry_resolves_by_file_name_when_the_id_domain_differs(tmp_path, mon .replace("oo-ld.github.io/oold-schema", "example.invalid/elsewhere") ) (target / name).write_text(json.dumps(document), encoding="utf-8") + # Only the $id values were rewritten, so the wrapper still $refs the base at /latest/ while + # the base now answers to /9.9.9/. That mismatch is the point: resolution is by file name. + (tmp_path / "meta" / "index.json").write_text( + json.dumps({"files": files, "versions": {"9.9.9": {}}, "remote": {}}), encoding="utf-8" + ) monkeypatch.setattr(meta_store, "meta_dir", lambda: tmp_path / "meta") - bundle = load_tracked("9.9.9") - assert bundle.self_check() == [] - assert bundle.meta_validator().is_valid({"x-oold-ui-title": "still works"}) + meta_store.load_index.cache_clear() + try: + bundle = load_tracked("9.9.9") + assert bundle.self_check() == [] + probe = {"$id": "https://example.org/probe.schema.json", "x-oold-ui-form-hidden": True} + assert bundle.meta_validator().is_valid(probe) + finally: + meta_store.load_index.cache_clear() def test_describe_store_reports_versions_and_cache_state(isolated_cache): diff --git a/tests/test_validation/test_pipeline.py b/tests/test_validation/test_pipeline.py index 54dddc1..25cf6f0 100644 --- a/tests/test_validation/test_pipeline.py +++ b/tests/test_validation/test_pipeline.py @@ -200,7 +200,11 @@ def test_an_empty_directory_is_reported(tmp_path): def test_an_instance_without_a_schema_reference_is_reported(tmp_path): (tmp_path / "x.instance.json").write_text('{"a": 1}', encoding="utf-8") - (tmp_path / "y.schema.json").write_text('{"type": "object"}', encoding="utf-8") + # $id so the schema beside the instance is itself valid: from 1.0.0-rc.2 the dialect requires + # one, and a second failure here would not be about the missing $schema reference under test. + (tmp_path / "y.schema.json").write_text( + '{"$id": "https://example.org/y.schema.json", "type": "object"}', encoding="utf-8" + ) report = validate_directory(tmp_path, OFFLINE) check = next(c for c in report.checks if c.id == "instance.schema") assert check.status == FAIL and "$schema" in check.message diff --git a/tests/test_validation/test_rules.py b/tests/test_validation/test_rules.py index e141082..818c23d 100644 --- a/tests/test_validation/test_rules.py +++ b/tests/test_validation/test_rules.py @@ -91,14 +91,18 @@ @pytest.fixture def catalog_version(tmp_path, monkeypatch): """A tracked meta version that additionally ships a rule catalog.""" - source = meta_store.meta_dir() / meta_store.latest_version() + version = meta_store.latest_version() + # What that version ships, not the shared default: since 1.0.0-rc.2 the dialect is a wrapper + # plus the base it $refs, and copying only the default three leaves that $ref dangling. + files = meta_store.meta_files(version) + source = meta_store.meta_dir() / version target = tmp_path / "meta" / "9.9.9" target.mkdir(parents=True) - for name in meta_store.meta_files(): + for name in files: (target / name).write_bytes((source / name).read_bytes()) (target / RULES_FILE).write_text(json.dumps(SAMPLE_RULES), encoding="utf-8") (tmp_path / "meta" / "index.json").write_text( - json.dumps({"files": meta_store.meta_files(), "versions": {"9.9.9": {}}, "remote": {}}), + json.dumps({"files": files, "versions": {"9.9.9": {}}, "remote": {}}), encoding="utf-8", ) monkeypatch.setattr(meta_store, "meta_dir", lambda: tmp_path / "meta") @@ -143,7 +147,8 @@ def test_a_malformed_catalog_is_treated_as_absent(catalog_version, tmp_path): (tmp_path / "meta" / catalog_version / RULES_FILE).write_text("{ not json", encoding="utf-8") bundle = load_tracked(catalog_version) assert bundle.has_rules is False - assert bundle.meta_validator().is_valid({"type": "object"}) + # $id because this fixture copies the newest dialect, which requires one of a document. + assert bundle.meta_validator().is_valid({"$id": "https://example.org/probe.schema.json", "type": "object"}) def test_checkable_rules_exclude_implementation_advisory_and_deprecated(catalog_version):