From c52900e9eb80324fcecceaf6705a06c057a4d243 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:39:02 +0000 Subject: [PATCH 01/10] T020, U-1: the governed host for the governed suites that drive the gate verbs (plan 038) tests/conftest.py registers THE GOVERNED HOST for the length of each test of the eight governed suites that drive openXdox's gate verbs (GOVERNED_HOST_SUITES): openxFactory's composite, opendox_host.profile(), where the run is composed (R1Q23 (a)), and GovernedStandInHost, which carries openXdox's three governed columns, elsewhere. The composite is registered with openDox's registry alone, so no openxFactory seam outlives the test. Its facets are read with sys.path saved and restored, because importing openxFactory's lane column runs carved_reach.install(), which would otherwise put openxFactory's pinned openXdox leg ahead of this checkout's namespace package. An openxdox module from anywhere else refuses the case by name. tests/test_host_plane.py holds the list to its scan (12.5's governed set that names a gate verb of cli_gate.py) and the mechanism to its word, all in a lone checkout. Both lists share one registration helper, a_hosts_plane; a module on both runs under the governed host. Composed, 12.5's sixteen suites go from 174 red to 109: host-reg 58 and one-off group H 7 turn green, and no green node turns red. Refs opensoft/openxFactory#656 (CLAIMED 6013608299). Lane: openXfactory-3 Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 213 +++++++++++++++++++++++++++++++++++++-- tests/test_host_plane.py | 206 ++++++++++++++++++++++++++++++++++++- 2 files changed, 409 insertions(+), 10 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 27de232..44e1b4f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,9 @@ from __future__ import annotations +import contextlib +import functools +import importlib import sys from pathlib import Path @@ -557,22 +560,218 @@ class StandInHost: SUBCOMMAND_EXTENSIONS: tuple = () -@_pytest.fixture(autouse=True) -def _a_hosts_plane_for_the_token_reading_suites(request): - if request.node.path.name not in HOST_PLANE_SUITES: - yield - return +@contextlib.contextmanager +def a_hosts_plane(host_factory): + """Register the host `host_factory()` builds with openDox's registry for + the length of the block, then put back what was registered before. + + The registration is dropped first, so the host's is made on a clean slate, + and the host is built only then. Whatever the block does, the registry is + emptied again afterwards and the earlier registration restored.""" from opendox import default_profile as _default_profile from opendox import domain_profile as _registry previous = _registry.current() if _registry.is_registered() else None _registry.unregister() - _registry.register(StandInHost()) try: - yield + yield _registry.register(host_factory()) finally: _registry.unregister() # The entry point's default is not put back as a registration: the # next entry point registers it again, as it does in any process. if previous is not None and previous is not _default_profile: _registry.register(previous) + + +@_pytest.fixture(autouse=True) +def _a_hosts_plane_for_the_token_reading_suites(request): + if plane_host_for(request.node.path.name) != "stand-in": + yield + return + with a_hosts_plane(StandInHost): + yield + + +# --------------------------------------------------------------------------- +# THE GOVERNED HOST FOR THE GOVERNED SUITES THAT DRIVE THE GATE VERBS (plan 038 +# T020, U-1; R2Q8 (a), openxFactory#656 comment 6003486656; P4F-5, ruled with +# plan 038 on comment 6013547504). +# +# #1144's 12.5 runs openxFactory's governed suites, each alone, "with the +# host's implementation registered". Eight of them drive openXdox's `gate` +# verbs, and with them its gate and projection routes. Those columns reach +# openDox only through a registered host: openDox's default profile +# contributes none, and `StandInHost` above contributes none either. So their +# cases read `invalid choice: 'gate'` or `unknown_action` (R2-INV-P4F's +# host-reg bucket and its one-off groups H and T). +# +# For the length of each test of the modules GOVERNED_HOST_SUITES names, this +# registers THE GOVERNED HOST, and puts the registry back afterwards, as the +# stand-in's fixture above does. Which host that is depends on the run: +# * Where the run is composed, with openxFactory's `scripts/` on the path +# (R1Q23 (a)), it is openxFactory's own composite, `opendox_host.profile()`: +# the object openxFactory's process-start call registers. It carries +# openXdox's gate and projection columns and openxFactory's own lane +# column, whose `POST /actions/refresh` two cases request. It is found as +# the home corpus's registration above finds its adapter: only a missing +# `opendox_host` itself means "absent". +# * Elsewhere it is `GovernedStandInHost`: openXdox's three columns and +# nothing else, as `tests/test_hermeticity_gate_verbs.py`'s `_GateHost` +# carries the gate verbs. Today these suites reach `doc_health`, so they +# run composed or not at all; this is the host they get wherever else they +# come to run. +# +# THE COMPOSITE ALONE, NOT openxFactory'S WHOLE START. `register_openxfactory()` +# also fills openxFactory's process-wide seams, and openxFactory's host has no +# call that takes them back, so they would outlive the test into every later +# module of the run. And it refuses while openXdox's own registry holds the +# profile the root `conftest.py` registers. This harness makes a host's other registrations +# itself (the profile, the home corpus), so only the composite is registered, +# with openDox's registry, as the stand-in is. +# +# AND openxFactory'S REACH STAYS OUT OF THIS PROCESS. Importing openxFactory's +# lane column runs `carved_reach.install()`, which puts openxFactory's pinned +# `openXdox/code/src` at the head of `sys.path`. `openxdox` is a namespace +# package, so every later `openxdox.*` import would come from that pinned copy +# and not from this checkout, and the run would measure another commit. So +# openXdox's columns are imported from this checkout first, the composite's +# facets are read once with `sys.path` saved, `sys.path` is put back, and an +# `openxdox` module imported from anywhere else refuses the case, by name. +# +# THE LIST IS THE SCAN'S, as HOST_PLANE_SUITES is. `tests/test_host_plane.py` +# holds it equal to the governed suites, computed as 12.5 computes them +# (`governed_suites()` below), that name a `gate` verb of openXdox's command +# line. A module on both lists runs under the governed host, which is a host's +# plane too (`plane_host_for`). +# --------------------------------------------------------------------------- + +GOVERNED_HOST_SUITES = frozenset({ + "test_branch_session.py", + "test_gateway_provenance.py", + "test_session_confinement.py", + "test_session_gates.py", + "test_session_lifecycle.py", + "test_session_notebook.py", + "test_session_transaction.py", + "test_session_verbs.py", +}) + +#: 12.5's governed set is every `tests/test_*.py` that names one of these +#: (its falsifier's `git grep -l -e … -- 'tests/test_*.py'`). They are spelled +#: here, in a file that pathspec does not match, so the check that holds the +#: list to its scan does not join the set it scans. +GOVERNED_SUITE_MARKERS: tuple[str, ...] = ("open-pr", "open_pr", "FakePullRequests") + + +def governed_suites(tests: Path = HERE) -> frozenset[str]: + """12.5's governed set: the `test_*.py` modules directly in `tests` that + name one of GOVERNED_SUITE_MARKERS.""" + return frozenset( + path.name for path in tests.glob("test_*.py") + if any(marker in path.read_text(encoding="utf-8") + for marker in GOVERNED_SUITE_MARKERS)) + + +def plane_host_for(suite: str) -> str | None: + """The host a test module's cases run under: "governed" for + GOVERNED_HOST_SUITES, "stand-in" for the rest of HOST_PLANE_SUITES, and + None for every other module. The governed host wins where a module is on + both lists, because it is a host's plane as well.""" + if suite in GOVERNED_HOST_SUITES: + return "governed" + if suite in HOST_PLANE_SUITES: + return "stand-in" + return None + + +#: openXdox's governed columns, as openxFactory's profile contributes them: +#: (the facet, the module, the class). Named rather than imported, because +#: each of the three modules reaches `doc_health`. +GOVERNED_COLUMNS: tuple[tuple[str, str, str], ...] = ( + ("SUBCOMMAND_EXTENSIONS", "openxdox.cli_gate", "GateSubcommands"), + ("ROUTE_EXTENSIONS", "openxdox.serve_gate", "GateRoutesExtension"), + ("ROUTE_EXTENSIONS", "openxdox.serve_projection", "ProjectionRoutesExtension"), +) + + +def _governed_columns(facet: str) -> tuple: + return tuple(getattr(importlib.import_module(module), name)() + for each, module, name in GOVERNED_COLUMNS if each == facet) + + +class GovernedStandInHost: + """A host's profile that carries openXdox's governed columns and nothing + else. Each facet is built on first read, so the host itself can be made + where `doc_health` cannot be imported.""" + + @functools.cached_property + def SUBCOMMAND_EXTENSIONS(self) -> tuple: # noqa: N802 (the facet's name) + return _governed_columns("SUBCOMMAND_EXTENSIONS") + + @functools.cached_property + def ROUTE_EXTENSIONS(self) -> tuple: # noqa: N802 (the facet's name) + return _governed_columns("ROUTE_EXTENSIONS") + + +def openxfactory_host(): + """openxFactory's host module (`scripts/opendox_host.py`) where the run is + composed, and None where it is not. Only a missing `opendox_host` itself + means "absent": a present one that cannot be imported fails here, loudly.""" + try: + return importlib.import_module("opendox_host") + except ModuleNotFoundError as absent: + if absent.name != "opendox_host": + raise + return None + + +#: The facets openDox's parser and server builders read off a host's profile. +#: Reading them is what imports a composite's columns. +BUILT_FACETS: tuple[str, ...] = ( + "SUBCOMMAND_EXTENSIONS", "ROUTE_EXTENSIONS", "HANDLER_CONTRIBUTIONS") + + +def _refuse_an_openxdox_from_elsewhere() -> None: + """Every `openxdox` module this process holds is this checkout's, or the + case is refused, naming each module that is not.""" + home = Path(_domain_profile.__file__).resolve().parent + elsewhere = sorted( + name for name, module in list(sys.modules.items()) + if name.startswith("openxdox.") + and getattr(module, "__file__", None) + and not Path(module.__file__).resolve().is_relative_to(home)) + if elsewhere: + raise RuntimeError( + f"{elsewhere} were imported from outside this checkout's openxdox " + f"({home}), so the run would measure another copy of them. " + "openxFactory's reach puts its pinned openXdox leg ahead of this " + "checkout when its lane column is imported; import the module " + "from this checkout before the composite's facets are read.") + + +def governed_host(): + """THE GOVERNED HOST for one case: openxFactory's composite where the run + is composed, and `GovernedStandInHost` where it is not.""" + host_module = openxfactory_host() + if host_module is None: + return GovernedStandInHost() + for _, module, _ in GOVERNED_COLUMNS: + importlib.import_module(module) + saved = list(sys.path) + try: + composite = host_module.profile() + for facet in BUILT_FACETS: + getattr(composite, facet) + finally: + sys.path[:] = saved + _refuse_an_openxdox_from_elsewhere() + return composite + + +@_pytest.fixture(autouse=True) +def _the_governed_host_for_the_governed_suites(request): + if plane_host_for(request.node.path.name) != "governed": + yield + return + with a_hosts_plane(governed_host): + yield diff --git a/tests/test_host_plane.py b/tests/test_host_plane.py index 5045cb5..9520b83 100644 --- a/tests/test_host_plane.py +++ b/tests/test_host_plane.py @@ -1,24 +1,36 @@ """The suites that read the console token run on a host's plane (plan 034 T086, -step (b); openDox-code's T104, RULED openxFactory#656 comment 5963851934). +step (b); openDox-code's T104, RULED openxFactory#656 comment 5963851934), and +the governed suites that drive openXdox's gate verbs run under the governed +host (plan 038 T020, U-1; R2Q8 (a)). `tests/conftest.py`'s `_a_hosts_plane_for_the_token_reading_suites` registers a stand-in host for the length of each test of the modules `HOST_PLANE_SUITES` names, because from T104 openDox publishes the console token on -`/capabilities` only on a host's plane. These cases hold that list and that -host to their reasons, and they run in a lone checkout, so in F9.1. +`/capabilities` only on a host's plane. Its +`_the_governed_host_for_the_governed_suites` registers the governed host, the +same way, for the modules `GOVERNED_HOST_SUITES` names. These cases hold both +lists and both hosts to their reasons, and they run in a lone checkout, so in +F9.1. None of them needs openxFactory: its host is stood in where a case needs +one. A CREATED file: no manifest row (RULED OQ-C). """ from __future__ import annotations +import ast import importlib.util import inspect import re +import sys +import types from pathlib import Path +import pytest + import conftest as tests_conftest from opendox import default_profile +from opendox import domain_profile as registry TESTS = Path(__file__).resolve().parent @@ -81,3 +93,191 @@ def test_on_the_stand_in_hosts_plane_the_token_stays_on_capabilities() -> None: source = inspect.getsource(serve_mod.build_server) assert "capabilities[CONSOLE_TOKEN_FIELD] = console_token" in source assert "delivery_for" not in source + + +# --------------------------------------------------------------------------- +# THE GOVERNED HOST (plan 038 T020, U-1). +# --------------------------------------------------------------------------- + +SOURCE = TESTS.parent / "src" + +#: A `gate` verb, as a test module spells it: the string `gate` and, after the +#: comma, the verb, the way an argument vector names a subcommand. +_GATE_VERB = re.compile(r"""["']gate["']\s*,\s*["']([a-z][a-z-]*)["']""") + + +def _gate_verbs() -> frozenset[str]: + """The verbs openXdox's `gate` command declares: every name + `src/openxdox/cli_gate.py` hands to `add_parser`, less `gate` itself. Read + from the source, so a lone checkout can read it without `doc_health`.""" + tree = ast.parse((SOURCE / "openxdox" / "cli_gate.py").read_text(encoding="utf-8")) + names = {node.args[0].value for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "add_parser" and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str)} + return frozenset(names - {"gate"}) + + +def _drives_a_gate_verb(path: Path, verbs: frozenset[str]) -> bool: + return any(verb in verbs + for verb in _GATE_VERB.findall(path.read_text(encoding="utf-8"))) + + +def test_the_governed_list_is_every_governed_suite_that_drives_a_gate_verb() -> None: + verbs = _gate_verbs() + assert {"create-document", "edit-document", "abandon-session"} <= verbs + governed = tests_conftest.governed_suites() + assert len(governed) >= 16, "12.5's floor: fewer means a proof vanished" + scanned = {name for name in governed + if _drives_a_gate_verb(TESTS / name, verbs)} + listed = set(tests_conftest.GOVERNED_HOST_SUITES) + assert scanned == listed, ( + f"drive a gate verb but are not listed: {sorted(scanned - listed)}; " + f"listed but drive none: {sorted(listed - scanned)}. A governed suite " + "(12.5's set) that drives openXdox's gate verbs needs a host carrying " + "openXdox's columns, so it joins tests/conftest.py's " + "GOVERNED_HOST_SUITES") + # The scan is 12.5's own set, and not every module that drives a verb: + # outside the governed set a suite registers its own host, or none. + assert any(_drives_a_gate_verb(path, verbs) + for path in TESTS.glob("test_*.py") if path.name not in governed) + + +def test_a_module_on_both_lists_runs_under_the_governed_host() -> None: + governed = tests_conftest.GOVERNED_HOST_SUITES + plane = tests_conftest.HOST_PLANE_SUITES + assert governed & plane, "no module is on both lists, so nothing tests the order" + for name in governed: + assert tests_conftest.plane_host_for(name) == "governed", name + for name in plane - governed: + assert tests_conftest.plane_host_for(name) == "stand-in", name + assert tests_conftest.plane_host_for(Path(__file__).name) is None + + +def _stand_in_columns(monkeypatch) -> list[str]: + """A module object for each of openXdox's governed columns, standing in for + the real one, which reaches `doc_health`. Each carries its named class, and + the harness's imports of them are recorded, in order, in the list returned. + Nothing else is imported differently, and `sys.modules` is not touched.""" + modules: dict[str, types.ModuleType] = {} + for _, module, name in tests_conftest.GOVERNED_COLUMNS: + stand_in = modules.setdefault(module, types.ModuleType(module)) + setattr(stand_in, name, type(name, (), {})) + imported: list[str] = [] + real_import = importlib.import_module + + def import_module(name: str, package: str | None = None): + if name in modules: + imported.append(name) + return modules[name] + return real_import(name, package) + + monkeypatch.setattr(tests_conftest, "importlib", + types.SimpleNamespace(import_module=import_module)) + return imported + + +def test_the_governed_stand_in_carries_openxdoxs_three_columns(monkeypatch) -> None: + """openXdox's share of openxFactory's profile: its gate verbs, and its gate + and projection routes. Each named class is defined where it is named.""" + assert tests_conftest.GOVERNED_COLUMNS == ( + ("SUBCOMMAND_EXTENSIONS", "openxdox.cli_gate", "GateSubcommands"), + ("ROUTE_EXTENSIONS", "openxdox.serve_gate", "GateRoutesExtension"), + ("ROUTE_EXTENSIONS", "openxdox.serve_projection", "ProjectionRoutesExtension"), + ) + for _, module, name in tests_conftest.GOVERNED_COLUMNS: + source = (SOURCE / Path(*module.split("."))).with_suffix(".py") + tree = ast.parse(source.read_text(encoding="utf-8")) + assert name in {node.name for node in tree.body + if isinstance(node, ast.ClassDef)}, (module, name) + + _stand_in_columns(monkeypatch) + monkeypatch.setitem(sys.modules, "opendox_host", None) # not composed + host = tests_conftest.governed_host() + assert isinstance(host, tests_conftest.GovernedStandInHost) + assert [type(c).__name__ for c in host.SUBCOMMAND_EXTENSIONS] == ["GateSubcommands"] + assert [type(c).__name__ for c in host.ROUTE_EXTENSIONS] == [ + "GateRoutesExtension", "ProjectionRoutesExtension"] + assert host.ROUTE_EXTENSIONS is host.ROUTE_EXTENSIONS + assert host is not default_profile + if importlib.util.find_spec("opendox.console_access") is not None: + from opendox import console_access + + assert (console_access.delivery_for(host) + == console_access.DELIVERY_CAPABILITIES) + + +class _Composite: + """openxFactory's composite, stood in. Reading a built facet records + whether openXdox's columns were already imported, and then does what + importing openxFactory's lane column does: puts a pinned leg at the head + of `sys.path`.""" + + def __init__(self, leg: Path, imported: list[str], on_read=None) -> None: + self.leg, self.imported, self.on_read, self.reads = leg, imported, on_read, [] + + def __getattr__(self, name: str): + if name not in tests_conftest.BUILT_FACETS: + raise AttributeError(name) + columns = {module for _, module, _ in tests_conftest.GOVERNED_COLUMNS} + self.reads.append((name, columns <= set(self.imported))) + if str(self.leg) not in sys.path: + sys.path.insert(0, str(self.leg)) + if self.on_read is not None: + self.on_read() + return () + + +def _composed(monkeypatch, composite: _Composite) -> None: + host_module = types.ModuleType("opendox_host") + host_module.profile = lambda: composite + monkeypatch.setitem(sys.modules, "opendox_host", host_module) + + +def test_composed_the_governed_host_is_the_composite_and_the_reach_is_put_back( + monkeypatch, tmp_path) -> None: + imported = _stand_in_columns(monkeypatch) + composite = _Composite(tmp_path / "openXdox" / "code" / "src", imported) + _composed(monkeypatch, composite) + before = list(sys.path) + earlier = tests_conftest.StandInHost() + with tests_conftest.a_hosts_plane(lambda: earlier): + with tests_conftest.a_hosts_plane(tests_conftest.governed_host) as host: + assert host is composite + assert registry.current() is composite + assert sys.path == before + assert registry.current() is earlier + assert [name for name, _ in composite.reads] == list(tests_conftest.BUILT_FACETS) + assert all(imported for _, imported in composite.reads), ( + "a facet was read before openXdox's columns were imported from this " + "checkout") + + +def test_an_openxdox_module_from_elsewhere_refuses_the_case(monkeypatch, tmp_path) -> None: + imported = _stand_in_columns(monkeypatch) + elsewhere = types.ModuleType("openxdox.from_a_pinned_leg") + elsewhere.__file__ = str(tmp_path / "openxdox" / "from_a_pinned_leg.py") + + def import_from_the_leg(): + monkeypatch.setitem(sys.modules, elsewhere.__name__, elsewhere) + + composite = _Composite(tmp_path / "leg", imported, on_read=import_from_the_leg) + _composed(monkeypatch, composite) + before = list(sys.path) + with pytest.raises(RuntimeError, match=r"openxdox\.from_a_pinned_leg"): + tests_conftest.governed_host() + assert sys.path == before + + +def test_openxfactorys_host_is_absent_only_when_its_own_module_is(monkeypatch, tmp_path) -> None: + monkeypatch.setitem(sys.modules, "opendox_host", None) + assert tests_conftest.openxfactory_host() is None + monkeypatch.delitem(sys.modules, "opendox_host") + (tmp_path / "opendox_host.py").write_text( + "import a_dependency_the_host_lacks\n", encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + with pytest.raises(ModuleNotFoundError) as refused: + tests_conftest.openxfactory_host() + assert refused.value.name == "a_dependency_the_host_lacks" From 073615822921fe7b562f0256607a39d5deb44116 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:01:10 +0000 Subject: [PATCH 02/10] T021, U-2: the gate console's schemas, each read where its owner keeps it, nothing vendored (plan 038) gate_console._validate_contract_document read both of its schemas from parents[2] / "contracts" / "schemas", which in this leg is the checkout root and holds no contracts/. Now each schema is read where its owner keeps it: * gate-action-record.schema.yaml, openXdox-spec's, from this distribution's own packaged copy, through contracts.verified_bytes(), which checks it against copies.yaml's digest first; * demotion-execution-receipt.schema.yaml, openxFactory's, from a source the HOST registers with the new register_contract_schema_source(). The same directory again is a no-op, a different one is refused, and a directory without the schema is refused when it is registered. unregister_contract_schema_source() and contract_schema_source_registered() complete the seam. With nothing registered, a receipt refuses, naming the call (R1Q27 (a)). Nothing is added to copies.yaml, and test_packaged_validator.py is unchanged and green. The seam sits near the end of the module, so that no line before it moves. tests/test_no_hardcoded_status_words.py pins seven prose f-strings in this module by line number. tests/conftest.py registers openxFactory's contracts/schemas/ from the composed tree, at process start, where the run is composed; a lone checkout registers none. tests/test_gate_console_schema_source.py runs the console in a child interpreter beside a stand-in doc_health, so it runs in a lone checkout. Composed, the 16 schema nodes and test_explicit_release_has_cli_and_http_parity turn green on T020's host. 12.5's suites read 92 red of the 174, and no green node turns red. Refs opensoft/openxFactory#656 (CLAIMED 6013608628). Lane: openXfactory-3 Co-Authored-By: Claude Opus 5.5 --- src/openxdox/gate_console.py | 138 ++++++++++++-- tests/conftest.py | 16 ++ tests/test_gate_console_schema_source.py | 217 +++++++++++++++++++++++ 3 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 tests/test_gate_console_schema_source.py diff --git a/src/openxdox/gate_console.py b/src/openxdox/gate_console.py index 640de0c..b6dbe45 100644 --- a/src/openxdox/gate_console.py +++ b/src/openxdox/gate_console.py @@ -631,22 +631,22 @@ 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).""" 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: + schema = yaml.safe_load(schema_text) + except yaml.YAMLError as exc: raise GateRefused( - f"the {label} schema could not be loaded from {schema_path}: " - f"{exc}") from exc + f"the {label} schema could not be parsed: {exc}") from exc errors = sorted( Draft202012Validator(schema, format_checker=FormatChecker()).iter_errors( dict(document)), @@ -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,120 @@ 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 _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..79152db --- /dev/null +++ b/tests/test_gate_console_schema_source.py @@ -0,0 +1,217 @@ +"""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_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}" From c40af91b9b78b37629c72715c10498cdbf051029 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:11:01 +0000 Subject: [PATCH 03/10] T022, U-3: the runbook placed from the composed tree, git-ignored, nothing vendored (plan 038) tests/test_session_runbook.py (its RUNBOOK) and tests/test_session_notebook.py read docs/ideation-dashboard-session-runbook.md at this checkout, where the runbook sat before the carve sent it to openDox-spec. The new scripts/composed_placements.py links it, for a composed run, from the composed tree (openDox/spec/docs/ under the openxFactory checkout) into docs/. .gitignore names the placed path, so the checkout stays clean and nothing placed can be committed. Before it places anything, the script checks four things and refuses (exit 2), naming the remedy, if any fails: * the spec checkout's HEAD is the commit the openDox root's spec gitlink pins; * the runbook is tracked there, unmodified; * the destination is ignored by .gitignore; * anything already at the destination is the script's own link to that same source. It places all or nothing. --remove takes back the script's own links and nothing else. The composed workflow (T029) and the documented local composed run both call it. Composed, the 10 runbook nodes turn green on T021's tree, 12.5's suites read 82 red of the 174, and no green node turns red. Refs opensoft/openxFactory#656 (CLAIMED 6013608959). Lane: openXfactory-3 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 + scripts/composed_placements.py | 207 +++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100755 scripts/composed_placements.py diff --git a/.gitignore b/.gitignore index 2fb4cee..2060e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ venv/ *.swp .idea/ .vscode/ + +# What scripts/composed_placements.py places for a composed run (plan 038 +# T022): linked from the composed tree, never committed. +/docs/ideation-dashboard-session-runbook.md diff --git a/scripts/composed_placements.py b/scripts/composed_placements.py new file mode 100755 index 0000000..ae22adf --- /dev/null +++ b/scripts/composed_placements.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Place, for a COMPOSED run, the files this checkout's suites read from the +composed tree (plan 038 T022, U-3; R2Q8 (a); P4F-3, ruled with plan 038 on +opensoft/openxFactory#656 comment 6013547504). + +WHY IT EXISTS. 12.5's falsifier runs openxFactory's governed suites in this +checkout, composed: openxFactory checked out with its submodules initialized +recursively, and its `scripts/` on `PYTHONPATH` (R1Q23 (a)). Two of those +suites read the session runbook at this checkout's +`docs/ideation-dashboard-session-runbook.md`, which is where it sat before the +carve: `tests/test_session_runbook.py` (its module constant `RUNBOOK`) and +`tests/test_session_notebook.py` (inline). The carve sent the runbook to +openDox-spec (`docs/opendox-carve-manifest.yaml`, in openxFactory), and this +checkout carries none. Neither suite may be edited outside an allow-list +entry, and a module constant lies inside no test. + +WHY A PLACEMENT AND NOT A COPY. openXdox-code's copy record +(`src/openxdox/contracts/copies.yaml`) is single-source: openXdox-spec, at one +commit, and the consumer validator's three kinds. An openDox-spec document +cannot be a row in it, and a second copy of it here would be one more copy to +keep in step. So the runbook is LINKED, for the run, from the composed tree, +where it is openDox-spec's own file at the commit the openDox root pins. The +placed path is in `.gitignore`, so the checkout stays clean and nothing placed +can be committed. + +WHAT IT CHECKS BEFORE IT PLACES ANYTHING, refusing (exit 2) and naming the +remedy otherwise: + * each source is a file openDox-spec TRACKS, UNMODIFIED, in a spec checkout + whose HEAD is the commit the openDox root's `spec` gitlink names. So the + run reads the pinned document, and not whatever a working tree holds; + * each destination is ignored by this checkout's `.gitignore`; + * a destination already present is this script's own link to that same + source. Anything else there is refused, never overwritten. +It places all or nothing: every check passes before the first link is made. + +USAGE, from anywhere: + + python3 scripts/composed_placements.py --openxfactory "$OPENXFACTORY" + python3 scripts/composed_placements.py --openxfactory "$OPENXFACTORY" --remove + +`--remove` takes back this script's own links and nothing else. The composed +workflow (T029) and the documented local composed run both call it before +they run the suites. The standard library and `git` are all it needs. + +A CREATED file: no row in openxFactory's `docs/opendox-carve-manifest.yaml` +(RULED OQ-C). +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +CHECKOUT = Path(__file__).resolve().parents[1] + +#: The openDox root inside the openxFactory checkout, and its `spec` leg. +OPENDOX_ROOT = PurePosixPath("openDox") +SPEC_LEG = PurePosixPath("spec") + + +@dataclass(frozen=True) +class Placement: + """One file the composed run places: `source`, a path inside openDox-spec, + linked to `destination`, a path inside this checkout.""" + + source: PurePosixPath + destination: PurePosixPath + + +#: Every placement, and the only ones. A new one comes with its reader's +#: reason here and its path in `.gitignore`. +PLACEMENTS: tuple[Placement, ...] = ( + # The session runbook: read by tests/test_session_runbook.py (`RUNBOOK`) + # and tests/test_session_notebook.py, at this checkout's `docs/`. + Placement(PurePosixPath("docs/ideation-dashboard-session-runbook.md"), + PurePosixPath("docs/ideation-dashboard-session-runbook.md")), +) + + +class PlacementRefused(Exception): + """A placement this script will not make, with the reason and the remedy.""" + + +def _git(*args: str, cwd: Path) -> subprocess.CompletedProcess: + try: + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, + text=True, check=False) + except FileNotFoundError as exc: + raise PlacementRefused( + "git is not on PATH, and the placements are checked with it") from exc + + +def _pinned_spec(openxfactory: Path) -> Path: + """The spec checkout, once its HEAD is the commit the openDox root pins.""" + root = openxfactory / OPENDOX_ROOT + spec = root / SPEC_LEG + pinned = _git("rev-parse", "--verify", f"HEAD:{SPEC_LEG}", cwd=root) \ + if root.is_dir() else None + if pinned is None or pinned.returncode != 0: + raise PlacementRefused( + f"{root} is not an openDox root checkout with a `spec` gitlink. " + "Check openxFactory out with its submodules initialized " + "recursively (`git submodule update --init --recursive`).") + head = _git("rev-parse", "--verify", "HEAD", cwd=spec) if spec.is_dir() else None + if head is None or head.returncode != 0: + raise PlacementRefused( + f"{spec} is not checked out. Initialize openxFactory's submodules " + "recursively (`git submodule update --init --recursive`).") + if head.stdout.strip() != pinned.stdout.strip(): + raise PlacementRefused( + f"{spec} is at {head.stdout.strip()}, and the openDox root pins " + f"{pinned.stdout.strip()}. The run must read the pinned document: " + "run `git submodule update --init --recursive` in the openxFactory " + "checkout.") + return spec + + +def _check(placement: Placement, spec: Path) -> tuple[Path, Path]: + """The placement's source and destination, once every check has passed.""" + source = spec / placement.source + tracked = _git("ls-files", "--error-unmatch", "--", str(placement.source), cwd=spec) + if tracked.returncode != 0 or not source.is_file(): + raise PlacementRefused( + f"openDox-spec does not track {placement.source} at its pinned " + "commit, so there is nothing pinned to place") + clean = _git("diff", "--quiet", "HEAD", "--", str(placement.source), cwd=spec) + if clean.returncode != 0: + raise PlacementRefused( + f"{source} differs from openDox-spec's pinned commit. The run must " + "read the pinned document; restore it with `git checkout -- " + f"{placement.source}` in {spec}.") + destination = CHECKOUT / placement.destination + ignored = _git("check-ignore", "-q", "--", str(placement.destination), + cwd=CHECKOUT) + if ignored.returncode != 0: + raise PlacementRefused( + f"{placement.destination} is not ignored by this checkout's " + ".gitignore, so a placed file could be committed. Name it there.") + if os.path.lexists(destination): + if not (destination.is_symlink() + and Path(os.readlink(destination)) == source): + raise PlacementRefused( + f"{placement.destination} already exists and is not this " + f"script's link to {source}. It is not overwritten; move it " + "away first.") + return source, destination + + +def place(openxfactory: Path) -> list[str]: + """Make every placement, all or nothing. Answers what was done.""" + spec = _pinned_spec(openxfactory.resolve()) + checked = [(placement, *_check(placement, spec)) for placement in PLACEMENTS] + done = [] + for placement, source, destination in checked: + if destination.is_symlink(): + done.append(f"already placed {placement.destination} -> {source}") + continue + destination.parent.mkdir(parents=True, exist_ok=True) + destination.symlink_to(source) + done.append(f"placed {placement.destination} -> {source}") + return done + + +def remove(openxfactory: Path) -> list[str]: + """Take back this script's own links, and nothing else.""" + spec = openxfactory.resolve() / OPENDOX_ROOT / SPEC_LEG + done = [] + for placement in PLACEMENTS: + destination = CHECKOUT / placement.destination + if not os.path.lexists(destination): + continue + if not (destination.is_symlink() + and Path(os.readlink(destination)) == spec / placement.source): + raise PlacementRefused( + f"{placement.destination} is not this script's link, so it " + "is not removed") + destination.unlink() + done.append(f"removed {placement.destination}") + return done + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Place, for a composed run, the files this checkout's " + "suites read from the composed tree (plan 038 T022).") + parser.add_argument("--openxfactory", required=True, type=Path, + help="the openxFactory checkout of the composed run, " + "its submodules initialized recursively") + parser.add_argument("--remove", action="store_true", + help="take back this script's own links") + args = parser.parse_args(argv) + try: + done = (remove if args.remove else place)(args.openxfactory) + except PlacementRefused as exc: + print(f"composed_placements: refused: {exc}", file=sys.stderr) + return 2 + for line in done: + print(f"composed_placements: {line}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From ecf0ba9bd4f2b54af66b7022ac1fc8c68005a89c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:20:19 +0000 Subject: [PATCH 04/10] T021: a host schema the console cannot apply refuses as GateRefused (Copilot r4194497045) A registered source's receipt schema was checked only for existence. A document that is valid YAML but no valid JSON Schema escaped as a raw exception: an empty file raised AttributeError, `required: 1` raised TypeError, and an unresolvable $ref raised jsonschema's referencing error. cli_gate's demotion reports only GateRefused, BoundaryViolation or OSError as "executed but receipt not written", after files have moved, so any of these ended the verb with a traceback. _schema_errors now parses the schema, checks it against the validator's own meta-schema, and applies it. A failure at any of the three refuses as GateRefused. _validate_contract_document keeps its line count, so no line that test_no_hardcoded_status_words.py's PROSE_EXEMPT pins moves. A new lone case covers the three schemas, and two more mutants are killed. Refs opensoft/openxFactory#656 (CLAIMED 6013608628). Lane: openXfactory-3 Co-Authored-By: Claude Opus 5.5 --- src/openxdox/gate_console.py | 49 ++++++++++++++++++++---- tests/test_gate_console_schema_source.py | 35 +++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/openxdox/gate_console.py b/src/openxdox/gate_console.py index b6dbe45..eba3773 100644 --- a/src/openxdox/gate_console.py +++ b/src/openxdox/gate_console.py @@ -636,20 +636,20 @@ def _validate_contract_document( """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).""" + 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 - try: - schema = yaml.safe_load(schema_text) - except yaml.YAMLError as exc: - raise GateRefused( - f"the {label} schema could not be parsed: {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( @@ -2440,6 +2440,39 @@ def _packaged_schema_text(copy_id: str, *, label: str) -> str: 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, and refused as `GateRefused` where it + is not YAML, where it is not a valid JSON Schema under the validator's own + meta-schema (an empty document, `required: 1`), and where 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.""" + from jsonschema.exceptions import SchemaError + + try: + schema = yaml.safe_load(schema_text) + except yaml.YAMLError as exc: + raise GateRefused( + f"the {label} schema could not be parsed: {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 + 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 diff --git a/tests/test_gate_console_schema_source.py b/tests/test_gate_console_schema_source.py index 79152db..2427cf0 100644 --- a/tests/test_gate_console_schema_source.py +++ b/tests/test_gate_console_schema_source.py @@ -205,6 +205,41 @@ def test_the_seam_holds_one_source_and_refuses_a_second(tmp_path) -> None: 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 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).""" + sources = {} + for name, text in {"empty": "", "not_a_schema": "required: 1\n", + "unresolvable": json.dumps({"$ref": "#/$defs/absent"})}.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"] + + 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 From 7a149b70c815514beca4aa6bd14a7653acb1060a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:40:44 +0000 Subject: [PATCH 05/10] T022: the placement script's guards held in CI, tests/test_composed_placements.py (Copilot r4194596317; holder ruling (a)) The holder's ruling on opensoft/openxFactory#656 (comment 6016356225, option (a)) adds tests/test_composed_placements.py to T022's files. The script changes the file system, so its guards now live in the suite CI runs, not in a scratch harness. Each case runs the script in a child interpreter, as the composed run calls it. The script under test is a copy of this checkout's script, inside a scratch checkout that carries this checkout's own .gitignore, and the tree it reads is a scratch openxFactory/openDox/spec tree pinned to commit A. No case writes into this checkout. 14 cases cover: * placing, with the checkout left clean; * the no-op of placing again; * --remove; * a spec off its pin; * a modified runbook; * an untracked runbook; * an uninitialized spec leg; * an uninitialized openDox root; * a destination .gitignore does not name; * no git; * a file, or a link elsewhere, at the destination, kept under both place and --remove. No case skips, and the file carries none of 12.5's governed-suite markers. Writing the uninitialized-leg case found a misreport in the script, now fixed. An uninitialized spec leg is an empty directory, so git answered for the openDox root above it, and the refusal read "spec is at ". The script now asks git about the leg only when the leg has its own .git, so the refusal reads "is not checked out" with the remedy. It refused before as well; only the message was wrong. Refs opensoft/openxFactory#656 (CLAIMED 6013608959). Lane: openXfactory-3 Co-Authored-By: Claude Opus 5.5 --- scripts/composed_placements.py | 5 +- tests/test_composed_placements.py | 276 ++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 tests/test_composed_placements.py diff --git a/scripts/composed_placements.py b/scripts/composed_placements.py index ae22adf..3e232af 100755 --- a/scripts/composed_placements.py +++ b/scripts/composed_placements.py @@ -105,7 +105,10 @@ def _pinned_spec(openxfactory: Path) -> Path: f"{root} is not an openDox root checkout with a `spec` gitlink. " "Check openxFactory out with its submodules initialized " "recursively (`git submodule update --init --recursive`).") - head = _git("rev-parse", "--verify", "HEAD", cwd=spec) if spec.is_dir() else None + # Its own `.git`: an uninitialized leg is an empty directory, and git + # would answer for the openDox root above it. + head = _git("rev-parse", "--verify", "HEAD", cwd=spec) \ + if (spec / ".git").exists() else None if head is None or head.returncode != 0: raise PlacementRefused( f"{spec} is not checked out. Initialize openxFactory's submodules " diff --git a/tests/test_composed_placements.py b/tests/test_composed_placements.py new file mode 100644 index 0000000..ee808f2 --- /dev/null +++ b/tests/test_composed_placements.py @@ -0,0 +1,276 @@ +"""`scripts/composed_placements.py`, held in CI (plan 038 T022, U-3; option (a) +of the holder's ruling on opensoft/openxFactory#656, comment 6016356225). + +WHY HERE. The script makes links in this checkout's working tree for a +composed run, and refuses (exit 2) whatever would let that run read anything +but the pinned document, or overwrite or take away anything but its own link. +A script that changes the file system keeps its guards in the suite CI runs, +so each guard below is a case. + +HOW. Each case runs the script as the composed run calls it: in a child +interpreter, against a scratch openxFactory tree whose openDox root pins +commit A of a scratch spec repository, where commit B changes the runbook. +The script it runs is a COPY of this checkout's script, inside a scratch +checkout that also carries a copy of this checkout's own `.gitignore`. The +script places relative to its own location, so no case writes into this +checkout. And the `.gitignore` the cases read is the one this checkout +commits, so a placement it stops ignoring turns the first case red. + +The standard library, `git` and pytest are all it needs; no case skips. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import pytest + +CHECKOUT = Path(__file__).resolve().parents[1] +SCRIPT = Path("scripts") / "composed_placements.py" + +#: The one placement: openDox-spec's session runbook, at the same path here. +RUNBOOK = "docs/ideation-dashboard-session-runbook.md" + + +def _environment(**overrides: str) -> dict[str, str]: + """The child's environment: no inherited `GIT_*` (a hook's `GIT_DIR` + would aim every call at another repository), no user or system git + configuration, and a fixed identity for the scratch commits.""" + environment = {key: value for key, value in os.environ.items() + if not key.startswith("GIT_")} + environment.update( + GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1", + GIT_AUTHOR_NAME="composed placements", GIT_AUTHOR_EMAIL="cases@invalid", + GIT_COMMITTER_NAME="composed placements", + GIT_COMMITTER_EMAIL="cases@invalid") + environment.update(overrides) + return environment + + +def _git(*args: str, cwd: Path) -> str: + return subprocess.run(["git", *args], cwd=cwd, env=_environment(), + check=True, capture_output=True, text=True).stdout.strip() + + +@dataclass +class Composed: + """The scratch checkout, the scratch composed tree, and the script.""" + + checkout: Path + openxfactory: Path + root: Path + spec: Path + + @property + def destination(self) -> Path: + return self.checkout / RUNBOOK + + @property + def source(self) -> Path: + return self.spec / RUNBOOK + + @property + def pinned(self) -> str: + return _git("rev-parse", "HEAD:spec", cwd=self.root) + + def run(self, *extra: str, **environment: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(self.checkout / SCRIPT), + "--openxfactory", str(self.openxfactory), *extra], + capture_output=True, text=True, env=_environment(**environment)) + + def refused(self, *extra: str, **environment: str) -> str: + """Run the script, hold it to a refusal, and answer what it said.""" + result = self.run(*extra, **environment) + assert result.returncode == 2, (result.returncode, result.stdout, + result.stderr) + assert result.stdout == "", result.stdout + assert result.stderr.startswith("composed_placements: refused: "), \ + result.stderr + return result.stderr + + +def _spec_repository(spec: Path) -> str: + """A spec repository whose commit A holds runbook A and whose commit B + changes it; checked out at A. Answers A.""" + spec.mkdir(parents=True) + _git("init", "-q", cwd=spec) + (spec / RUNBOOK).parent.mkdir(parents=True) + (spec / RUNBOOK).write_text("runbook A\n", encoding="utf-8") + _git("add", RUNBOOK, cwd=spec) + _git("commit", "-q", "-m", "A", cwd=spec) + commit_a = _git("rev-parse", "HEAD", cwd=spec) + (spec / RUNBOOK).write_text("runbook B\n", encoding="utf-8") + _git("commit", "-q", "-a", "-m", "B", cwd=spec) + _git("checkout", "-q", commit_a, cwd=spec) + return commit_a + + +def _opendox_root(root: Path, pinned: str) -> None: + """An openDox root whose `spec` gitlink pins `pinned`.""" + root.mkdir(parents=True, exist_ok=True) + _git("init", "-q", cwd=root) + _git("update-index", "--add", "--cacheinfo", f"160000,{pinned},spec", + cwd=root) + _git("commit", "-q", "-m", "the openDox root", cwd=root) + + +@pytest.fixture +def composed(tmp_path: Path) -> Composed: + checkout = tmp_path / "openXdox-code" + (checkout / SCRIPT).parent.mkdir(parents=True) + shutil.copyfile(CHECKOUT / SCRIPT, checkout / SCRIPT) + shutil.copyfile(CHECKOUT / ".gitignore", checkout / ".gitignore") + _git("init", "-q", cwd=checkout) + _git("add", ".gitignore", str(SCRIPT), cwd=checkout) + _git("commit", "-q", "-m", "the scratch checkout", cwd=checkout) + openxfactory = tmp_path / "openxFactory" + root = openxfactory / "openDox" + spec = root / "spec" + _opendox_root(root, _spec_repository(spec)) + return Composed(checkout, openxfactory, root, spec) + + +# --- what it places ------------------------------------------------------- + + +def test_a_pinned_unmodified_runbook_is_linked_and_leaves_the_checkout_clean( + composed): + result = composed.run() + assert result.returncode == 0, result.stderr + assert result.stdout == f"composed_placements: placed {RUNBOOK} -> " \ + f"{composed.source.resolve()}\n" + assert composed.destination.is_symlink() + assert Path(os.readlink(composed.destination)) == composed.source.resolve() + assert composed.destination.read_text(encoding="utf-8") == "runbook A\n" + # Ignored by the checkout's own `.gitignore`: nothing placed can be committed. + assert _git("status", "--porcelain", "--untracked-files=all", + cwd=composed.checkout) == "" + + +def test_placing_again_is_a_no_op(composed): + assert composed.run().returncode == 0 + result = composed.run() + assert result.returncode == 0, result.stderr + assert result.stdout.startswith(f"composed_placements: already placed {RUNBOOK}") + assert composed.destination.read_text(encoding="utf-8") == "runbook A\n" + + +def test_remove_takes_back_its_own_link_and_nothing_else(composed): + assert composed.run().returncode == 0 + result = composed.run("--remove") + assert result.returncode == 0, result.stderr + assert result.stdout == f"composed_placements: removed {RUNBOOK}\n" + assert not os.path.lexists(composed.destination) + assert composed.source.read_text(encoding="utf-8") == "runbook A\n" + # And with nothing placed, there is nothing to take back. + again = composed.run("--remove") + assert (again.returncode, again.stdout) == (0, ""), again.stderr + + +# --- what it refuses, placing nothing -------------------------------------- + + +def test_a_spec_checkout_off_the_opendox_roots_pin_is_refused(composed): + _git("checkout", "-q", "-", cwd=composed.spec) # commit B + said = composed.refused() + assert f"and the openDox root pins {composed.pinned}" in said + assert "git submodule update --init --recursive" in said + assert not os.path.lexists(composed.destination) + + +def test_a_modified_runbook_is_refused(composed): + composed.source.write_text("edited in the working tree\n", encoding="utf-8") + said = composed.refused() + assert "differs from openDox-spec's pinned commit" in said + assert not os.path.lexists(composed.destination) + + +def test_a_runbook_the_spec_does_not_track_is_refused(composed): + _git("rm", "-q", "--cached", RUNBOOK, cwd=composed.spec) + said = composed.refused() + assert f"openDox-spec does not track {RUNBOOK}" in said + assert not os.path.lexists(composed.destination) + + +def test_an_uninitialized_spec_leg_is_refused_as_not_checked_out(composed): + # What a composed checkout without `--recursive` holds: an empty directory, + # where git would answer for the openDox root above it. + shutil.rmtree(composed.spec) + composed.spec.mkdir() + said = composed.refused() + assert "is not checked out" in said + assert "git submodule update --init --recursive" in said + assert not os.path.lexists(composed.destination) + + +def test_an_uninitialized_opendox_root_is_refused(composed): + shutil.rmtree(composed.root) + composed.root.mkdir() + # An openxFactory checkout above it, whose own tree has no `spec`. + _git("init", "-q", cwd=composed.openxfactory) + (composed.openxfactory / "README.md").write_text("openxFactory\n", encoding="utf-8") + _git("add", "README.md", cwd=composed.openxfactory) + _git("commit", "-q", "-m", "openxFactory", cwd=composed.openxfactory) + said = composed.refused() + assert "is not an openDox root checkout with a `spec` gitlink" in said + assert not os.path.lexists(composed.destination) + + +def test_a_destination_the_gitignore_does_not_name_is_refused(composed): + gitignore = composed.checkout / ".gitignore" + text = gitignore.read_text(encoding="utf-8") + assert f"/{RUNBOOK}\n" in text, "this checkout's .gitignore names the runbook" + gitignore.write_text(text.replace(f"/{RUNBOOK}\n", ""), encoding="utf-8") + said = composed.refused() + assert "is not ignored by this checkout's .gitignore" in said + assert not os.path.lexists(composed.destination) + + +def test_without_git_on_path_it_refuses(composed, tmp_path): + nothing = tmp_path / "an-empty-PATH" + nothing.mkdir() + said = composed.refused(PATH=str(nothing)) + assert "git is not on PATH" in said + assert not os.path.lexists(composed.destination) + + +# --- what it never overwrites or takes away --------------------------------- + + +def _a_file_of_someone_elses(composed: Composed) -> None: + composed.destination.parent.mkdir(parents=True) + composed.destination.write_text("someone's own file\n", encoding="utf-8") + + +def _a_link_elsewhere(composed: Composed, tmp_path: Path) -> Path: + elsewhere = tmp_path / "another-runbook.md" + elsewhere.write_text("another runbook\n", encoding="utf-8") + composed.destination.parent.mkdir(parents=True) + composed.destination.symlink_to(elsewhere) + return elsewhere + + +@pytest.mark.parametrize("verb", [(), ("--remove",)], ids=["place", "remove"]) +def test_a_file_at_the_destination_is_refused_and_kept(composed, verb): + _a_file_of_someone_elses(composed) + said = composed.refused(*verb) + assert ("is not this script's link, so it is not removed" if verb + else "already exists and is not this script's link") in said + assert not composed.destination.is_symlink() + assert composed.destination.read_text(encoding="utf-8") == "someone's own file\n" + + +@pytest.mark.parametrize("verb", [(), ("--remove",)], ids=["place", "remove"]) +def test_a_link_elsewhere_at_the_destination_is_refused_and_kept(composed, + tmp_path, verb): + elsewhere = _a_link_elsewhere(composed, tmp_path) + said = composed.refused(*verb) + assert ("is not this script's link, so it is not removed" if verb + else "already exists and is not this script's link") in said + assert Path(os.readlink(composed.destination)) == elsewhere From dc879bebffb18294bf4117df3e6ffe1409d1128b Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:12:51 +0000 Subject: [PATCH 06/10] T022: the placement is ignored by the root .gitignore itself, never only by a local exclude (Copilot r4196244480; holder ruling 6026275158) git check-ignore honours .git/info/exclude and core.excludesFile, so the guard passed on a machine whose repository .gitignore no longer named the placement, and the placed link could then be committed from another checkout. The holder ruled the fix into T022, which owns the script (opensoft/openxFactory#656, comment 6026275158, item 2); Copilot raised it on #45, which stacks on this file. The guard now reads git check-ignore -v, which prints the deciding pattern as ::, with the source relative to the repository root. It requires that source to be .gitignore. It also refuses a negated pattern: -v exits 0 for a "!" pattern although the path is not ignored, which the old -q exit status had covered. tests/test_composed_placements.py adds four cases: * a destination only .git/info/exclude names: refused; * a destination only core.excludesFile names: refused; * a local exclude beside the .gitignore line still places, because the root .gitignore outranks it and stays the source; * a .gitignore that negates the destination: refused. Against the script before this commit, the two local-exclude cases fail (2 failed, 16 passed). With it, 18 passed. All 17 mutants are killed, each by its own case, including MP-any-ignore-source (the dropped source check) and MQ-a-negation-counts-as-ignored. Refs opensoft/openxFactory#656 (CLAIMED 6013608959). Lane: openXfactory-3 Co-Authored-By: Claude Opus 5.5 --- scripts/composed_placements.py | 20 +++++++++++-- tests/test_composed_placements.py | 49 ++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/scripts/composed_placements.py b/scripts/composed_placements.py index 3e232af..0ba42d8 100755 --- a/scripts/composed_placements.py +++ b/scripts/composed_placements.py @@ -28,7 +28,8 @@ * each source is a file openDox-spec TRACKS, UNMODIFIED, in a spec checkout whose HEAD is the commit the openDox root's `spec` gitlink names. So the run reads the pinned document, and not whatever a working tree holds; - * each destination is ignored by this checkout's `.gitignore`; + * each destination is ignored by this checkout's own `.gitignore`, not + only by a local exclude (`.git/info/exclude`, `core.excludesFile`); * a destination already present is this script's own link to that same source. Anything else there is refused, never overwritten. It places all or nothing: every check passes before the first link is made. @@ -137,12 +138,25 @@ def _check(placement: Placement, spec: Path) -> tuple[Path, Path]: "read the pinned document; restore it with `git checkout -- " f"{placement.source}` in {spec}.") destination = CHECKOUT / placement.destination - ignored = _git("check-ignore", "-q", "--", str(placement.destination), + # Ignored by the repository-root `.gitignore` itself, never by a local + # exclude (`.git/info/exclude`, `core.excludesFile`), which no other + # checkout carries. `-v` prints the deciding pattern as + # `::`, its source relative to the + # repository root, and exits 0 for a negated (`!`) pattern too. + verdict = _git("check-ignore", "-v", "--", str(placement.destination), cwd=CHECKOUT) - if ignored.returncode != 0: + ignored_by, _, rest = verdict.stdout.partition(":") + pattern = rest.partition(":")[2].partition("\t")[0] + if verdict.returncode != 0 or pattern.startswith("!"): raise PlacementRefused( f"{placement.destination} is not ignored by this checkout's " ".gitignore, so a placed file could be committed. Name it there.") + if ignored_by != ".gitignore": + raise PlacementRefused( + f"{placement.destination} is ignored only by {ignored_by}, a local " + "exclude that no other checkout carries, and not by this " + "checkout's .gitignore, so a placed file could be committed " + "elsewhere. Name it in .gitignore.") if os.path.lexists(destination): if not (destination.is_symlink() and Path(os.readlink(destination)) == source): diff --git a/tests/test_composed_placements.py b/tests/test_composed_placements.py index ee808f2..74585eb 100644 --- a/tests/test_composed_placements.py +++ b/tests/test_composed_placements.py @@ -222,11 +222,58 @@ def test_an_uninitialized_opendox_root_is_refused(composed): assert not os.path.lexists(composed.destination) -def test_a_destination_the_gitignore_does_not_name_is_refused(composed): +def _drop_the_gitignore_line(composed: Composed) -> None: gitignore = composed.checkout / ".gitignore" text = gitignore.read_text(encoding="utf-8") assert f"/{RUNBOOK}\n" in text, "this checkout's .gitignore names the runbook" gitignore.write_text(text.replace(f"/{RUNBOOK}\n", ""), encoding="utf-8") + + +def _exclude_locally(composed: Composed, where: str, tmp_path: Path) -> None: + """Name the runbook in a local exclude, which no other checkout carries.""" + if where == "info/exclude": + exclude = composed.checkout / ".git" / "info" / "exclude" + exclude.parent.mkdir(parents=True, exist_ok=True) + with exclude.open("a", encoding="utf-8") as handle: + handle.write(f"/{RUNBOOK}\n") + else: + excludes = tmp_path / "excludes" + excludes.write_text(f"/{RUNBOOK}\n", encoding="utf-8") + _git("config", "core.excludesFile", str(excludes), cwd=composed.checkout) + + +def test_a_destination_the_gitignore_does_not_name_is_refused(composed): + _drop_the_gitignore_line(composed) + said = composed.refused() + assert "is not ignored by this checkout's .gitignore" in said + assert not os.path.lexists(composed.destination) + + +@pytest.mark.parametrize("where", ["info/exclude", "core.excludesFile"]) +def test_a_destination_only_a_local_exclude_names_is_refused(composed, tmp_path, + where): + # Copilot r4196244480; the holder's ruling on opensoft/openxFactory#656, + # comment 6026275158, item 2: the root .gitignore must be the source. + _drop_the_gitignore_line(composed) + _exclude_locally(composed, where, tmp_path) + said = composed.refused() + assert "a local exclude that no other checkout carries" in said + assert not os.path.lexists(composed.destination) + + +def test_a_local_exclude_beside_the_gitignore_line_still_places(composed, + tmp_path): + # The root .gitignore outranks a local exclude, so it stays the source. + _exclude_locally(composed, "info/exclude", tmp_path) + result = composed.run() + assert result.returncode == 0, result.stderr + assert composed.destination.is_symlink() + + +def test_a_gitignore_that_negates_the_destination_is_refused(composed): + # `git check-ignore -v` exits 0 for a negated pattern too. + with (composed.checkout / ".gitignore").open("a", encoding="utf-8") as handle: + handle.write(f"!/{RUNBOOK}\n") said = composed.refused() assert "is not ignored by this checkout's .gitignore" in said assert not os.path.lexists(composed.destination) From 146ae923a17e8bfd72abf18737d4d5f6611cecf9 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:22:20 +0000 Subject: [PATCH 07/10] T022: ambient git repository variables do not redirect the placement's checks (Copilot r4200975244) Git honours GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE and their kin over the directory each check runs in, and it sets GIT_DIR and GIT_WORK_TREE for every hook it runs. Run from a hook, the script's pin, clean and ignore checks would have inspected another repository. _git now drops the same variables openxdox.domain_corpus_adapter's AMBIENT_GIT_VARIABLES drops, for every call. tests/test_composed_placements.py adds a regression case, clean and edited. It runs the script with GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE pointed at a decoy repository: * clean: the pinned runbook is placed; * edited: the edit is refused as "differs from openDox-spec's pinned commit". Against the script before this commit both cases fail (2 failed, 18 passed). With it, 20 passed. MR-ambient-git-variables-kept is killed, and all 18 mutants are killed. Refs opensoft/openxFactory#656 (CLAIMED 6013608959). Lane: openXfactory-3 Co-Authored-By: Claude Opus 5.5 --- scripts/composed_placements.py | 19 +++++++++++++++++-- tests/test_composed_placements.py | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/scripts/composed_placements.py b/scripts/composed_placements.py index 0ba42d8..952fecb 100755 --- a/scripts/composed_placements.py +++ b/scripts/composed_placements.py @@ -86,10 +86,25 @@ class PlacementRefused(Exception): """A placement this script will not make, with the reason and the remedy.""" +#: Git variables that select a repository, work tree or index. Git honours +#: them over the directory each check runs in, and it sets `GIT_DIR` and +#: `GIT_WORK_TREE` for every hook it runs, so a hook running this script would +#: point the pin, clean and ignore checks at another repository. They are +#: dropped for every call, as `openxdox.domain_corpus_adapter`'s +#: `AMBIENT_GIT_VARIABLES` drops them (Copilot r4200975244). +AMBIENT_GIT_VARIABLES = ( + "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", "GIT_PREFIX", +) + + def _git(*args: str, cwd: Path) -> subprocess.CompletedProcess: + environment = {key: value for key, value in os.environ.items() + if key not in AMBIENT_GIT_VARIABLES} try: - return subprocess.run(["git", *args], cwd=cwd, capture_output=True, - text=True, check=False) + return subprocess.run(["git", *args], cwd=cwd, env=environment, + capture_output=True, text=True, check=False) except FileNotFoundError as exc: raise PlacementRefused( "git is not on PATH, and the placements are checked with it") from exc diff --git a/tests/test_composed_placements.py b/tests/test_composed_placements.py index 74585eb..65839b6 100644 --- a/tests/test_composed_placements.py +++ b/tests/test_composed_placements.py @@ -287,6 +287,32 @@ def test_without_git_on_path_it_refuses(composed, tmp_path): assert not os.path.lexists(composed.destination) +@pytest.mark.parametrize("edited", [False, True], ids=["clean", "edited"]) +def test_ambient_git_repository_variables_do_not_redirect_the_checks( + composed, tmp_path, edited): + # Copilot r4200975244: git sets GIT_DIR and GIT_WORK_TREE for every hook, + # and honours them over the directory each check runs in. Pointed at a + # decoy repository, they must change nothing. + decoy = tmp_path / "decoy" + decoy.mkdir() + _git("init", "-q", cwd=decoy) + (decoy / "README.md").write_text("decoy\n", encoding="utf-8") + _git("add", "README.md", cwd=decoy) + _git("commit", "-q", "-m", "decoy", cwd=decoy) + ambient = {"GIT_DIR": str(decoy / ".git"), "GIT_WORK_TREE": str(decoy), + "GIT_INDEX_FILE": str(decoy / ".git" / "index")} + if edited: + composed.source.write_text("edited in the working tree\n", + encoding="utf-8") + said = composed.refused(**ambient) + assert "differs from openDox-spec's pinned commit" in said + assert not os.path.lexists(composed.destination) + else: + result = composed.run(**ambient) + assert result.returncode == 0, result.stderr + assert composed.destination.read_text(encoding="utf-8") == "runbook A\n" + + # --- what it never overwrites or takes away --------------------------------- From 74f3a4fb7059223db5b5a1a04f5fbe9a6bad4e45 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:48:21 +0000 Subject: [PATCH 08/10] T022: the placed bytes are the pinned blob, from a regular file in each leg's own work tree (Copilot r4201032934, r4201424060, r4201032886) Three findings Copilot raised at 146ae923 and c22b5df7. Four of the new cases were real bypasses against the script before this commit: it placed the link instead of refusing. * r4201032934: git diff calls a file clean without reading it where the index marks it assume-unchanged or skip-worktree. The check now compares git hash-object of the source with the pinned blob HEAD:. * r4201424060: a source that is a symbolic link is refused, whether the pinned commit tracks it as a link or it replaced the file. A link's bytes are not fixed by the pin. * r4201032886: the openDox root and the spec leg must each be their own work tree. Their own .git, and git rev-parse --show-toplevel resolving to the directory itself, as openxdox.domain_corpus_adapter checks. So an enclosing repository cannot answer HEAD:spec, and core.worktree cannot point the checks elsewhere. tests/test_composed_placements.py adds seven cases: * an edit under --assume-unchanged, and one under --skip-worktree; * a runbook the pinned commit tracks as a link, with its target edited; * a runbook replaced by a link to identical bytes; * an openDox root answered by an enclosing repository; * an openDox root without a spec gitlink; * a spec leg whose core.worktree is elsewhere. Against the script before this commit, six of them fail; four of those placed the link (rc 0). With it, 27 passed. All 21 mutants are killed, each by its own case, including the new MS (no symlink check), MU (root may be any repository), MV (toplevel not compared) and MB (content not compared). Refs opensoft/openxFactory#656 (CLAIMED 6013608959). Lane: openXfactory-3 Co-Authored-By: Claude Opus 5.5 --- scripts/composed_placements.py | 42 ++++++++++++---- tests/test_composed_placements.py | 80 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/scripts/composed_placements.py b/scripts/composed_placements.py index 952fecb..fd3900a 100755 --- a/scripts/composed_placements.py +++ b/scripts/composed_placements.py @@ -25,9 +25,11 @@ WHAT IT CHECKS BEFORE IT PLACES ANYTHING, refusing (exit 2) and naming the remedy otherwise: - * each source is a file openDox-spec TRACKS, UNMODIFIED, in a spec checkout - whose HEAD is the commit the openDox root's `spec` gitlink names. So the - run reads the pinned document, and not whatever a working tree holds; + * each source is a regular file (not a link) openDox-spec TRACKS whose + bytes are the pinned blob, in a spec checkout whose HEAD is the commit + the openDox root's `spec` gitlink names, each answered by its own + repository and work tree. So the run reads the pinned document, and not + whatever a working tree or an index flag says; * each destination is ignored by this checkout's own `.gitignore`, not only by a local exclude (`.git/info/exclude`, `core.excludesFile`); * a destination already present is this script's own link to that same @@ -110,21 +112,34 @@ def _git(*args: str, cwd: Path) -> subprocess.CompletedProcess: "git is not on PATH, and the placements are checked with it") from exc +def _is_its_own_work_tree(path: Path) -> bool: + """Git answers for `path` from `path`'s own repository, whose work tree is + `path`: not from a repository above it (an uninitialized submodule is an + empty directory) and not from a work tree its configuration puts + elsewhere (`core.worktree`). As `openxdox.domain_corpus_adapter` checks + `--show-toplevel` before it trusts a revision (Copilot r4201032886).""" + if not (path / ".git").exists(): + return False + top = _git("rev-parse", "--show-toplevel", cwd=path) + return (top.returncode == 0 + and Path(top.stdout.strip()).resolve() == path.resolve()) + + def _pinned_spec(openxfactory: Path) -> Path: """The spec checkout, once its HEAD is the commit the openDox root pins.""" root = openxfactory / OPENDOX_ROOT spec = root / SPEC_LEG pinned = _git("rev-parse", "--verify", f"HEAD:{SPEC_LEG}", cwd=root) \ - if root.is_dir() else None + if _is_its_own_work_tree(root) else None if pinned is None or pinned.returncode != 0: raise PlacementRefused( f"{root} is not an openDox root checkout with a `spec` gitlink. " "Check openxFactory out with its submodules initialized " "recursively (`git submodule update --init --recursive`).") - # Its own `.git`: an uninitialized leg is an empty directory, and git + # Its own work tree: an uninitialized leg is an empty directory, and git # would answer for the openDox root above it. head = _git("rev-parse", "--verify", "HEAD", cwd=spec) \ - if (spec / ".git").exists() else None + if _is_its_own_work_tree(spec) else None if head is None or head.returncode != 0: raise PlacementRefused( f"{spec} is not checked out. Initialize openxFactory's submodules " @@ -146,8 +161,19 @@ def _check(placement: Placement, spec: Path) -> tuple[Path, Path]: raise PlacementRefused( f"openDox-spec does not track {placement.source} at its pinned " "commit, so there is nothing pinned to place") - clean = _git("diff", "--quiet", "HEAD", "--", str(placement.source), cwd=spec) - if clean.returncode != 0: + if source.is_symlink(): + raise PlacementRefused( + f"{source} is a symbolic link. The run links only the regular " + "file openDox-spec tracks at its pinned commit, whose bytes a link " + "does not fix (Copilot r4201424060).") + # The bytes themselves against the pinned blob. `git diff` can call a file + # clean without reading it, where the index marks it `assume-unchanged` or + # `skip-worktree` (Copilot r4201032934). + pinned_blob = _git("rev-parse", "--verify", f"HEAD:{placement.source}", + cwd=spec) + on_disk = _git("hash-object", "--", str(placement.source), cwd=spec) + if (pinned_blob.returncode != 0 or on_disk.returncode != 0 + or pinned_blob.stdout.strip() != on_disk.stdout.strip()): raise PlacementRefused( f"{source} differs from openDox-spec's pinned commit. The run must " "read the pinned document; restore it with `git checkout -- " diff --git a/tests/test_composed_placements.py b/tests/test_composed_placements.py index 65839b6..d3aa1d4 100644 --- a/tests/test_composed_placements.py +++ b/tests/test_composed_placements.py @@ -313,6 +313,86 @@ def test_ambient_git_repository_variables_do_not_redirect_the_checks( assert composed.destination.read_text(encoding="utf-8") == "runbook A\n" +@pytest.mark.parametrize("flag", ["--assume-unchanged", "--skip-worktree"]) +def test_an_edit_the_index_is_told_to_overlook_is_refused(composed, flag): + # Copilot r4201032934: with either flag `git diff` calls the file clean + # without reading it. The check compares the bytes with the pinned blob. + _git("update-index", flag, RUNBOOK, cwd=composed.spec) + composed.source.write_text("edited in the working tree\n", encoding="utf-8") + said = composed.refused() + assert "differs from openDox-spec's pinned commit" in said + assert not os.path.lexists(composed.destination) + + +def _pin(composed: Composed, commit: str) -> None: + """Move the openDox root's `spec` gitlink to `commit`, and check it out.""" + _git("checkout", "-q", commit, cwd=composed.spec) + _git("update-index", "--cacheinfo", f"160000,{commit},spec", cwd=composed.root) + _git("commit", "-q", "-m", "pin", cwd=composed.root) + + +def test_a_runbook_the_pinned_commit_tracks_as_a_link_is_refused(composed): + # Copilot r4201424060: the pinned "document" is a link to a file whose + # bytes `git diff` on the link never reads. + real = composed.spec / "docs" / "real-runbook.md" + real.write_text("runbook A\n", encoding="utf-8") + composed.source.unlink() + composed.source.symlink_to("real-runbook.md") + _git("add", "-A", "docs", cwd=composed.spec) + _git("commit", "-q", "-m", "the runbook as a link", cwd=composed.spec) + _pin(composed, _git("rev-parse", "HEAD", cwd=composed.spec)) + real.write_text("edited behind the link\n", encoding="utf-8") + said = composed.refused() + assert "is a symbolic link" in said + assert not os.path.lexists(composed.destination) + + +def test_a_runbook_replaced_by_a_link_to_identical_bytes_is_refused(composed, + tmp_path): + # Identical bytes today; a link can be repointed after the check. + copy = tmp_path / "identical-runbook.md" + copy.write_text("runbook A\n", encoding="utf-8") + composed.source.unlink() + composed.source.symlink_to(copy) + said = composed.refused() + assert "is a symbolic link" in said + assert not os.path.lexists(composed.destination) + + +def test_an_opendox_root_answered_by_an_enclosing_repository_is_refused( + composed): + # Copilot r4201032886: without its own repository, git would answer + # `HEAD:spec` from the repository above, if its tree had a `spec` entry. + pinned = composed.pinned + shutil.rmtree(composed.root / ".git") + _git("init", "-q", cwd=composed.openxfactory) + _git("update-index", "--add", "--cacheinfo", f"160000,{pinned},spec", + cwd=composed.openxfactory) + _git("commit", "-q", "-m", "an enclosing repository", cwd=composed.openxfactory) + said = composed.refused() + assert "is not an openDox root checkout with a `spec` gitlink" in said + assert not os.path.lexists(composed.destination) + + +def test_an_opendox_root_without_a_spec_gitlink_is_refused(composed): + _git("rm", "-q", "--cached", "spec", cwd=composed.root) + _git("commit", "-q", "-m", "no spec leg", cwd=composed.root) + said = composed.refused() + assert "is not an openDox root checkout with a `spec` gitlink" in said + assert not os.path.lexists(composed.destination) + + +def test_a_spec_leg_whose_work_tree_is_configured_elsewhere_is_refused( + composed, tmp_path): + # Copilot r4201032886: `core.worktree` points the checks at another tree. + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + _git("config", "core.worktree", str(elsewhere), cwd=composed.spec) + said = composed.refused() + assert "is not checked out" in said + assert not os.path.lexists(composed.destination) + + # --- what it never overwrites or takes away --------------------------------- From 1f2d685844fb1a727e9d543e834b6cbda3d685d8 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:46:15 +0000 Subject: [PATCH 09/10] T022: the content check hashes the raw bytes, past clean filters (Copilot r4201850292) By default git hash-object applies the path's clean filters and EOL normalization, so a filter in the spec checkout (.git/info/attributes plus a filter..clean command, or the text attribute on a CRLF edit) turned edited bytes back into the pinned blob. Against the script before this commit, both cases PLACED the link. The check now runs hash-object --no-filters, so it compares the raw bytes the placed link exposes. tests/test_composed_placements.py adds test_an_edit_a_clean_filter_would_hide_is_refused[clean-filter|eol-normalization]. Both cases fail before the fix and pass with it: 29 passed. MW-hash-through-clean-filters is killed, and all 22 mutants are killed. Refs opensoft/openxFactory#656 (CLAIMED 6013608959). Lane: openXfactory-3 Co-Authored-By: Claude Opus 5.5 --- scripts/composed_placements.py | 8 ++++++-- tests/test_composed_placements.py | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/scripts/composed_placements.py b/scripts/composed_placements.py index fd3900a..79b2bf6 100755 --- a/scripts/composed_placements.py +++ b/scripts/composed_placements.py @@ -168,10 +168,14 @@ def _check(placement: Placement, spec: Path) -> tuple[Path, Path]: "does not fix (Copilot r4201424060).") # The bytes themselves against the pinned blob. `git diff` can call a file # clean without reading it, where the index marks it `assume-unchanged` or - # `skip-worktree` (Copilot r4201032934). + # `skip-worktree` (Copilot r4201032934). And `--no-filters`: by default + # `hash-object` applies the path's clean filters and EOL normalization, + # which can turn edited bytes back into the pinned blob (Copilot + # r4201850292). The raw bytes are what the placed link exposes. pinned_blob = _git("rev-parse", "--verify", f"HEAD:{placement.source}", cwd=spec) - on_disk = _git("hash-object", "--", str(placement.source), cwd=spec) + on_disk = _git("hash-object", "--no-filters", "--", str(placement.source), + cwd=spec) if (pinned_blob.returncode != 0 or on_disk.returncode != 0 or pinned_blob.stdout.strip() != on_disk.stdout.strip()): raise PlacementRefused( diff --git a/tests/test_composed_placements.py b/tests/test_composed_placements.py index d3aa1d4..e270de4 100644 --- a/tests/test_composed_placements.py +++ b/tests/test_composed_placements.py @@ -324,6 +324,27 @@ def test_an_edit_the_index_is_told_to_overlook_is_refused(composed, flag): assert not os.path.lexists(composed.destination) +@pytest.mark.parametrize("attribute", ["filter=hide", "text"], + ids=["clean-filter", "eol-normalization"]) +def test_an_edit_a_clean_filter_would_hide_is_refused(composed, attribute): + # Copilot r4201850292: `git hash-object` applies the path's clean filters + # by default, which can turn edited bytes back into the pinned blob. The + # check hashes the raw bytes the placed link exposes. + attributes = composed.spec / ".git" / "info" / "attributes" + attributes.parent.mkdir(parents=True, exist_ok=True) + attributes.write_text(f"/{RUNBOOK} {attribute}\n", encoding="utf-8") + if attribute == "filter=hide": + _git("config", "filter.hide.clean", "printf 'runbook A\\n'", + cwd=composed.spec) + edited = b"edited in the working tree\n" + else: + edited = b"runbook A\r\n" + composed.source.write_bytes(edited) + said = composed.refused() + assert "differs from openDox-spec's pinned commit" in said + assert not os.path.lexists(composed.destination) + + def _pin(composed: Composed, commit: str) -> None: """Move the openDox root's `spec` gitlink to `commit`, and check it out.""" _git("checkout", "-q", commit, cwd=composed.spec) From 2c86dccac9f6279222ff00691ab9637c4d272d41 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:49:17 +0000 Subject: [PATCH 10/10] T022: no link in any component below the spec leg (Copilot r4201912214) Path.is_symlink() looked only at the final component. With spec/docs replaced by a link to an outside directory holding identical bytes, the script PLACED a link to that outside file, which the pin does not fix. Every component from the spec leg down to the file is now checked, and a link anywhere is refused. tests/test_composed_placements.py adds test_a_runbook_reached_through_a_linked_directory_is_refused. It fails before the fix (placed, rc 0) and passes with it: 30 passed. MX-only-the-last-component is killed, and all 23 mutants are killed. Refs opensoft/openxFactory#656 (CLAIMED 6013608959). Lane: openXfactory-3 Co-Authored-By: Claude Opus 5.5 --- scripts/composed_placements.py | 15 ++++++++++----- tests/test_composed_placements.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/composed_placements.py b/scripts/composed_placements.py index 79b2bf6..69e1a78 100755 --- a/scripts/composed_placements.py +++ b/scripts/composed_placements.py @@ -161,11 +161,16 @@ def _check(placement: Placement, spec: Path) -> tuple[Path, Path]: raise PlacementRefused( f"openDox-spec does not track {placement.source} at its pinned " "commit, so there is nothing pinned to place") - if source.is_symlink(): - raise PlacementRefused( - f"{source} is a symbolic link. The run links only the regular " - "file openDox-spec tracks at its pinned commit, whose bytes a link " - "does not fix (Copilot r4201424060).") + # No link in ANY component below the spec leg, the file's or a parent + # directory's: a link's target is not fixed by the pin (Copilot + # r4201424060, r4201912214). + for depth in range(1, len(placement.source.parts) + 1): + part = spec.joinpath(*placement.source.parts[:depth]) + if part.is_symlink(): + raise PlacementRefused( + f"{part} is a symbolic link. The run links only the regular " + "file openDox-spec tracks at its pinned commit, reached " + "through no link, whose bytes a link does not fix.") # The bytes themselves against the pinned blob. `git diff` can call a file # clean without reading it, where the index marks it `assume-unchanged` or # `skip-worktree` (Copilot r4201032934). And `--no-filters`: by default diff --git a/tests/test_composed_placements.py b/tests/test_composed_placements.py index e270de4..741743c 100644 --- a/tests/test_composed_placements.py +++ b/tests/test_composed_placements.py @@ -380,6 +380,19 @@ def test_a_runbook_replaced_by_a_link_to_identical_bytes_is_refused(composed, assert not os.path.lexists(composed.destination) +def test_a_runbook_reached_through_a_linked_directory_is_refused(composed, + tmp_path): + # Copilot r4201912214: a link in ANY component below the spec leg. Here + # `docs/` points at an outside copy whose bytes are identical today. + external = tmp_path / "external-docs" + shutil.copytree(composed.spec / "docs", external) + shutil.rmtree(composed.spec / "docs") + (composed.spec / "docs").symlink_to(external, target_is_directory=True) + said = composed.refused() + assert "is a symbolic link" in said + assert not os.path.lexists(composed.destination) + + def test_an_opendox_root_answered_by_an_enclosing_repository_is_refused( composed): # Copilot r4201032886: without its own repository, git would answer