diff --git a/src/openxdox/gate_console.py b/src/openxdox/gate_console.py index 640de0c..911a56a 100644 --- a/src/openxdox/gate_console.py +++ b/src/openxdox/gate_console.py @@ -631,25 +631,25 @@ def build_gate_action_record( def _validate_contract_document( - document: Mapping[str, Any], *, schema_filename: str, label: str, + document: Mapping[str, Any], *, schema_text: str, label: str, ) -> None: - """Validate one document against a repository-pinned released schema.""" + """Validate one document against a released schema's text, which its + caller reads where the schema's owner keeps it: `_packaged_schema_text` + or `_host_schema_text`, under "THE CONTRACT SCHEMAS THE CONSOLE VALIDATES + AGAINST" near the end of this module (plan 038 T021). + + A schema that is not YAML, is not a valid JSON Schema, or cannot be + applied is refused there too, by `_schema_errors`, as `GateRefused`. So + a host's broken schema reaches a caller as a failing document does, and + never as another exception.""" try: from jsonschema import Draft202012Validator, FormatChecker except ImportError as exc: # pragma: no cover - release dependency guard raise GateRefused( f"jsonschema is required to validate {label}") from exc - schema_path = (Path(__file__).resolve().parents[2] / "contracts" / "schemas" - / schema_filename) - try: - schema = yaml.safe_load(schema_path.read_text(encoding="utf-8")) - except (OSError, yaml.YAMLError) as exc: - raise GateRefused( - f"the {label} schema could not be loaded from {schema_path}: " - f"{exc}") from exc errors = sorted( - Draft202012Validator(schema, format_checker=FormatChecker()).iter_errors( - dict(document)), + _schema_errors(document, schema_text, label=label, + validator=Draft202012Validator, formats=FormatChecker), key=lambda error: tuple(str(part) for part in error.absolute_path)) if errors: detail = "; ".join( @@ -661,8 +661,8 @@ def _validate_contract_document( def validate_gate_action_record(record: Mapping[str, Any]) -> None: """Validate one record against the repository-pinned released schema.""" - _validate_contract_document( - record, schema_filename="gate-action-record.schema.yaml", + _validate_contract_document(record, schema_text=_packaged_schema_text( + GATE_ACTION_RECORD_SCHEMA, label="gate-action record"), label="gate-action record") if record.get("action") != ACTION_CLEANUP_ABANDONED_BRANCH: return @@ -707,8 +707,8 @@ def validate_gate_action_record(record: Mapping[str, Any]) -> None: def validate_demotion_execution_receipt(receipt: Mapping[str, Any]) -> None: - _validate_contract_document( - receipt, schema_filename="demotion-execution-receipt.schema.yaml", + _validate_contract_document(receipt, schema_text=_host_schema_text( + DEMOTION_RECEIPT_SCHEMA, label="demotion execution receipt"), label="demotion execution receipt") destination = receipt.get("destination") if not isinstance(destination, Mapping) or str(destination.get("path") or "") \ @@ -2348,6 +2348,165 @@ def edit_project(self, project_id: str, *, add=None, remove=None, at=at, records_dir=self.records_dir, provenance=provenance, **extra) +# -------------------------------------------------------------------------- +# THE CONTRACT SCHEMAS THE CONSOLE VALIDATES AGAINST, AND WHERE EACH IS READ +# (plan 038 T021, U-2; R2Q8 (a); P4F-4, ruled with plan 038 on +# openxFactory#656 comment 6013547504; R1Q27 (a), comment 5851950767). +# +# Two documents are validated here, against two schemas with two owners: +# * a gate-action record, against openXdox-spec's +# `gate-action-record.schema.yaml`. This distribution packages it in +# `openxdox.contracts`, as one of the consumer validator's own three kinds, +# held to its digest by `copies.yaml`. So it is read from there, through +# `contracts.verified_bytes()`, wherever this package runs; +# * a demotion execution receipt, against openxFactory's +# `demotion-execution-receipt.schema.yaml`. That schema is the HOST's +# contract: openXdox-spec carries only its examples, and the copy record +# admits the validator's three kinds and no fourth +# (`tests/test_packaged_validator.py`), so nothing of it is vendored here. +# A host that writes receipts registers, once, at process start, the +# directory its schema is in, with `register_contract_schema_source()`. +# With none registered, validating a receipt refuses, naming that call: +# R1Q27 (a)'s "only where the tree it runs from supplies their schemas". +# +# Both used to be read from `parents[2] / "contracts" / "schemas"` beside +# this module. That was openxFactory's own tree before the carve. In this +# leg it is the checkout's root, which holds no `contracts/`. +# -------------------------------------------------------------------------- + +#: The gate-action record schema's copy id in `openxdox.contracts`. +GATE_ACTION_RECORD_SCHEMA = "gate-action-record" +#: The demotion execution receipt schema's file name in the host's source. +DEMOTION_RECEIPT_SCHEMA = "demotion-execution-receipt.schema.yaml" + +_contract_schema_source: Path | None = None + + +class ContractSchemaSourceRefused(RuntimeError): + """A contract schema source the gate console cannot take, refused when it + is registered rather than when a receipt is first validated.""" + + +def register_contract_schema_source(source: str | Path) -> Path: + """Register the host's contract schema directory. Returns it, resolved. + + The directory must hold `demotion-execution-receipt.schema.yaml`, the one + schema the console reads from a host. Registering the same directory again + is a no-op, so an idempotent host start is not punished. A DIFFERENT one is + refused: one process whose receipts are validated against two schemas, + depending on which registration a caller reached, is the failure one + registration exists to prevent. Call `unregister_contract_schema_source()` + first if the swap is deliberate.""" + global _contract_schema_source + directory = Path(source).resolve() + if not (directory / DEMOTION_RECEIPT_SCHEMA).is_file(): + raise ContractSchemaSourceRefused( + f"{directory} holds no {DEMOTION_RECEIPT_SCHEMA}, so the gate " + "console cannot read a demotion execution receipt's schema from " + "it. Register the directory the host keeps its contract schemas " + "in, its contracts/schemas/.") + if (_contract_schema_source is not None + and _contract_schema_source != directory): + raise ContractSchemaSourceRefused( + f"a contract schema source is already registered " + f"({_contract_schema_source}), and {directory} would replace it. " + "Registration happens once, at process start. Call " + "openxdox.gate_console.unregister_contract_schema_source() first " + "if the swap is deliberate.") + _contract_schema_source = directory + return directory + + +def unregister_contract_schema_source() -> None: + """Drop the registration. For test isolation and for a host tearing down.""" + global _contract_schema_source + _contract_schema_source = None + + +def contract_schema_source_registered() -> bool: + """Is a contract schema source registered, without reading anything?""" + return _contract_schema_source is not None + + +def _packaged_schema_text(copy_id: str, *, label: str) -> str: + """A schema this distribution packages, once its digest has been checked.""" + from . import contracts + + try: + return contracts.verified_bytes(copy_id).decode("utf-8") + except (contracts.CopyRefused, UnicodeDecodeError) as exc: + raise GateRefused( + f"the {label} schema could not be read from openXdox's packaged " + f"copy: {exc}") from exc + + +def _schema_errors(document: Mapping[str, Any], schema_text: str, *, + label: str, validator: Any, formats: Any) -> list: + """`document`'s errors against the schema `schema_text` holds. + + The schema itself is checked first. ANY failure at any of the three steps + refuses as `GateRefused`, naming the step: + * parsed: not YAML the safe loader can read (a `YAMLError`, and what it + raises past one: the `RecursionError` of a nesting deeper than the + interpreter allows, the `ValueError` of an impossible date); + * checked: not a valid JSON Schema under the validator's own + meta-schema (an empty document, `required: 1`), or a schema the check + cannot finish (the `RecursionError` of a cyclic YAML alias); + * applied: applying it raises (an unresolvable `$ref`). + A host supplies the receipt schema, and the demotion verb writes its + receipt after it has moved files: it reports a `GateRefused` there as an + executed demotion whose receipt was not written, where any other exception + would end it with a traceback (Copilot on #40: r4194497045, r4197914632, + r4198638746).""" + from jsonschema.exceptions import SchemaError + + try: + schema = yaml.safe_load(schema_text) + except Exception as exc: # noqa: BLE001 - any failure to read it refuses + raise GateRefused( + f"the {label} schema could not be parsed: " + f"{type(exc).__name__}: {exc}") from exc + try: + validator.check_schema(schema) + except SchemaError as exc: + raise GateRefused( + f"the {label} schema is not a valid JSON Schema: {exc.message}" + ) from exc + except Exception as exc: # noqa: BLE001 - any failure to check it refuses + raise GateRefused( + f"the {label} schema could not be checked: " + f"{type(exc).__name__}: {exc}") from exc + try: + return list(validator(schema, format_checker=formats()).iter_errors( + dict(document))) + except Exception as exc: # noqa: BLE001 - any failure to apply it refuses + raise GateRefused( + f"the {label} schema could not be applied: " + f"{type(exc).__name__}: {exc}") from exc + + +def _host_schema_text(filename: str, *, label: str) -> str: + """A schema the host supplies, from the source it registered.""" + source = _contract_schema_source + if source is None: + raise GateRefused( + f"the {label} cannot be validated: its schema, {filename}, is the " + "host's contract, and no contract schema source is registered. A " + "host registers the directory it keeps its contract schemas in, " + "once, at process start, with " + "openxdox.gate_console.register_contract_schema_source()" + " (R1Q27 (a): a kind is validated only where the tree it runs " + "from supplies its schema).") + path = source / filename + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise GateRefused( + f"the {label} schema could not be loaded from {path}: " + f"{exc}") from exc + + + # --------------------------------------------------------------------------- # BACKWARD-COMPATIBLE MODULE ATTRIBUTES, RESOLVED ON FIRST ACCESS (PEP 562). # diff --git a/tests/conftest.py b/tests/conftest.py index 44e1b4f..5c0ad6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -775,3 +775,19 @@ def _the_governed_host_for_the_governed_suites(request): return with a_hosts_plane(governed_host): yield + + +# THE GATE CONSOLE'S CONTRACT SCHEMA SOURCE, REGISTERED AS A HOST REGISTERS IT +# (plan 038 T021, U-2; P4F-4). A demotion execution receipt's schema is +# openxFactory's contract, and the gate console reads it only from a source a +# host registers (`gate_console.register_contract_schema_source`). Where the +# run is composed, this registers openxFactory's `contracts/schemas/` from the +# composed tree, at process start, as openxFactory's own host wiring does from +# T030. A lone checkout registers none, and there a receipt refuses by name +# (R1Q27 (a)). +_OPENXFACTORY_HOST = openxfactory_host() +if _OPENXFACTORY_HOST is not None: + from openxdox import gate_console as _gate_console # noqa: E402 + + _gate_console.register_contract_schema_source( + Path(_OPENXFACTORY_HOST.__file__).resolve().parents[1] / "contracts" / "schemas") diff --git a/tests/test_gate_console_schema_source.py b/tests/test_gate_console_schema_source.py new file mode 100644 index 0000000..bb46f24 --- /dev/null +++ b/tests/test_gate_console_schema_source.py @@ -0,0 +1,270 @@ +"""The gate console's contract schemas, each read where its owner keeps it +(plan 038 T021, U-2; R2Q8 (a); P4F-4, ruled with plan 038 on openxFactory#656 +comment 6013547504; R1Q27 (a), comment 5851950767). + +`openxdox.gate_console` validates two documents against two schemas with two +owners: + * a gate-action record, against openXdox-spec's schema, which this + distribution packages (`openxdox.contracts`, held to its digest by + `copies.yaml`). The console reads it from there; + * a demotion execution receipt, against openxFactory's schema, which nothing + here vendors. The console reads it from the source a host registers with + `register_contract_schema_source()`, and with none registered it refuses, + naming that call. + +THESE CASES RUN IN A LONE CHECKOUT, so in F9.1 and in the required job. The +console imports openxFactory's `doc_health` when it loads (R1Q6 (d)'s reason), +though nothing these cases run calls it. So each case runs the console in a +CHILD interpreter, beside a stand-in `doc_health` that answers the three names +the console's import reads and nothing else. This process never imports the +console through the stand-in. The child's import path is set whole, so in a +composed run openxFactory's `doc_health` is not on it either, and no source is +registered there unless the case registers one. + +A CREATED file: no manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import ast +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +SOURCE = Path(__file__).resolve().parents[1] / "src" +GATE_CONSOLE = SOURCE / "openxdox" / "gate_console.py" + +#: The names the console's import reads from `doc_health`: `corpus` for +#: `STATUS_SCAN_LINES` (gate_console.py), and `lines` for `split_keepends` and +#: `join_rows` (round_trip.py). Nothing these cases run calls either function. +_STAND_IN = { + "__init__.py": "", + "corpus.py": "STATUS_SCAN_LINES = 0\n", + "lines.py": ("def split_keepends(text):\n" + " raise AssertionError('the doc_health stand-in')\n\n\n" + "def join_rows(rows):\n" + " raise AssertionError('the doc_health stand-in')\n"), +} + +#: Run first in every child: the console, a valid gate-action record and a +#: receipt, and `refusal()`, which answers None where the call passes and the +#: refusal's type and words where it refuses. +_PRELUDE = ''' +import json +import shutil +import sys +from pathlib import Path + +from openxdox import contracts +from openxdox import gate_console as gc + +out = {} +RECORD = gc.build_gate_action_record( + actor="brett", action="demote", at="2026-10-06T00:00:00Z", reason="why", + change_id="add-x", artifacts=[{"kind": "transition-manifest", + "reference": "ideation/staging/x/m.yaml"}]) +RECEIPT = {"kind": "from-a", "destination": {"id": "x", "path": "ideation/staging/x"}, + "transition_manifest": "ideation/staging/x/m.yaml"} + + +def refusal(call, *args): + try: + call(*args) + except (gc.GateRefused, gc.ContractSchemaSourceRefused) as exc: + return f"{type(exc).__name__}: {exc}" + return None +''' + + +def _in_a_child(tmp_path: Path, body: str) -> dict: + """Run `body` after the prelude in a fresh interpreter whose import path is + the `doc_health` stand-in and this checkout's `src/`, and answer the + `out` mapping it prints.""" + stand_in = tmp_path / "stand-in" / "doc_health" + stand_in.mkdir(parents=True) + for name, text in _STAND_IN.items(): + (stand_in / name).write_text(text, encoding="utf-8") + script = tmp_path / "case.py" + script.write_text(_PRELUDE + textwrap.dedent(body) + + "\nprint(json.dumps(out))\n", encoding="utf-8") + env = {key: value for key, value in os.environ.items() + if key != "PYTHONPATH"} + env["PYTHONPATH"] = os.pathsep.join((str(stand_in.parent), str(SOURCE))) + env["PYTHONDONTWRITEBYTECODE"] = "1" + run = subprocess.run([sys.executable, str(script)], cwd=tmp_path, env=env, + capture_output=True, text=True, check=False) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout.splitlines()[-1]) + + +def _receipt_source(directory: Path, *, kind: str, extra: dict | None = None) -> Path: + """A host's contract schema directory, stood in: its receipt schema admits + one `kind`. `extra` adds further schema files beside it.""" + directory.mkdir(parents=True) + schema = {"type": "object", "required": ["kind", "destination"], + "properties": {"kind": {"const": kind}}} + (directory / "demotion-execution-receipt.schema.yaml").write_text( + json.dumps(schema), encoding="utf-8") + for name, text in (extra or {}).items(): + (directory / name).write_text(text, encoding="utf-8") + return directory + + +def test_a_receipt_refuses_by_name_where_no_host_registered_its_schema(tmp_path) -> None: + out = _in_a_child(tmp_path, ''' + out["registered"] = gc.contract_schema_source_registered() + out["refusal"] = refusal(gc.validate_demotion_execution_receipt, RECEIPT) + ''') + assert out["registered"] is False + assert out["refusal"].startswith("GateRefused: the demotion execution receipt cannot be validated") + assert "demotion-execution-receipt.schema.yaml" in out["refusal"] + assert ("openxdox.gate_console.register_contract_schema_source()" + in out["refusal"]) + + +def test_a_gate_action_record_is_read_from_the_packaged_copy(tmp_path) -> None: + """With no source registered the record validates; a record the schema + does not admit refuses; a registered source whose own record schema admits + nothing is never read for a record; and a packaged copy that drifted from + its digest refuses.""" + hostile = _receipt_source(tmp_path / "hostile", kind="from-a", extra={ + "gate-action-record.schema.yaml": json.dumps({"not": {}})}) + out = _in_a_child(tmp_path, f''' + out["valid"] = refusal(gc.validate_gate_action_record, RECORD) + out["not_admitted"] = refusal(gc.validate_gate_action_record, + dict(RECORD, kind="another-kind")) + gc.register_contract_schema_source({str(hostile)!r}) + out["beside_a_hostile_source"] = refusal(gc.validate_gate_action_record, RECORD) + staged = Path({str(tmp_path / "staged")!r}) + shutil.copytree(contracts.package_dir(), staged, + ignore=shutil.ignore_patterns("__pycache__", "*.py")) + drifted = staged / "schemas" / "gate-action-record.schema.yaml" + drifted.write_bytes(drifted.read_bytes() + b"\\n") + contracts.package_dir = lambda: staged + out["drifted"] = refusal(gc.validate_gate_action_record, RECORD) + ''') + assert out["valid"] is None + assert out["not_admitted"].startswith( + "GateRefused: the gate-action record failed schema validation: kind") + assert out["beside_a_hostile_source"] is None + assert out["drifted"].startswith( + "GateRefused: the gate-action record schema could not be read from " + "openXdox's packaged copy") + assert "is not the spec leg's file" in out["drifted"] + + +def test_a_receipt_is_validated_against_the_registered_sources_schema(tmp_path) -> None: + source = _receipt_source(tmp_path / "host" / "contracts" / "schemas", kind="from-a") + out = _in_a_child(tmp_path, f''' + out["returned"] = str(gc.register_contract_schema_source({str(source)!r})) + out["registered"] = gc.contract_schema_source_registered() + out["valid"] = refusal(gc.validate_demotion_execution_receipt, RECEIPT) + out["not_admitted"] = refusal(gc.validate_demotion_execution_receipt, + dict(RECEIPT, kind="from-b")) + ''') + assert out["returned"] == str(source.resolve()) + assert out["registered"] is True + assert out["valid"] is None + assert out["not_admitted"].startswith( + "GateRefused: the demotion execution receipt failed schema validation: kind") + + +def test_the_seam_holds_one_source_and_refuses_a_second(tmp_path) -> None: + first = _receipt_source(tmp_path / "a", kind="from-a") + second = _receipt_source(tmp_path / "b", kind="from-b") + empty = tmp_path / "c" + empty.mkdir() + out = _in_a_child(tmp_path, f''' + out["first"] = str(gc.register_contract_schema_source(Path({str(first)!r}))) + out["again"] = str(gc.register_contract_schema_source({str(first)!r} + "/.")) + out["second"] = refusal(gc.register_contract_schema_source, {str(second)!r}) + out["still_first"] = refusal(gc.validate_demotion_execution_receipt, RECEIPT) + gc.unregister_contract_schema_source() + out["after_unregister"] = gc.contract_schema_source_registered() + out["swapped"] = str(gc.register_contract_schema_source({str(second)!r})) + out["now_second"] = refusal(gc.validate_demotion_execution_receipt, + dict(RECEIPT, kind="from-b")) + gc.unregister_contract_schema_source() + out["no_schema"] = refusal(gc.register_contract_schema_source, {str(empty)!r}) + out["after_no_schema"] = gc.contract_schema_source_registered() + ''') + assert out["first"] == out["again"] == str(first.resolve()) + assert out["second"].startswith( + "ContractSchemaSourceRefused: a contract schema source is already registered") + assert str(first.resolve()) in out["second"] and str(second.resolve()) in out["second"] + assert out["still_first"] is None + assert out["after_unregister"] is False + assert out["swapped"] == str(second.resolve()) + assert out["now_second"] is None + assert out["no_schema"].startswith( + f"ContractSchemaSourceRefused: {empty.resolve()} holds no " + "demotion-execution-receipt.schema.yaml") + assert out["after_no_schema"] is False + + +def test_a_host_schema_the_console_cannot_apply_refuses_as_gate_refused(tmp_path) -> None: + """A registered source whose receipt schema the safe loader cannot read, + is empty, is not a valid JSON Schema, or cannot be applied refuses as + `GateRefused`, never as another exception. The demotion verb writes its + receipt after it has moved files, and reports only a `GateRefused` there + as a receipt not written (Copilot on #40: r4194497045; r4197914632, the + loader raises `RecursionError` and `ValueError` past `YAMLError`; + r4198638746, a cyclic alias the meta-schema check cannot finish).""" + sources = {} + for name, text in {"empty": "", "not_a_schema": "required: 1\n", + "unresolvable": json.dumps({"$ref": "#/$defs/absent"}), + "not_yaml": "type: [object\n", + "too_deep": "[" * 5000 + "]" * 5000, + "impossible_date": "type: object\nx: 2001-02-30\n", + "cyclic_mapping": "properties: &p {a: {properties: *p}}\n", + "cyclic_list": "allOf: &loop [*loop]\n"}.items(): + directory = tmp_path / name + directory.mkdir() + (directory / "demotion-execution-receipt.schema.yaml").write_text( + text, encoding="utf-8") + sources[name] = str(directory) + out = _in_a_child(tmp_path, f''' + for name, source in {sources!r}.items(): + gc.unregister_contract_schema_source() + gc.register_contract_schema_source(source) + try: + gc.validate_demotion_execution_receipt(RECEIPT) + out[name] = None + except gc.GateRefused as exc: + out[name] = f"GateRefused: {{exc}}" + except Exception as exc: + out[name] = f"ESCAPED {{type(exc).__name__}}: {{exc}}" + ''') + for name in ("empty", "not_a_schema"): + assert out[name].startswith( + "GateRefused: the demotion execution receipt schema is not a valid " + "JSON Schema"), out[name] + assert out["unresolvable"].startswith( + "GateRefused: the demotion execution receipt schema could not be " + "applied"), out["unresolvable"] + for name in ("not_yaml", "too_deep", "impossible_date"): + assert out[name].startswith( + "GateRefused: the demotion execution receipt schema could not be " + "parsed"), out[name] + # A cyclic mapping never finishes the meta-schema check. A cyclic list is + # refused by the check itself under the pinned jsonschema; either way, a + # GateRefused. + assert out["cyclic_mapping"].startswith( + "GateRefused: the demotion execution receipt schema could not be " + "checked: RecursionError"), out["cyclic_mapping"] + assert out["cyclic_list"].startswith("GateRefused: "), out["cyclic_list"] + + +def test_the_console_reads_no_schema_from_beside_the_checkout() -> None: + """Before T021 both schemas were read from `parents[2]` of the module, + which in this leg is the checkout root. No `parents` index is read in the + console's code any more (its comments may still say where it was).""" + tree = ast.parse(GATE_CONSOLE.read_text(encoding="utf-8")) + reaches = [node.lineno for node in ast.walk(tree) + if isinstance(node, ast.Subscript) + and isinstance(node.value, ast.Attribute) + and node.value.attr == "parents"] + assert reaches == [], f"gate_console.py reads a parents index at lines {reaches}"