From 343c1ba823e775fe58dc28cb7b62b1b2660e5d00 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:46:14 +0000 Subject: [PATCH 01/44] Pin openDox-code e3ef506a, the head of openDox-code#59 (T055), for plan 034 T059 T059 moves the opendox pin to the phase-2 openDox-code commit (5.4a, 9.5 step 3). That commit is T062's, and it lands after every phase-2 openDox-code landing, so this draft builds against openDox-code#59's head, the last of the seams T059 registers at, and re-points to T062's commit before it lands. The comments that named the old pin as the one this leg declares are brought to the new one, each measured there: ViewBinding at e3ef506a still has styles and exports (dataclasses.fields in a venv at that pin), and the tuple and helpers tests/integration/test_assembled_bundle.py copies are unchanged between 2d116415 and e3ef506a. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 2 +- src/openxdox/view_extensions.py | 15 ++++++++------- tests/integration/test_assembled_bundle.py | 2 +- tests/test_gate_loop_probes.py | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4343bbf..26cbb32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,7 @@ requires-python = ">=3.12" # PR) — and not an arbitrary choice: `Draft202012Validator`, the name # `gate_console.py:563` imports, was added in jsonschema 4.18.0. dependencies = [ - "opendox @ git+https://github.com/opensoft/openDox-code@2d116415159b721b55613fe20a159afc46202d1f", + "opendox @ git+https://github.com/opensoft/openDox-code@e3ef506a8036c1360d88ff75cab50f8b8e5d5fca", "PyYAML>=6.0", "jsonschema>=4.18", ] diff --git a/src/openxdox/view_extensions.py b/src/openxdox/view_extensions.py index ef3b9b8..e3b2c9b 100644 --- a/src/openxdox/view_extensions.py +++ b/src/openxdox/view_extensions.py @@ -23,23 +23,24 @@ `a99eba03` and this paragraph said the pinned commit "is older than the view registry itself — `opendox.view_extension` does not exist there, and the `exports` field RULED Q2 adds is newer still". THAT IS NO LONGER TRUE, and the -old wording is quoted here as provenance rather than deleted: the pin now names -openDox-code#55 (`2d116415`, plan 034 T037's landing, since plan 034 T040), -where `view_extension` is importable and `ViewBinding` takes `exports` — +old wording is quoted here as provenance rather than deleted: the pin named +openDox-code#55 (`2d116415`, plan 034 T037's landing) from plan 034 T040, and +names openDox-code `e3ef506a` (the head of openDox-code#59, T055) since plan 034 +T059; at both `view_extension` is importable and `ViewBinding` takes `exports` — measured, and the three materialization assertions in `tests/test_gate_loop_views.py` run and pass against it instead of skipping. It first became true at `0b4e8bbf` (openDox-code#23, § 3.4 slice S8 leg B), which is where that wording was corrected; the pin then crossed `0e65b5f8` (#24) to `5c137a90` (openDox-code#27, § 3.4 RULED Q7), whose `ViewBinding` first carried a `styles` field — absent at `0b4e8bbf`, present at `5c137a90` -and still at `2d116415`, measured by `dataclasses.fields()` in a venv at each -pin. THE `5c137a90` BUMP ITSELF READ NOTHING, and the review of `ea6991b` was +and still at `2d116415` and at `e3ef506a`, measured by `dataclasses.fields()` +in a venv at each pin. THE `5c137a90` BUMP ITSELF READ NOTHING, and the review of `ea6991b` was right to check that: it materialized `VIEW_BINDING_SPECS` unchanged and asked nothing about the installed `ViewBinding`. THE READING IS THIS ACT'S, and this act is the pull request that bump named as waiting on it: `specs_for()` below reads `dataclasses.fields(binding_cls)` and drops `styles` where the installed dataclass has no such field. MEASURED IN A VENV AT THAT PIN, and again at -`2d116415`: it has one, so nothing is dropped, every binding that owns +`2d116415` and at `e3ef506a`: it has one, so nothing is dropped, every binding that owns selectors declares its sheet, and the four contributed stylesheets are LIVE rather than inert — which is the one thing they waited on that bump for. @@ -518,7 +519,7 @@ def specs_for(binding_cls: Any) -> tuple[dict[str, Any], ...]: So the field is DROPPED where the installed class does not take it and the column mounts unstyled. AT THE PIN THIS LEG DECLARES TODAY THE DETECTION IS THE PLAIN PATH, not a fallback: `dataclasses.fields()` finds `styles` on - `2d116415`'s `ViewBinding`, as on `5c137a90`'s where the field first + `e3ef506a`'s `ViewBinding`, as on `2d116415`'s and on `5c137a90`'s where the field first reached the pin, every spec crosses whole, and the four contributed sheets are LIVE — same code, same behaviour, one branch not taken. This paragraph read "the pin bump that follows openDox-code's Q7 leg turns the sheets on with no edit here"; that bump landed, and that is what diff --git a/tests/integration/test_assembled_bundle.py b/tests/integration/test_assembled_bundle.py index 2e8bb19..158b181 100644 --- a/tests/integration/test_assembled_bundle.py +++ b/tests/integration/test_assembled_bundle.py @@ -31,7 +31,7 @@ `_ST_DECLARATION` and `_declared_st_tokens`) and the `GATE_EXCLUSIVE` tuple are openDox-code's text at `55194335`, the last commit that carried all five, byte for byte. The tuple and the three helpers openDox-code kept are unchanged at -`2d116415`. Their comments are kept too, so "Copilot review, round N" in them +`2d116415` and at `e3ef506a`. Their comments are kept too, so "Copilot review, round N" in them is a round on openDox-code#27, where that file was written. Four things changed, each because this is the composition and not a lone leg: 1. A missing assembly FAILS here, where it skipped there. The composition is diff --git a/tests/test_gate_loop_probes.py b/tests/test_gate_loop_probes.py index e1252ed..7355bc0 100644 --- a/tests/test_gate_loop_probes.py +++ b/tests/test_gate_loop_probes.py @@ -194,7 +194,7 @@ def bundle(tmp_path) -> Path: # Its four-row table is there, not restated here. Round 1 of the review on # #21 found this file claiming the install "came from somewhere older than # the declared pin" on a check that only tested for a marker. UNDER THAT - # CHECK, had the DECLARED leg (`0b4e8bbf` then, `2d116415` now) itself ever + # CHECK, had the DECLARED leg (`0b4e8bbf` then, `e3ef506a` now) itself ever # stopped shipping `web/**`, all thirteen # probes below would have skipped and this required check would have stayed # green over the regression. UNDER THE TABLE THEY OBEY NOW THEY FAIL: the From bc03c00753203cb5084f6ce29e0c6769aa1f85b2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:46:43 +0000 Subject: [PATCH 02/44] Seal the four gaps in the governed registry and writer before they are registered (plan 034 T059) openDox's own defaults sealed each of these in openDox-code#59's review rounds, and #59's body names them as openXdox-code's to seal before T059 registers this leg's mechanisms at the same seams: 1. snapshot_registry.resolve_within applied the hidden-name rule only to the path as the URL spells it, so a symlink inside the root led to what the rule refuses by name (r4125556296). It now applies the rule to the canonical path too. 2. SnapshotRegistry.drop left the active key naming a dropped entry, which also kept every later entry from becoming active. Dropping the active entry now clears the key. 3. snapshot.write_snapshot wrote in place (r4126138808), and canonical_json wrote NaN and Infinity (r4125900060). The write now goes to an exclusive temporary sibling, keeps the target's permission bits, is fsynced, and moves over the target in one os.replace; a value JSON cannot carry is refused as SnapshotNotWritable, a ProjectionSeamError, with nothing written. 4. SnapshotRegistry read without its lock (r4136863481). Every read now holds it. snapshot_registry also reads doc_health's pin_sentinels where it writes the sentinel, in SnapshotEntry.index_entry, rather than at module level, so the registration at openDox's registry seam can be made in a lone checkout. tests/test_governed_registry_and_writer.py holds each gap, red before this commit. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- src/openxdox/snapshot.py | 78 ++++++- src/openxdox/snapshot_registry.py | 92 ++++++-- tests/test_governed_registry_and_writer.py | 240 +++++++++++++++++++++ 3 files changed, 387 insertions(+), 23 deletions(-) create mode 100644 tests/test_governed_registry_and_writer.py diff --git a/src/openxdox/snapshot.py b/src/openxdox/snapshot.py index bad36e1..78f2c18 100644 --- a/src/openxdox/snapshot.py +++ b/src/openxdox/snapshot.py @@ -8,7 +8,9 @@ date by the generator. List ordering is the generator's responsibility. Writes go through the interactivity boundary (never around it): `write_snapshot` -takes an `OutputBoundary` and writes only under a declared output path. +takes an `OutputBoundary` and writes only under a declared output path. The +write is atomic, and a value JSON cannot carry is refused rather than written +(plan 034 T059: this module is openXdox's writer at openDox's writer seam). Validation is DELEGATED to this product's own validator, in this repository (`scripts/validate-ideation-dashboard-contracts.py`) — the schema is never @@ -19,14 +21,20 @@ from __future__ import annotations +import contextlib import json +import os +import stat import subprocess import sys import tomllib +import uuid from dataclasses import dataclass from pathlib import Path from typing import Any +from opendox import projection_seams + # RELATIVE TO THIS PRODUCT'S OWN ROOT — the repository this module ships in — # never to an aggregation checkout above it. openxFactory's § 5.2 shed # (`cc4ae9d3`) deleted `openxFactory/scripts/validate-ideation-dashboard-contracts.py` @@ -42,13 +50,37 @@ class SnapshotInvalid(Exception): """A rendered snapshot failed the pinned validator (or it could not run).""" +class SnapshotNotWritable(projection_seams.ProjectionSeamError, ValueError): + """The snapshot holds a value JSON cannot carry, so nothing is written. + + A `ProjectionSeamError`, because this module is openXdox's writer at + openDox's writer seam, and openDox's generate verbs report that family as a + refusal. A `ValueError` too, which is what `json` itself raises for the + same value.""" + + # --------------------------- canonical serialization --------------------------- def canonical_json(snapshot: dict[str, Any]) -> str: """Deterministic, diffable JSON: sorted keys, 2-space indent, trailing newline (the house canonical-render discipline — see doc_health/runner.py, - execution_lane/bundler.py).""" - return json.dumps(snapshot, indent=2, sort_keys=True, ensure_ascii=True) + "\n" + execution_lane/bundler.py). + + NOTHING JSON CANNOT CARRY (plan 034 T059). NaN and the infinities are + refused (`allow_nan=False`) rather than written as the `NaN` and + `Infinity` that Python's `json` otherwise emits, which no JSON reader + parses: not the server's, not a browser's, not the validator's. So are a + value of no JSON type and a structure that contains itself. openDox's own + writer refuses the same values (openDox-code#59, r4125900060).""" + try: + return json.dumps(snapshot, indent=2, sort_keys=True, ensure_ascii=True, + allow_nan=False) + "\n" + except (TypeError, ValueError) as exc: + raise SnapshotNotWritable( + f"the snapshot holds a value JSON cannot carry ({exc}). NaN, the " + "infinities and values of no JSON type have no JSON spelling, and " + "a file carrying one would be one no JSON reader parses, so " + "nothing was written") from exc def canonical_bytes(snapshot: dict[str, Any]) -> bytes: @@ -61,8 +93,44 @@ def load_snapshot(path: Path | str) -> dict[str, Any]: def write_snapshot(snapshot: dict[str, Any], path: Path | str, boundary) -> Path: """Render canonically and write through the interactivity boundary. Every - snapshot write lands under the boundary's declared output allowlist.""" - return boundary.write_output(path, canonical_json(snapshot)) + snapshot write lands under the boundary's declared output allowlist. + + ATOMICALLY (plan 034 T059). openDox's server answers `/snapshot.json` on + threads of its own while a refresh rewrites the very snapshot it serves, + and a write in place (truncate, then write) let a request read a truncated + file. So the bytes go to a temporary sibling first, and one `os.replace` + moves them over the target: a reader sees the whole old snapshot or the + whole new one, never part of either. openDox's own writer does the same + (openDox-code#59, r4126138808). + + THE BOUNDARY STILL DECIDES THE DESTINATION. `permit_output` is the check + `write_output` makes, root and allowlist, with its refusal and its ledger, + and it runs first, so a refused target leaves nothing behind. The + rendering runs before it, so a snapshot JSON cannot carry is refused with + nothing written either. The sibling is created exclusively beside the + permitted target, with the mode an ordinary write would give it, or with + the target's own permission bits where the target exists, so a refresh + never widens a restricted snapshot. Its name is a dot-file with no + document extension, so `/source` never serves it, and it is removed if the + write or the move fails.""" + data = canonical_json(snapshot).encode("ascii") + target = boundary.permit_output(path) + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666) + try: + with os.fdopen(descriptor, "wb") as stream: + with contextlib.suppress(FileNotFoundError): + os.fchmod(stream.fileno(), stat.S_IMODE(os.stat(target).st_mode)) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(temporary) + raise + return target # --------------------------- validator location --------------------------- diff --git a/src/openxdox/snapshot_registry.py b/src/openxdox/snapshot_registry.py index 6fdfbaa..6897939 100644 --- a/src/openxdox/snapshot_registry.py +++ b/src/openxdox/snapshot_registry.py @@ -82,7 +82,16 @@ # `scripts/` and the path insertion above already reaches it, so the spelling # comes from the declaration that a verification guarding on the exact string # reads. -from doc_health import pin_sentinels # noqa: E402 +# +# IT IS IMPORTED WHERE IT IS READ, in `SnapshotEntry.index_entry` (plan 034 +# T059). This module is openXdox's contribution at openDox's snapshot registry +# seam (`openxdox.projection_contributions`), and openDox probes a +# registration's names when it is made. `pin_sentinels` is this module's one +# reach into openxFactory's `doc_health`, which a lone openXdox-code checkout +# does not carry (R1Q6 (d), openxFactory#656 comment 5817152735). Read at +# module level, it made the registration itself fail there, and with it every +# process that registers a profile. Read in `index_entry`, it fails where the +# sentinel is written, on `doc_health`, as before, and nowhere else. DEFAULT_REF = "main" INDEX_KIND = "ideation-dashboard-snapshot-index" @@ -293,6 +302,8 @@ def index_entry(self) -> dict: and whether the snapshot's own generation lacked a revision, could not fetch one, or never recorded one is not knowable from here. Writing a stronger member would assert a condition nobody established.""" + from doc_health import pin_sentinels + out: dict[str, Any] = { "repository": self.repository, "ref": self.ref, @@ -401,17 +412,24 @@ def resolve_within(root: Path, url_tail: str) -> Path | None: A `.git` (or any dot-directory) named as the LAST component is refused by the `is_file()` check below, so the first half needs no special case for - it.""" + it. + + THE RULE IS APPLIED TWICE (plan 034 T059): to the path as the URL spells + it, and to the CANONICAL path, relative to `root`, once symlinks are + resolved. The spelling alone let a symlink inside the root lead to what the + rule refuses by name: `link -> .git` served `/source/link/config`, which is + `.git/config`, and `notes.md -> .env` served `.env`. Both resolve inside + the root, to a regular file, so confinement never saw them. openDox's own + default registry closed the same gap in its review round + (openDox-code#59, r4125556296), and this module is registered at the same + seam (`openxdox.projection_contributions`).""" rel = urllib.parse.unquote(url_tail) rel = rel.split("?", 1)[0].split("#", 1)[0] if not rel or rel.startswith("/") or "\x00" in rel: return None parts = [p for p in rel.replace("\\", "/").split("/") if p not in ("", ".")] - if any(p.startswith(".") and p != ".." for p in parts[:-1]): + if _names_something_hidden(parts): return None - if parts and parts[-1].startswith(".") and parts[-1] != "..": - if PurePosixPath(parts[-1]).suffix not in SERVED_DOTFILE_SUFFIXES: - return None root = Path(root).resolve() try: resolved = (root / rel).resolve() @@ -419,11 +437,25 @@ def resolve_within(root: Path, url_tail: str) -> Path | None: return None if resolved != root and not resolved.is_relative_to(root): return None + if _names_something_hidden(resolved.relative_to(root).parts): + return None if not resolved.is_file(): return None return resolved +def _names_something_hidden(parts: list[str] | tuple[str, ...]) -> bool: + """Whether a relative path's components name what `/source` never serves: + a dot-directory anywhere but the last component, or a last component that + is a dot-file without a projected extension. `..` is not a name, and the + escape check decides it.""" + if any(p.startswith(".") and p != ".." for p in parts[:-1]): + return True + last = parts[-1] if parts else "" + return (last.startswith(".") and last != ".." + and PurePosixPath(last).suffix not in SERVED_DOTFILE_SUFFIXES) + + # --------------------------- the registry --------------------------- @dataclass @@ -502,32 +534,55 @@ def register_aggregate(self, aggregate: Aggregate) -> Aggregate: return aggregate def drop(self, repository: str, ref: str | None = None) -> None: + """Remove an entry. Dropping the ACTIVE entry clears the active key + (plan 034 T059), so no ref-less request meets a key with nothing + behind it, and the next entry registered becomes active, as the first + one did. Left set, the stale key also kept every later registration + from becoming active, since `register` promotes only while nothing is, + so the registry held entries and answered no active one.""" with self._lock: - self._entries.pop(snapshot_key(repository, ref), None) + key = snapshot_key(repository, ref) + self._entries.pop(key, None) + if self._active == key: + self._active = None # ---- lookup ---- + # + # EVERY READ HOLDS THE LOCK (plan 034 T059). A block `atomically()` holds + # is a read-modify-write, and a reader that did not wait for it could + # answer from its middle: the active key moved and not yet put back, for + # one. openDox's own default registry closed the same gap in its review + # round (openDox-code#59, r4136863481). The lock is re-entrant, so a read + # inside a held block, or inside another read, is safe. def get(self, repository: str, ref: str | None = None) -> SnapshotEntry | None: """Ref-less lookups resolve to `main` (D4).""" - return self._entries.get(snapshot_key(repository, ref)) + key = snapshot_key(repository, ref) + with self._lock: + return self._entries.get(key) def entries(self) -> list[SnapshotEntry]: """Registered entries, ordered by (repository, ref) — a stable roster.""" - return [self._entries[k] for k in sorted(self._entries)] + with self._lock: + return [self._entries[k] for k in sorted(self._entries)] def aggregates(self) -> list[Aggregate]: - return [self._aggregates[k] for k in sorted(self._aggregates)] + with self._lock: + return [self._aggregates[k] for k in sorted(self._aggregates)] def keys(self) -> list[tuple[str, str]]: - return sorted(self._entries) + with self._lock: + return sorted(self._entries) def __len__(self) -> int: - return len(self._entries) + with self._lock: + return len(self._entries) @property def active(self) -> SnapshotEntry | None: - if self._active is None: - return None - return self._entries.get(self._active) + with self._lock: + if self._active is None: + return None + return self._entries.get(self._active) def set_active(self, repository: str, ref: str | None = None) -> SnapshotEntry | None: with self._lock: @@ -540,9 +595,10 @@ def resolve(self, repository: str | None, ref: str | None = None) -> SnapshotEnt """The one resolution every route uses: a named pair, or the ACTIVE entry when no repository is named (which is what `/snapshot.json` with no query means — today's behaviour, unchanged).""" - if repository is None or repository == "": - return self.active - return self.get(repository, ref) + with self._lock: + if repository is None or repository == "": + return self.active + return self.get(repository, ref) # ---- per-entry source confinement (task 2.2) ---- def resolve_source(self, repository: str | None, ref: str | None, tail: str) -> Path | None: diff --git a/tests/test_governed_registry_and_writer.py b/tests/test_governed_registry_and_writer.py new file mode 100644 index 0000000..b358276 --- /dev/null +++ b/tests/test_governed_registry_and_writer.py @@ -0,0 +1,240 @@ +"""Four gaps in openXdox's governed registry and writer, closed before they are +registered at openDox's seams (plan 034 T059). + +openDox's own defaults closed each of these in openDox-code#59's review +rounds, and #59's body lists them as openXdox-code's to close before T059 +registers this leg's mechanisms: + +1. `snapshot_registry.resolve_within` met the hidden-name rule only as the URL + spells a path, so a symlink inside the root led to what the rule refuses by + name (r4125556296): `link -> .git` served `.git/config`. +2. `SnapshotRegistry.drop` left the active key naming a dropped entry. +3. `snapshot.write_snapshot` wrote in place, so a reader could meet a + truncated snapshot (r4126138808), and `canonical_json` wrote NaN and + Infinity, which no JSON reader parses (r4125900060). +4. `SnapshotRegistry` read without its lock, so a reader could answer from the + middle of a block `atomically()` holds (r4136863481). + +Each case below is red against the code before T059. + +A CREATED file: no manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import json +import math +import os +import threading +import time +from pathlib import Path + +import pytest + +from opendox import projection_seams +from opendox.boundary import OutputBoundary +from openxdox import snapshot as snapshot_mod +from openxdox import snapshot_registry as reg + + +# -------------------------------------------------------------------------- +# 1: the hidden-name rule meets the canonical path too +# -------------------------------------------------------------------------- + +@pytest.fixture +def served_root(tmp_path: Path) -> Path: + root = tmp_path / "checkout" + (root / ".git").mkdir(parents=True) + (root / ".git" / "config").write_text("[remote]\n", encoding="utf-8") + (root / ".env").write_text("TOKEN=x\n", encoding="utf-8") + (root / "docs").mkdir() + (root / "docs" / "note.md").write_text("# a note\n", encoding="utf-8") + return root + + +def test_a_symlink_to_a_dot_directory_serves_nothing(served_root: Path) -> None: + (served_root / "link").symlink_to(served_root / ".git", target_is_directory=True) + assert reg.resolve_within(served_root, "link/config") is None + + +def test_a_symlink_to_a_hidden_file_serves_nothing(served_root: Path) -> None: + (served_root / "notes.md").symlink_to(served_root / ".env") + assert reg.resolve_within(served_root, "notes.md") is None + + +def test_a_symlink_to_a_document_is_still_served(served_root: Path) -> None: + (served_root / "alias.md").symlink_to(served_root / "docs" / "note.md") + assert reg.resolve_within(served_root, "alias.md") == ( + served_root / "docs" / "note.md").resolve() + assert reg.resolve_within(served_root, "docs/note.md") == ( + served_root / "docs" / "note.md").resolve() + + +def test_the_spelled_rule_still_refuses_first(served_root: Path) -> None: + assert reg.resolve_within(served_root, ".git/config") is None + assert reg.resolve_within(served_root, "%2egit/config") is None + assert reg.resolve_within(served_root, ".env") is None + + +# -------------------------------------------------------------------------- +# 2: dropping the active entry clears the active key +# -------------------------------------------------------------------------- + +def _entry(repository: str, ref: str = "main") -> reg.SnapshotEntry: + return reg.SnapshotEntry(repository=repository, ref=ref) + + +def test_dropping_the_active_entry_leaves_no_active_key() -> None: + registry = reg.SnapshotRegistry() + registry.register(_entry("alpha")) + registry.drop("alpha") + assert registry.active is None + assert registry._active is None + + +def test_after_the_active_entry_is_dropped_the_next_one_becomes_active() -> None: + """Left set, the stale key kept every later entry from becoming active, + since `register` promotes only while nothing is.""" + registry = reg.SnapshotRegistry() + registry.register(_entry("alpha")) + registry.drop("alpha") + registry.register(_entry("beta")) + assert registry.active is not None and registry.active.repository == "beta" + + +def test_dropping_another_entry_keeps_the_active_one() -> None: + registry = reg.SnapshotRegistry() + registry.register(_entry("alpha")) + registry.register(_entry("beta")) + registry.drop("beta") + assert registry.active.repository == "alpha" + + +# -------------------------------------------------------------------------- +# 3: the writer is atomic, and refuses what JSON cannot carry +# -------------------------------------------------------------------------- + +def _boundary(root: Path, name: str = "snapshot.json") -> OutputBoundary: + return OutputBoundary(root, [name]) + + +@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf], + ids=["nan", "infinity", "minus-infinity"]) +def test_a_value_json_cannot_carry_is_refused_and_nothing_is_written(tmp_path, value) -> None: + target = tmp_path / "snapshot.json" + with pytest.raises(snapshot_mod.SnapshotNotWritable): + snapshot_mod.write_snapshot({"kind": "k", "score": value}, target, + _boundary(tmp_path)) + assert list(tmp_path.iterdir()) == [] + + +def test_a_value_of_no_json_type_is_refused(tmp_path) -> None: + with pytest.raises(snapshot_mod.SnapshotNotWritable): + snapshot_mod.canonical_json({"kind": "k", "when": object()}) + + +def test_the_refusal_is_one_openDox_reports_as_a_seam_refusal() -> None: + assert issubclass(snapshot_mod.SnapshotNotWritable, projection_seams.ProjectionSeamError) + assert issubclass(snapshot_mod.SnapshotNotWritable, ValueError) + + +def test_the_write_replaces_the_target_in_one_move(tmp_path, monkeypatch) -> None: + """The bytes land in a sibling, and one `os.replace` puts them over the + target. So the target is never opened for writing.""" + target = tmp_path / "snapshot.json" + target.write_text("old\n", encoding="utf-8") + moves = [] + real_replace = os.replace + + def replace(source, destination): + assert Path(destination) == target + assert target.read_text(encoding="utf-8") == "old\n" # untouched until the move + moves.append(Path(source).name) + return real_replace(source, destination) + + monkeypatch.setattr(os, "replace", replace) + written = snapshot_mod.write_snapshot({"kind": "k"}, target, _boundary(tmp_path)) + assert written == target.resolve() + assert json.loads(target.read_text(encoding="utf-8")) == {"kind": "k"} + assert len(moves) == 1 and moves[0].startswith(".snapshot.json.") + assert sorted(p.name for p in tmp_path.iterdir()) == ["snapshot.json"] + + +def test_a_failed_move_leaves_the_old_snapshot_and_no_sibling(tmp_path, monkeypatch) -> None: + target = tmp_path / "snapshot.json" + target.write_text("old\n", encoding="utf-8") + + def refuse(source, destination): + raise OSError("the move failed") + + monkeypatch.setattr(os, "replace", refuse) + with pytest.raises(OSError, match="the move failed"): + snapshot_mod.write_snapshot({"kind": "k"}, target, _boundary(tmp_path)) + assert target.read_text(encoding="utf-8") == "old\n" + assert sorted(p.name for p in tmp_path.iterdir()) == ["snapshot.json"] + + +def test_a_rewrite_keeps_the_snapshots_permissions(tmp_path) -> None: + target = tmp_path / "snapshot.json" + target.write_text("old\n", encoding="utf-8") + target.chmod(0o600) + snapshot_mod.write_snapshot({"kind": "k"}, target, _boundary(tmp_path)) + assert (target.stat().st_mode & 0o777) == 0o600 + + +def test_the_boundary_still_decides_the_destination(tmp_path) -> None: + from opendox.boundary import BoundaryViolation + + with pytest.raises(BoundaryViolation): + snapshot_mod.write_snapshot({"kind": "k"}, tmp_path / "elsewhere.json", + _boundary(tmp_path)) + assert list(tmp_path.iterdir()) == [] + + +def test_the_bytes_are_the_canonical_render(tmp_path) -> None: + snapshot = {"b": 1, "a": {"d": [2, 1], "c": "é"}} + target = tmp_path / "snapshot.json" + snapshot_mod.write_snapshot(snapshot, target, _boundary(tmp_path)) + assert target.read_bytes() == snapshot_mod.canonical_bytes(snapshot) + + +# -------------------------------------------------------------------------- +# 4: every read waits for a held read-modify-write +# -------------------------------------------------------------------------- + +READS = { + "get": lambda r: r.get("alpha", "session/x"), + "active": lambda r: r.active, + "resolve": lambda r: r.resolve(None), + "entries": lambda r: r.entries(), + "keys": lambda r: r.keys(), + "len": lambda r: len(r), + "aggregates": lambda r: r.aggregates(), +} + + +@pytest.mark.parametrize("read", sorted(READS)) +def test_a_read_waits_for_a_held_read_modify_write(read) -> None: + """A block holds the registry, registers a session and promotes it, and + only then puts `main` back. A read started while the block holds must + answer as the block left the registry, never from its middle.""" + registry = reg.SnapshotRegistry() + registry.register(_entry("alpha")) + held = threading.Event() + answer = [] + + def reader() -> None: + held.wait() + answer.append(READS[read](registry)) + + thread = threading.Thread(target=reader) + thread.start() + with registry.atomically(): + registry.register(_entry("alpha", "session/x"), active=True) + registry.register_aggregate(reg.Aggregate(id="agg")) + held.set() + time.sleep(0.2) + assert not answer, f"{read} answered while the block held the registry" + registry.set_active("alpha") + thread.join(timeout=5) + assert answer, f"{read} never answered" From 9bbee754b1aa84fc2ccab9868d111da4dd23a7bc Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:46:58 +0000 Subject: [PATCH 03/44] openXdox contributes its governed generator, registry and source through openDox's seams (plan 034 T059, 5.4a) The governed half of R1Q10 (a). openxdox.projection_contributions registers openXdox's generator at opendox.generator_seam, and its snapshot registry, corpus-root predicate, writer and validators for the three governed kinds at opendox.projection_seams. openXdox keeps generator.py, snapshot.py, snapshot_registry.py, completeness.py and corpus_root.py; the contributions reach generator, corpus_root and completeness, which read openxFactory's doc_health at module level, only when they are used. register() is explicit, idempotent, and all or none: a seam's refusal takes back every seam it wrote, in reverse, and reaches the caller. openxdox.domain_profile.register() calls it; load() registers nothing. This is the holder's ruling on T059 (option (c)): a host that registers openXdox's profile with openDox alone, as openxFactory does, calls register() itself, which is T064's one line in openxFactory's opendox_host.register_openxfactory(). tests/test_generated_at_anchor.py patched snapshot._locate_validator, which openDox's generate verb no longer reaches; it now patches the registered validator's locate(). tests/conftest.py registers the home corpus openxFactory's host registers (corpus_adapter_openxfactory.home_corpus) where F5.2's environment composes openxFactory's scripts/, and nothing where it is absent, as #1144's 4.1a has every seam refuse unregistered (holder's ruling on T059). The ratchet is lowered for T055's reaches: cli and serve leave OPENDOX_BACK_IMPORTS, and branch_session goes to (0, 2). snapshot_registry's doc_health read is deferred, so DOC_HEALTH_SURFACE records it as (0, 1). Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- src/openxdox/domain_profile.py | 14 + src/openxdox/projection_contributions.py | 365 +++++++++++++++++++++ tests/conftest.py | 57 ++++ tests/test_dependency_direction.py | 42 ++- tests/test_generated_at_anchor.py | 12 +- tests/test_projection_contributions.py | 398 +++++++++++++++++++++++ 6 files changed, 875 insertions(+), 13 deletions(-) create mode 100644 src/openxdox/projection_contributions.py create mode 100644 tests/test_projection_contributions.py diff --git a/src/openxdox/domain_profile.py b/src/openxdox/domain_profile.py index 0cb7057..1f7c518 100644 --- a/src/openxdox/domain_profile.py +++ b/src/openxdox/domain_profile.py @@ -1095,6 +1095,17 @@ def register(profile: DomainProfile) -> DomainProfile: """THE one registration. Called by the host's own adapter at process start. Returns the profile so a host can register and hold it in one expression. + + AND openXdox's GOVERNED PROJECTION WITH IT (plan 034 T059). A process that + registers openXdox's profile is a governed host, so this also registers + openXdox's generator, snapshot registry, corpus-root predicate, writer and + validators at openDox's seams (`openxdox.projection_contributions`), before + any of openDox's entry points reads a default. It is idempotent, and all + or none: a seam's refusal reaches the caller with no seam written and no + profile registered. `load()` registers nothing, so reading or validating + a profile never changes what a process serves. A host that registers its + profile with openDox alone, as openxFactory does, calls + `projection_contributions.register()` itself (plan 034 T064). """ global _registered if not isinstance(profile, DomainProfile): @@ -1117,6 +1128,9 @@ def register(profile: DomainProfile) -> DomainProfile: "already read the first. Call " "openxdox.domain_profile.unregister() first if the swap is " "deliberate.") + from . import projection_contributions + + projection_contributions.register() _registered = profile return profile diff --git a/src/openxdox/projection_contributions.py b/src/openxdox/projection_contributions.py new file mode 100644 index 0000000..a3d5a79 --- /dev/null +++ b/src/openxdox/projection_contributions.py @@ -0,0 +1,365 @@ +"""openXdox's governed projection, contributed through openDox's seams. + +WHY THIS FILE EXISTS. Box 5.4a of openxFactory's +`add-neutral-product-standalone-operability` (RATIFIED, `openxFactory#656` +comment `5815412869`) has openXdox KEEP `generator.py`, `snapshot.py`, +`snapshot_registry.py`, `completeness.py` and `corpus_root.py`, and contribute +its governed generator through the seam 5.4 declares. R1Q10 (a) (comment +`5850003126`, in R-G3's pattern) gives every consumer mechanism on release 1's +path an openDox-owned neutral default, and has openXdox contribute its +governed one through the same seam. openDox-code declares the seams: the +generator seam (`opendox.generator_seam`, plan 034 T052) and the four +projection seams (`opendox.projection_seams`, T055). This module is openXdox's +half (plan 034 T059): it registers + +* the governed generator (`generator.generate_snapshot`) at the generator seam, + writing `ideation-dashboard-snapshot` and declaring the two inputs a governed + generation takes, `project_register_source` and `possibles_source`; +* the snapshot registry and the source over it (`snapshot_registry`) at + `projection_seams.registry`; +* the corpus-root predicate (`corpus_root`), with the corpus's change rows, at + `projection_seams.corpus_root`; +* the canonical writer (`snapshot`) at `projection_seams.writer`; +* this product's validator (`snapshot.validate_snapshot`) at + `projection_seams.validators`, for each of openXdox-spec's three kinds. + openDox's own kinds (`opendox-snapshot`, `ideation-workbench`) keep openDox's + own validator: a host that contributes its governed validator does not take + openDox's kinds with it (`projection_seams`' own rule). + +WHO CALLS `register()`. It is the one call, made ONCE, at process start, before +any of openDox's entry points reads a default: a default is sealed once read, +and a host registration over a read default is refused. + +* `openxdox.domain_profile.register()` calls it, so openXdox's own + registration path (this leg's root `conftest.py`, and any openXdox-only + host) gets the contributions with no second call. +* openxFactory registers its profile with openDox, never with openXdox, so it + makes this call itself: plan 034's T064 adds it to + `scripts/opendox_host.register_openxfactory()`. The holder decided this on + 2026-09-29 and revised the 2026-09-27 decision in openDox-code#54's body, + which assumed openxFactory already called openXdox's registration hook. +* `domain_profile.load()` does NOT call it. Loading or validating a profile + registers nothing, so a verifier, a sync or a test that only reads a profile + never changes what a process serves. + +ALL OR NONE. Each seam refuses a registration over a host's that differs, and +over openDox's default once a consumer has read it. If any seam refuses, every +seam this call wrote is emptied again, in reverse order, before the refusal +reaches the caller. A seam that already held this module's contribution was +not written, since its registration is a no-op, and it is left as it was. A +seam where this call replaced openDox's unread default is emptied too, and +openDox's entry points register the default there again, as they do wherever +nothing is registered. So a refused call never leaves one process projecting +through some governed mechanisms and some neutral ones. + +IDEMPOTENT. Each contribution below is one object, made once at import. A +second `register()` finds each seam holding the same object, and every seam +treats that as a no-op. + +RESOLVED AT USE, AS THE REACH IT REPLACES WAS. `generator.py`, `corpus_root.py` +and `completeness.py` import openxFactory's `doc_health` at module level, which +a lone openXdox-code checkout does not carry (R1Q6 (d), comment `5817152735`; +the direction arc is plan 034's T008). openDox probes a registration's names +when it is made, so a contribution that imported those modules would make the +registration itself fail in a lone checkout, and with it every process that +registers a profile. So the generator and the corpus-root predicate are +adapters that import the governed module when they are CALLED, as openDox's +`consumer_reach` stand-ins did before T055. In a lone checkout a governed +generation then fails where it always failed, on `doc_health`, and registering +does not. `snapshot_registry.py` reads `doc_health` only for one sentinel, so it +imports it there (T059), and the module itself is registered. + +A CREATED FILE: no row in openxFactory's `docs/opendox-carve-manifest.yaml`, +which declares what LEAVES openxFactory, never what a destination assembles +(RULED OQ-C). +""" + +from __future__ import annotations + +import importlib +import threading +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from opendox import generator_seam, projection_seams + +from . import snapshot as snapshot_mod +from . import snapshot_registry as snapshot_registry_mod + +__all__ = [ + "CORPUS_ROOT", + "GENERATOR", + "GENERATOR_INPUTS", + "GOVERNED_KINDS", + "GOVERNED_SNAPSHOT_KIND", + "GovernedCorpusRoot", + "GovernedValidator", + "REGISTRY", + "VALIDATOR", + "WRITER", + "generate", + "is_registered", + "register", + "unregister", +] + +#: The contract openXdox's governed generator writes, and the kind every +#: snapshot it answers carries. openXdox-spec owns its schema. +GOVERNED_SNAPSHOT_KIND = "ideation-dashboard-snapshot" + +#: The kinds this product's validator is registered for: openXdox-spec's three +#: (#1144 7.1's second row), which are this consumer's own. +GOVERNED_KINDS: tuple[str, ...] = ( + GOVERNED_SNAPSHOT_KIND, + "ideation-dashboard-snapshot-index", + "gate-action-record", +) + +#: The inputs a governed generation takes beyond the seam's own four. Both are +#: optional, as the seam requires: openDox passes one only when its caller has +#: a value for it. `generate_snapshot`'s test-only keywords (`git`, +#: `generator_version`, `excluded_documents`) are not declared, so the seam +#: never passes them. +GENERATOR_INPUTS: tuple[str, ...] = ("project_register_source", "possibles_source") + + +def _governed(name: str) -> Any: + """`openxdox.`, imported now. The one place a contribution reaches a + governed module that needs `doc_health`, so a lone checkout fails here, at + use, naming the missing module.""" + return importlib.import_module(f"{__package__}.{name}") + + +# -------------------------------------------------------------------------- +# the generator +# -------------------------------------------------------------------------- + +def generate(repo_root: Path | str, repository: str, *, + source_revision: str | None = None, + generated_at: str | None = None, + project_register_source: Path | None = None, + possibles_source: Path | None = None) -> dict: + """openXdox's governed generation, the seam's operation: + `generator.generate_snapshot`, resolved at each call.""" + return _governed("generator").generate_snapshot( + repo_root, repository, source_revision=source_revision, + generated_at=generated_at, + project_register_source=project_register_source, + possibles_source=possibles_source) + + +GENERATOR = generator_seam.SnapshotGenerator( + contract=GOVERNED_SNAPSHOT_KIND, generate=generate, inputs=GENERATOR_INPUTS) + + +# -------------------------------------------------------------------------- +# the corpus-root predicate +# -------------------------------------------------------------------------- + +class _ScannedRoots(Sequence): + """`corpus_root.SCANNED_ROOTS`, read when it is used. + + The roots derive from openxFactory's doc-health scan + (`corpus.GOVERNED_ROOTS`), so they cannot be read before `doc_health` is + reached. openDox takes the value when the registration is made and reads + it only through sequence operations (`tuple(...)`), so this defers to the + first of them. `repr` does not resolve, so a debugger or an assertion + rewrite never makes the reach.""" + + def _roots(self) -> tuple[str, ...]: + return tuple(_governed("corpus_root").SCANNED_ROOTS) + + def __getitem__(self, index): + return self._roots()[index] + + def __len__(self) -> int: + return len(self._roots()) + + def __iter__(self): + return iter(self._roots()) + + def __contains__(self, item: Any) -> bool: + return item in self._roots() + + def __repr__(self) -> str: + return "" + + +class GovernedCorpusRoot: + """openXdox's corpus-root predicate at `projection_seams.corpus_root`. + + `corpus_scan_defect`, `corpus_root_refusal` and `SCANNED_ROOTS` are + `corpus_root`'s own. `change_rows` is the corpus's change enumeration with + each change's declared staged origin, which `branch_session._change_rows` + computed from `generator.iter_changes` and + `generator.declared_origin_state` before T055 routed it here.""" + + SCANNED_ROOTS: Sequence = _ScannedRoots() + + @staticmethod + def corpus_scan_defect(repo_root: Path | str) -> str | None: + return _governed("corpus_root").corpus_scan_defect(repo_root) + + @staticmethod + def corpus_root_refusal(repo_root: Path | str, *, flag: str = "--repo-root", + shape: str = "") -> str | None: + return _governed("corpus_root").corpus_root_refusal( + repo_root, flag=flag, shape=shape) + + @staticmethod + def change_rows(checkout_root: Path | str) -> tuple: + """`(change id, status, folder, origin state, origin)` per change.""" + generator = _governed("generator") + return tuple( + (change_id, status, folder, *generator.declared_origin_state(folder)) + for change_id, status, folder, _archive_date + in generator.iter_changes(Path(checkout_root))) + + +CORPUS_ROOT = GovernedCorpusRoot() + + +# -------------------------------------------------------------------------- +# the validator +# -------------------------------------------------------------------------- + +class GovernedValidator: + """This product's validator at `projection_seams.validators`, for + openXdox-spec's three kinds. + + openDox hands it the roots a search may start from, the written snapshot's + directory first and the served checkout second. `locate()` asks + `snapshot.find_validator` from each in turn, as openDox's + `cli._locate_validator` did before T055, and the first validator found + runs. `snapshot.validate_snapshot` reaches the verdict, with its three + outcomes, and its result carries every attribute openDox reads.""" + + #: The remedy openDox prints when the validator is found but cannot run. + dependency_remedy = snapshot_mod.DEPENDENCY_REMEDY + + def locate(self, search_from: tuple = ()) -> Path | None: + """The validator from the first root that reaches one, or None.""" + for start in tuple(search_from) or (None,): + found = snapshot_mod.find_validator(None if start is None else Path(start)) + if found is not None: + return found + return None + + def validate(self, path: Path | str, *, strict: bool = False, + search_from: tuple = ()) -> snapshot_mod.ValidationResult: + validator = self.locate(search_from) + if validator is not None: + return snapshot_mod.validate_snapshot(path, validator=validator, + strict=strict) + starts = tuple(search_from) or (None,) + reasons = [] + for start in starts: + reason = snapshot_mod._validator_not_found_reason( + None if start is None else Path(start)) + if reason not in reasons: + reasons.append(reason) + return snapshot_mod.ValidationResult( + False, -1, "", "validator not found", None, + snapshot_mod.VALIDATOR_UNAVAILABLE, + f"no {snapshot_mod.VALIDATOR_RELPATH} of this product's own was " + f"found from any root offered: " + "; ".join(reasons)) + + +VALIDATOR = GovernedValidator() + +#: The registry module carries every name `projection_seams.registry` asks for. +REGISTRY = snapshot_registry_mod + +#: The canonical writer: `snapshot.write_snapshot(snapshot, path, boundary)`. +WRITER = snapshot_mod + + +# -------------------------------------------------------------------------- +# the one registration +# -------------------------------------------------------------------------- + +_lock = threading.Lock() + + +def _contributions() -> tuple[tuple[str, Any, str | None, Any], ...]: + """`(name, seam, kind, contribution)`, in the order they are registered.""" + return ( + ("generator", generator_seam, None, GENERATOR), + ("registry", projection_seams.registry, None, REGISTRY), + ("corpus_root", projection_seams.corpus_root, None, CORPUS_ROOT), + ("writer", projection_seams.writer, None, WRITER), + *((f"validators[{kind}]", projection_seams.validators, kind, VALIDATOR) + for kind in GOVERNED_KINDS), + ) + + +def _holds(seam: Any, kind: str | None, contribution: Any) -> bool: + """Does `seam` hold `contribution` now? Answered without reading it. + + The generator seam's `current()` records nothing, so it is asked. A + projection seam's `current()` and `for_kind()` close the entry point's + default's window, and asking one would turn a replaceable default into a + refusal. So its registration is read where the seam keeps it. The names + read are pinned by `tests/test_projection_contributions.py`, so a pin move + that renames them fails there rather than here.""" + if seam is generator_seam: + try: + return generator_seam.current() is contribution + except generator_seam.GeneratorNotRegistered: + return False + if kind is None: + return seam._registered is contribution + held = seam._registered.get(kind) + return held is not None and held[0] is contribution + + +def _register_one(seam: Any, kind: str | None, contribution: Any) -> None: + if kind is None: + seam.register(contribution) + else: + seam.register(kind, contribution) + + +def _take_back(seam: Any, kind: str | None) -> None: + if kind is None: + seam.unregister() + else: + seam.unregister(kind) + + +def register() -> tuple[str, ...]: + """Register every contribution at its seam, all or none. Returns the + seams' names. + + A refusal is openDox's own (`GeneratorAlreadyRegistered`, + `SeamAlreadyRegistered`), raised unchanged once every seam this call wrote + has been emptied again.""" + with _lock: + written: list[tuple[Any, str | None]] = [] + try: + for _name, seam, kind, contribution in _contributions(): + if _holds(seam, kind, contribution): + continue + _register_one(seam, kind, contribution) + written.append((seam, kind)) + except BaseException: + for seam, kind in reversed(written): + _take_back(seam, kind) + raise + return tuple(name for name, *_ in _contributions()) + + +def is_registered() -> bool: + """Does every seam hold this module's contribution?""" + return all(_holds(seam, kind, contribution) + for _name, seam, kind, contribution in _contributions()) + + +def unregister() -> None: + """Empty every seam that holds this module's contribution, and leave every + other seam as it is. For test isolation and for a host tearing down.""" + with _lock: + for _name, seam, kind, contribution in reversed(_contributions()): + if _holds(seam, kind, contribution): + _take_back(seam, kind) diff --git a/tests/conftest.py b/tests/conftest.py index 02899a5..e1c8837 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -363,3 +363,60 @@ def unregistered_profile(): _domain_profile.unregister() if previous is not None: _domain_profile.register(previous) + + +# --------------------------------------------------------------------------- +# THE HOME CORPUS, REGISTERED AS openxFactory'S HOST REGISTERS IT (plan 034 +# T059; holder's ruling on T059, 2026-09-30). +# +# openDox's `authoring.create_scaffold` asks the registered home corpus which +# fields it obliges (`scaffold_lead_fields()`, openDox-code#57, plan 034 T054), +# and with nothing registered it refuses, as #1144's 4.1a has every seam do: +# "A process in which no entry point was built and nothing registered anything +# — an import, a test, a library caller — still refuses with 4.2's +# ADAPTER_NOT_REGISTERED, so the default is a registration the entry point +# makes and never a fallback inside the seam." This leg's governed suites call +# the gate verbs in-process, as a library caller, so from that pin they refused +# on the home corpus where they passed before. +# +# So this harness registers the home a host would, as the root `conftest.py` +# registers the profile a host would: the SAME factory openxFactory's host +# registers (`corpus_adapter_openxfactory.home_corpus`, through +# `scripts/opendox_host.register_seams()`). It is found where F5.2's +# environment composes openxFactory's `scripts/` on `PYTHONPATH` (T007 batch G, +# R1Q23 (a)). The governed layout the suites were written against is that +# adapter's answer: it obliges neither `title` nor `summary`, so a scaffold +# keeps its H1 first. +# +# WHERE IT IS ABSENT, NOTHING IS REGISTERED. A lone checkout carries no +# openxFactory `scripts/`, and there the suites refuse as 4.1a says, which is +# the right answer. Every suite that reaches the home corpus also reaches +# `doc_health`, so each is in the declared exclusion already +# (`tests/declared_exclusion.yaml`), and its evidence is unchanged. Only a +# missing `corpus_adapter_openxfactory` itself means "absent": a present one +# that cannot be imported fails here, loudly. +# +# THE SUITES KEEP READING WHAT THIS CHECKOUT RESOLVES. openxFactory's adapter +# puts its own pinned openDox leg's `src/` FIRST on `sys.path` when it is +# imported (`corpus_adapter_openxfactory/adapter.py`), because that is how +# openxFactory consumes the interface. `opendox` itself is already imported by +# then, from the installed distribution, so its modules keep coming from there. +# The two top-level modules beside it, `route_extension` and +# `subcommand_extension`, are not imported yet, and a later import would have +# found the aggregation's pinned copies ahead of the ones this checkout's own +# path finds (`src/`, then the installed openDox). So both are imported first, +# from there. +import route_extension # noqa: E402,F401 +import subcommand_extension # noqa: E402,F401 + +try: + import corpus_adapter_openxfactory as _openxfactory_corpus # noqa: E402 +except ModuleNotFoundError as _absent: + if _absent.name != "corpus_adapter_openxfactory": + raise + _openxfactory_corpus = None + +if _openxfactory_corpus is not None: + from opendox import corpus_adapter as _corpus_adapter # noqa: E402 + + _corpus_adapter.register_home(_openxfactory_corpus.home_corpus) diff --git a/tests/test_dependency_direction.py b/tests/test_dependency_direction.py index 7c19eee..52c253b 100644 --- a/tests/test_dependency_direction.py +++ b/tests/test_dependency_direction.py @@ -285,7 +285,12 @@ def test_the_lawful_direction_is_actually_exercised() -> None: #: IMPLEMENTATION SURFACE here, where importing doc-health is lawful." #: MEASURED at the leg's own tree rather than carried: 13 statements over 8 #: modules under `src/` (8 at import time, 5 deferred); 25 over 15 files -#: counting the carved `tests/`. The box's "23" is a whole-package figure from +#: counting the carved `tests/`. Plan 034 T059 moved `snapshot_registry.py`'s +#: one statement from import time into `SnapshotEntry.index_entry`, where its +#: sentinel is read, so this leg's registry can be registered at openDox's +#: registry seam in a checkout without `doc_health` +#: (`openxdox.projection_contributions`). The surface is the same 13 +#: statements over the same 8 modules, now 7 at import time and 6 deferred. The box's "23" is a whole-package figure from #: design § D3's `serve.py` paragraph and is not exactly measurable as written #: — recorded, on the reality check of 2026-09-10, as an arithmetic note and #: not a defect. What this test does is make the surface ENUMERATED, so a new @@ -299,7 +304,7 @@ def test_the_lawful_direction_is_actually_exercised() -> None: "src/openxdox/gate_routes.py": (0, 1), "src/openxdox/generator.py": (3, 0), "src/openxdox/round_trip.py": (1, 0), - "src/openxdox/snapshot_registry.py": (1, 0), + "src/openxdox/snapshot_registry.py": (0, 1), } @@ -343,9 +348,9 @@ def test_the_doc_health_implementation_surface_is_exactly_declared() -> None: #: this repository has since had to sweep for. It happened once more: the pin #: named `5c137a90` (openDox-code#27, § 3.4 RULED Q7) from 2026-09-17, and #: this note went on saying so after #29 moved it, until plan 034 T043 swept -#: it. The pin names `2d116415` (openDox-code#55, plan 034 T037) since -#: 2026-09-27, and `_back_import_census()` recomputed against THAT tree returns -#: exactly the five rows below. FROM `a99eba03` (#11, § 4.3) TO `5c137a90` THE +#: it. The pin named `2d116415` (openDox-code#55, plan 034 T037) from +#: 2026-09-27, and `_back_import_census()` recomputed against that tree +#: returned exactly the five rows T040 carried. FROM `a99eba03` (#11, § 4.3) TO `5c137a90` THE #: PIN CROSSED THIRTEEN openDox-code LANDINGS, from #13 (`e86deb2`) to #27 #: (`git rev-list --count --first-parent a99eba03..5c137a90` in openDox-code). #: This note named five of them, the correction Copilot's round-1 review of @@ -362,6 +367,21 @@ def test_the_doc_health_implementation_surface_is_exactly_declared() -> None: #: the five rows below. The numbers here are that measurement, not a #: carried-forward memory. #: +#: PLAN 034 T059 LOWERED IT, the first fall since slice 2b. The pin moved from +#: `2d116415` to openDox-code `e3ef506a`, the head of openDox-code#59 (T055, +#: openDox's own snapshot registry and source, corpus-root predicate, writer +#: and validator lookup behind seams of their own), twenty-three first-parent +#: commits later. At that tree `_back_import_census()` returns three rows: +#: `cli.py` (0, 1) and `serve.py` (0, 2) reach (0, 0) and leave the table, and +#: `branch_session.py` falls from (0, 7) to (0, 2). The eight reaches T055 +#: closed are the ones its falsifier names: `branch_session`'s `_change_rows`, +#: `session_entry`, `register_session_entry`, `refresh_session_snapshot` and +#: `refresh_main_view`, `cli`'s `_session_registry`, and `serve`'s +#: `_checkout_real` and `_refuse_impossible_checkout_root`. Each now asks a +#: seam, and this leg contributes its governed mechanism there +#: (`openxdox.projection_contributions`). The eleven that remain are plan 034 +#: T084's, and T086 takes the table to (0, 0). +#: #: THE WHOLE OF THE INVERSION IS GONE, which is worth stating plainly because #: this table has never been able to say it before: no module of the pinned #: openDox names `openxdox` at import time. The 19 deferred reaches are NOT a @@ -397,10 +417,12 @@ def test_the_doc_health_implementation_surface_is_exactly_declared() -> None: #: imports each converted module in a subprocess with `openxdox` blocked — and #: that is the right home for it: this leg measures a repository it does not #: write, and cannot import openDox's modules to find out. That file's -#: `NEUTRAL_MODULES` is the asserted half, and at `2d116415` it holds nine: +#: `NEUTRAL_MODULES` is the asserted half. At `2d116415` it held nine: #: `workbench`, `serve_workbench`, `consumer_reach`, `branch_session`, -#: `domain_profile`, `profile_proxy`, `view_extension`, `cli` and `serve`. Its -#: `STILL_REACHING` is empty there. +#: `domain_profile`, `profile_proxy`, `view_extension`, `cli` and `serve`. At +#: `e3ef506a` it holds thirteen, T055's four new modules with them: +#: `projection_seams`, `default_registry`, `default_projection` and `rfc3339`. +#: Its `STILL_REACHING` is empty at both. #: #: THE TWO MODULES THAT DID NOT IMPORT WITHOUT A CONSUMER NOW DO. This note #: said `opendox.serve` and `opendox.cli` were blocked by `ideation_dashboard` @@ -419,9 +441,7 @@ def test_the_doc_health_implementation_surface_is_exactly_declared() -> None: #: improvement as well as a regression, so the number in the tree stays true. OPENDOX_BACK_IMPORTS: dict[str, tuple[int, int]] = { # module (import-time, deferred) - "opendox/branch_session.py": (0, 7), - "opendox/cli.py": (0, 1), - "opendox/serve.py": (0, 2), + "opendox/branch_session.py": (0, 2), "opendox/serve_project.py": (0, 2), "opendox/serve_workbench.py": (0, 7), } diff --git a/tests/test_generated_at_anchor.py b/tests/test_generated_at_anchor.py index 2c90b89..e47023b 100644 --- a/tests/test_generated_at_anchor.py +++ b/tests/test_generated_at_anchor.py @@ -219,8 +219,16 @@ def test_the_predicate_checks_the_instant_not_only_the_shape(): def test_a_pinned_anchor_passes_strict_validation(tmp_path, monkeypatch): """`--strict` is the lane's gate (design Decision 3 step 3), so the anchor has to satisfy the schema's `format: date-time` for real, not merely the - CLI's own predicate.""" - monkeypatch.setattr(cli_mod, "_locate_validator", lambda *a, **k: VALIDATOR) + CLI's own predicate. + + The validator is the one registered for the snapshot's kind at openDox's + validator lookup (plan 034 T059, `openxdox.projection_contributions`), + which replaced `cli._locate_validator` (T055). So the pinned validator is + handed to that registration's `locate`.""" + from openxdox import projection_contributions + + monkeypatch.setattr(projection_contributions.VALIDATOR, "locate", + lambda *a, **k: VALIDATOR) output = tmp_path / "out" / "snapshot.json" assert cli_mod.main([ "generate", "--repo-root", str(BASE_REPO), "--repository", "fixture-repo", diff --git a/tests/test_projection_contributions.py b/tests/test_projection_contributions.py new file mode 100644 index 0000000..3a80355 --- /dev/null +++ b/tests/test_projection_contributions.py @@ -0,0 +1,398 @@ +"""openXdox's governed projection is what openDox's seams hold (plan 034 T059). + +T059's falsifier opens with "seam tests that show openXdox's contributions are +the registered ones". These are they. Each seam holds openXdox's own +contribution once `projection_contributions.register()` has run: the governed +generator, this leg's snapshot registry, its corpus-root predicate, its writer, +and its validator for openXdox-spec's three kinds. openDox's own kinds keep +openDox's validator. + +The rest pin the registration's contract: it is idempotent, all or none, made +by `domain_profile.register()` and never by `domain_profile.load()`, and +import-free of `doc_health`, so a lone checkout registers and fails only where +a governed generation runs. + +This leg's root `conftest.py` registers the fixture profile at import, and so +the contributions. A case that empties a seam runs inside `isolated_seams`, +which puts the contributions back afterwards. + +A CREATED file: no manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from opendox import generator_seam, projection_seams +from openxdox import domain_profile, projection_contributions as pc +from openxdox import snapshot as snapshot_mod +from openxdox import snapshot_registry + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC = REPO_ROOT / "src" +PROFILE_FIXTURE = REPO_ROOT / "tests" / "fixtures" / "openxfactory-engineering-profile.yaml" + + +def _empty_every_seam() -> None: + generator_seam.unregister() + projection_seams.registry.unregister() + projection_seams.corpus_root.unregister() + projection_seams.writer.unregister() + projection_seams.validators.unregister() + + +@pytest.fixture +def isolated_seams(): + """Run one case with every seam empty, then put the contributions back. + + openDox's entry points register their own defaults again wherever nothing + is registered, so a later case that runs one gets them as before.""" + _empty_every_seam() + try: + yield + finally: + _empty_every_seam() + pc.register() + + +def _registered_profile(): + return domain_profile.current() if domain_profile.is_registered() else None + + +# -------------------------------------------------------------------------- +# the registered ones +# -------------------------------------------------------------------------- + +def test_each_seam_holds_openxdoxs_contribution() -> None: + """The process this suite runs in registered the fixture profile at + import, so every seam holds openXdox's own, and nothing else.""" + assert pc.is_registered() + assert generator_seam.current() is pc.GENERATOR + assert projection_seams.registry.current() is snapshot_registry + assert projection_seams.corpus_root.current() is pc.CORPUS_ROOT + assert projection_seams.writer.current() is snapshot_mod + for kind in pc.GOVERNED_KINDS: + assert projection_seams.validators.for_kind(kind) is pc.VALIDATOR + + +def test_the_generator_is_the_governed_one_declaring_its_two_inputs() -> None: + assert pc.GENERATOR.contract == "ideation-dashboard-snapshot" + assert pc.GENERATOR.inputs == ("project_register_source", "possibles_source") + assert pc.GOVERNED_KINDS == ("ideation-dashboard-snapshot", + "ideation-dashboard-snapshot-index", + "gate-action-record") + + +def test_the_generator_hands_the_seams_call_to_generate_snapshot(monkeypatch) -> None: + """Every argument the seam passes reaches `generate_snapshot` unchanged, + and nothing else does.""" + calls = [] + + class Generator: + @staticmethod + def generate_snapshot(*args, **kwargs): + calls.append((args, kwargs)) + return {"kind": "ideation-dashboard-snapshot", "schema_version": 1} + + monkeypatch.setattr(pc, "_governed", + lambda name: Generator if name == "generator" else None) + answered = generator_seam.generate( + Path("/corpus"), "repo", source_revision="a" * 40, + generated_at="2026-09-30T00:00:00Z", + project_register_source=Path("/register.yaml")) + assert answered == {"kind": "ideation-dashboard-snapshot", "schema_version": 1} + assert calls == [((Path("/corpus"), "repo"), { + "source_revision": "a" * 40, "generated_at": "2026-09-30T00:00:00Z", + "project_register_source": Path("/register.yaml"), + "possibles_source": None})] + + +def test_openDoxs_own_kinds_keep_openDoxs_validator(isolated_seams) -> None: + """The entry points' defaults land on openDox's kinds, and none of ours.""" + pc.register() + projection_seams.register_defaults() + from opendox import default_projection + + for kind in default_projection.OWN_KINDS: + assert projection_seams.validators.for_kind(kind) is default_projection.VALIDATOR + for kind in pc.GOVERNED_KINDS: + assert projection_seams.validators.for_kind(kind) is pc.VALIDATOR + assert projection_seams.registry.current() is snapshot_registry + + +def test_the_entry_points_defaults_do_not_displace_it(isolated_seams) -> None: + pc.register() + projection_seams.register_defaults() + from opendox import default_generator + + generator_seam.register_default(default_generator.GENERATOR) + assert pc.is_registered() + + +# -------------------------------------------------------------------------- +# idempotent, and all or none +# -------------------------------------------------------------------------- + +def test_a_second_registration_is_a_no_op() -> None: + names = pc.register() + assert names == pc.register() + assert pc.is_registered() + + +def test_a_refusal_takes_back_every_seam_this_call_wrote(isolated_seams) -> None: + """A host's other writer is registered, so the writer seam refuses. The + generator, registry and corpus-root seams, written before it, are empty + again, and the host's writer is untouched.""" + + class OtherWriter: + @staticmethod + def write_snapshot(snapshot, path, boundary): # pragma: no cover + raise AssertionError("never called") + + other = OtherWriter() + projection_seams.writer.register(other) + with pytest.raises(projection_seams.SeamAlreadyRegistered): + pc.register() + assert not generator_seam.is_registered() + assert not projection_seams.registry.is_registered() + assert not projection_seams.corpus_root.is_registered() + assert projection_seams.writer.current() is other + assert projection_seams.validators.kinds() == () + + +def test_a_replaced_unread_default_is_taken_back_too(isolated_seams) -> None: + """The entry points registered their defaults, and something read the + writer's. This call replaces the unread defaults before the writer + refuses, and empties them again, so no seam is left governed while the + writer stays neutral.""" + from opendox import default_generator, default_projection + + generator_seam.register_default(default_generator.GENERATOR) + projection_seams.register_defaults() + assert projection_seams.writer.current() is default_projection.WRITER # read + with pytest.raises(projection_seams.SeamAlreadyRegistered): + pc.register() + assert not generator_seam.is_registered() + assert not projection_seams.registry.is_registered() + assert not projection_seams.corpus_root.is_registered() + assert projection_seams.writer.current() is default_projection.WRITER + + +def test_a_seam_already_holding_the_contribution_is_not_taken_back(isolated_seams) -> None: + """The registry already held this leg's module, so this call did not write + it, and a refusal later leaves it where it was.""" + + class OtherWriter: + @staticmethod + def write_snapshot(snapshot, path, boundary): # pragma: no cover + raise AssertionError("never called") + + projection_seams.registry.register(snapshot_registry) + projection_seams.writer.register(OtherWriter()) + with pytest.raises(projection_seams.SeamAlreadyRegistered): + pc.register() + assert projection_seams.registry.current() is snapshot_registry + assert not generator_seam.is_registered() + + +def test_asking_whether_it_is_registered_leaves_a_default_replaceable(isolated_seams) -> None: + """`is_registered()` reads each projection seam where it keeps its + registration, because `current()` would close the default's window. So a + default asked about stays replaceable, and the names read are pinned + here: a pin move that renamed them fails this case.""" + projection_seams.register_defaults() + assert not pc.is_registered() + assert hasattr(projection_seams.registry, "_registered") + assert isinstance(projection_seams.validators._registered, dict) + pc.register() + assert pc.is_registered() + + +def test_unregister_empties_only_what_it_holds(isolated_seams) -> None: + pc.register() + from opendox import default_projection + + projection_seams.validators.register_default( + "opendox-snapshot", default_projection.VALIDATOR) + pc.unregister() + assert not generator_seam.is_registered() + assert not projection_seams.writer.is_registered() + assert projection_seams.validators.kinds() == ("opendox-snapshot",) + + +# -------------------------------------------------------------------------- +# who registers it +# -------------------------------------------------------------------------- + +def test_registering_openxdoxs_profile_registers_the_contributions(isolated_seams) -> None: + held = _registered_profile() + domain_profile.unregister() + try: + domain_profile.register(domain_profile.load(PROFILE_FIXTURE)) + assert pc.is_registered() + finally: + domain_profile.unregister() + if held is not None: + domain_profile.register(held) + + +def test_a_refused_contribution_leaves_no_profile_registered(isolated_seams) -> None: + class OtherWriter: + @staticmethod + def write_snapshot(snapshot, path, boundary): # pragma: no cover + raise AssertionError("never called") + + held = _registered_profile() + domain_profile.unregister() + projection_seams.writer.register(OtherWriter()) + try: + with pytest.raises(projection_seams.SeamAlreadyRegistered): + domain_profile.register(domain_profile.load(PROFILE_FIXTURE)) + assert not domain_profile.is_registered() + finally: + projection_seams.writer.unregister() + if held is not None: + domain_profile.register(held) + + +def test_loading_a_profile_registers_nothing(isolated_seams) -> None: + domain_profile.load(PROFILE_FIXTURE) + assert not generator_seam.is_registered() + assert not projection_seams.registry.is_registered() + assert projection_seams.validators.kinds() == () + + +# -------------------------------------------------------------------------- +# resolved at use +# -------------------------------------------------------------------------- + +_BLOCKED = textwrap.dedent(''' + import importlib.abc, sys + + class Block(importlib.abc.MetaPathFinder): + def find_spec(self, name, path=None, target=None): + if name == "doc_health" or name.startswith("doc_health."): + raise ModuleNotFoundError(f"No module named {name!r}", name=name) + return None + + sys.meta_path.insert(0, Block()) +''') + + +def _run_blocked(body: str) -> subprocess.CompletedProcess: + env = {**os.environ, "PYTHONPATH": str(SRC)} + return subprocess.run( + [sys.executable, "-c", _BLOCKED + textwrap.dedent(body)], + capture_output=True, text=True, env=env, cwd=str(REPO_ROOT)) + + +def test_registering_reaches_no_module_that_needs_doc_health() -> None: + """With `doc_health` unimportable, the registration succeeds and imports + none of the governed modules that need it.""" + done = _run_blocked(''' + import json, sys + from openxdox import projection_contributions as pc + pc.register() + print(json.dumps({ + "registered": pc.is_registered(), + "loaded": sorted(m for m in ("openxdox.generator", "openxdox.corpus_root", + "openxdox.completeness", "doc_health") + if m in sys.modules)})) + ''') + assert done.returncode == 0, done.stderr + assert json.loads(done.stdout.splitlines()[-1]) == {"registered": True, "loaded": []} + + +def test_without_doc_health_a_governed_generation_fails_on_doc_health() -> None: + """Where it always failed, and with the same final exception, which is + what the declared exclusion's `doc_health` evidence reads.""" + done = _run_blocked(''' + from pathlib import Path + from openxdox import projection_contributions as pc + pc.register() + from opendox import generator_seam + try: + generator_seam.generate(Path("."), "repo") + except ModuleNotFoundError as exc: + print("refused:", exc.name) + else: + print("generated") + ''') + assert done.returncode == 0, done.stderr + assert done.stdout.strip().splitlines()[-1] == "refused: doc_health" + + +def test_the_scanned_roots_are_read_when_used(monkeypatch) -> None: + reads = [] + + class CorpusRoot: + @property + def SCANNED_ROOTS(self): # noqa: N802 - the governed module's own name + reads.append(1) + return ("docs", "openspec") + + monkeypatch.setattr(pc, "_governed", lambda name: CorpusRoot()) + roots = pc.CORPUS_ROOT.SCANNED_ROOTS + assert "read when used" in repr(roots) and reads == [] + assert tuple(roots) == ("docs", "openspec") + assert len(roots) == 2 and roots[1] == "openspec" and "docs" in roots + + +def test_the_change_rows_are_the_governed_enumeration_with_each_origin(monkeypatch) -> None: + """What `branch_session._change_rows` computed before T055 routed it here.""" + folder = Path("/corpus/openspec/changes/add-x") + + class Generator: + @staticmethod + def iter_changes(root): + assert root == Path("/corpus") + return [("add-x", "active", folder, None)] + + @staticmethod + def declared_origin_state(path): + assert path == folder + return ("staged", "ideation/staging/x") + + monkeypatch.setattr(pc, "_governed", lambda name: Generator) + assert projection_seams.corpus_root.current().change_rows("/corpus") == ( + ("add-x", "active", folder, "staged", "ideation/staging/x"),) + + +# -------------------------------------------------------------------------- +# the validator +# -------------------------------------------------------------------------- + +def test_the_validator_is_located_from_each_root_in_turn(monkeypatch, tmp_path) -> None: + found = tmp_path / "validator.py" + asked = [] + + def find_validator(start): + asked.append(start) + return found if start == tmp_path / "second" else None + + ran = [] + monkeypatch.setattr(snapshot_mod, "find_validator", find_validator) + monkeypatch.setattr(snapshot_mod, "validate_snapshot", + lambda path, **kw: ran.append((path, kw)) or "result") + result = pc.VALIDATOR.validate(tmp_path / "s.json", strict=True, + search_from=(tmp_path / "first", tmp_path / "second")) + assert result == "result" + assert asked == [tmp_path / "first", tmp_path / "second"] + assert ran == [(tmp_path / "s.json", {"validator": found, "strict": True})] + + +def test_no_validator_from_any_root_is_unavailable_naming_the_script(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(snapshot_mod, "find_validator", lambda start: None) + result = pc.VALIDATOR.validate(tmp_path / "s.json", + search_from=(tmp_path / "a", tmp_path / "b")) + assert result.outcome == projection_seams.VALIDATOR_UNAVAILABLE + assert not result.available and result.validator is None + assert str(snapshot_mod.VALIDATOR_RELPATH) in result.unavailable_reason + assert pc.VALIDATOR.dependency_remedy == snapshot_mod.DEPENDENCY_REMEDY From e02318399ce8dbb3a9c1c8fce9573b0f2fd649c4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:47:12 +0000 Subject: [PATCH 04/44] The seam-assembly file leaves the declared exclusion (plan 034 T059) tests/test_seam_assembly_beside_gate_and_projection.py was declared under doc_health (R1Q6 (d)) by T044 because serve_projection reached doc_health through snapshot_registry at import. That read is now deferred to where the sentinel is written, so the file passes alone in a lone checkout, and T041's test_declared_exclusion would refuse it as declared. Its entry leaves (67 -> 66 files), and its six cases run in this leg's required check (holder's ruling on T059). T059 joins the exclusion file's single-writer chain between T044 and T061. The four seam files' docstrings and validate.yml's paragraphs stop saying the file is declared, and validate.yml's pin prose names the new openDox pin. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 16 ++++++----- tests/declared_exclusion.yaml | 3 +-- tests/test_declared_exclusion.py | 4 +-- .../test_evidence_provenance_surface_seam.py | 10 +++---- tests/test_model_scenario_workbench_seam.py | 10 +++---- tests/test_role_authority_projection_seam.py | 16 +++++------ ...eam_assembly_beside_gate_and_projection.py | 27 ++++++++++--------- 7 files changed, 45 insertions(+), 41 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1723c63..d59134a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -131,9 +131,10 @@ jobs: # `tests/integration/`. The chain was off because `tests/conftest.py` # imported carved session fixtures that reached `ideation_dashboard` and # `doc_health`, two packages neither leg carries. At the openDox this - # leg now pins (openDox-code `2d116415`, since plan 034 T040), the chain - # loads in a lone checkout, and what still reaches openxFactory is what - # the declaration and `LEFT_OUT` hold. Each of the sixteen files' + # leg pinned from plan 034 T040 (openDox-code `2d116415`), and at the one + # it pins since plan 034 T059 (`e3ef506a`), the chain loads in a lone + # checkout, and what still reaches openxFactory is what the declaration + # and `LEFT_OUT` hold. Each of the sixteen files' # account of itself (why it joined the list, its counts, its review # rounds) is in this file's history at `d84b5048` # (`git show d84b5048:.github/workflows/validate.yml`), and is not @@ -261,8 +262,8 @@ jobs: # gap were the three seam suites' `doc_health` pairs. T044 made each of # them assert for real and moved the six into # `tests/test_seam_assembly_beside_gate_and_projection.py`, which the - # declaration lists under `doc_health` (R1Q6 (d)), so SELECTED and - # SKIPPED each lose those six. The declaration's own check gains one + # declaration then listed under `doc_health` (R1Q6 (d); it left in plan + # 034 T059, below), so SELECTED and SKIPPED each lose those six. The declaration's own check gains one # case, that file run alone, which passes, so SELECTED and PASSED each # gain one. SELECTED goes 890 -> 885, PASSED 880 -> 881 and SKIPPED # 10 -> 4. The 24 seam cases that need neither column stay where they @@ -286,8 +287,9 @@ jobs: # `tests/test_model_scenario_workbench_seam.py` and # `tests/test_role_authority_projection_seam.py` into # `tests/test_seam_assembly_beside_gate_and_projection.py`, which the - # declaration lists under `doc_health`. So none of them is a skip any - # more, and none is in these numbers. None comes from + # declaration listed under `doc_health` until plan 034 T059 took it + # out. So none of them is a skip any more: from T044 to T059 none was + # in these numbers, and since T059 all six are, as passes. None comes from # `tests/integration/`, where nothing skips (its rule 2). If the count # moves, move the pin WITH the reason — never relax the comparison. # SKIPPED is `==` where the other two are `>=` because a file that diff --git a/tests/declared_exclusion.yaml b/tests/declared_exclusion.yaml index b40ebb9..fa631c6 100644 --- a/tests/declared_exclusion.yaml +++ b/tests/declared_exclusion.yaml @@ -57,7 +57,7 @@ schema_version: 1 kind: declared-test-exclusion -count: 67 +count: 66 reasons: - id: doc_health @@ -123,7 +123,6 @@ entries: - {path: tests/test_repo_root_guard.py, reasons: [doc_health]} - {path: tests/test_repo_selector.py, reasons: [doc_health]} - {path: tests/test_round_trip.py, reasons: [doc_health]} - - {path: tests/test_seam_assembly_beside_gate_and_projection.py, reasons: [doc_health], note: "six cases plan 034 T044 moved here from the three route seam suites, where each skipped on doc_health"} - {path: tests/test_session_commits.py, reasons: [doc_health]} - {path: tests/test_session_confinement.py, reasons: [doc_health]} - {path: tests/test_session_document_ownership.py, reasons: [doc_health]} diff --git a/tests/test_declared_exclusion.py b/tests/test_declared_exclusion.py index a0ea956..23d0163 100644 --- a/tests/test_declared_exclusion.py +++ b/tests/test_declared_exclusion.py @@ -123,8 +123,8 @@ def _load() -> dict: # its own. So a cause followed by an unrelated failure, a cause raised while # an unrelated exception was being handled, a cause beside an unrelated one, # and a cause's words quoted in some other failure are each unattributed, and -# no two reasons can take one result. The shapes are the ones the 67 listed -# files' 234 red results take when each file runs alone. +# no two reasons can take one result. The shapes are the ones the 67 files +# listed at plan 034 T044 took, 234 red results, when each ran alone. # --------------------------------------------------------------------------- #: `doc_health` raised missing. Where it is absent, the interpreter names diff --git a/tests/test_evidence_provenance_surface_seam.py b/tests/test_evidence_provenance_surface_seam.py index 11907a6..416391b 100644 --- a/tests/test_evidence_provenance_surface_seam.py +++ b/tests/test_evidence_provenance_surface_seam.py @@ -20,13 +20,13 @@ authority alone, which needs nothing a lone checkout lacks, it is asserted below. Beside the gate and projection columns it is asserted in `tests/test_seam_assembly_beside_gate_and_projection.py`, because -`serve_projection` reaches openxFactory's `doc_health` when it is imported +`serve_projection` reached openxFactory's `doc_health` when it was imported (through `snapshot_registry`), which no lone checkout supplies. Those two cases sat here behind a guard that skipped on every lone run, until T044 made -them assert for real and moved them. That file is in the declared exclusion -(`tests/declared_exclusion.yaml`) under `doc_health`. It runs wherever -`doc_health` is present, and everywhere else it is reported as an open -extraction. +them assert for real and moved them. That file was in the declared exclusion +(`tests/declared_exclusion.yaml`) under `doc_health` until plan 034 T059 moved +`snapshot_registry`'s one `doc_health` read out of its module level. It runs +in this leg's required check now. """ from __future__ import annotations diff --git a/tests/test_model_scenario_workbench_seam.py b/tests/test_model_scenario_workbench_seam.py index 7a9c228..178bc25 100644 --- a/tests/test_model_scenario_workbench_seam.py +++ b/tests/test_model_scenario_workbench_seam.py @@ -27,13 +27,13 @@ (d) IS ASSERTED IN TWO PLACES, since T044. Beside the other two § 4.5 features, which need nothing a lone checkout lacks, it is asserted below. Beside the gate and projection columns it is asserted in that file, because -`serve_projection` reaches openxFactory's `doc_health` when it is imported +`serve_projection` reached openxFactory's `doc_health` when it was imported (through `snapshot_registry`), which no lone checkout supplies. Those two cases sat here behind a guard that skipped on every lone run, until T044 made -them assert for real and moved them. That file is in the declared exclusion -(`tests/declared_exclusion.yaml`) under `doc_health`. It runs wherever -`doc_health` is present, and everywhere else it is reported as an open -extraction. +them assert for real and moved them. That file was in the declared exclusion +(`tests/declared_exclusion.yaml`) under `doc_health` until plan 034 T059 moved +`snapshot_registry`'s one `doc_health` read out of its module level. It runs +in this leg's required check now. """ from __future__ import annotations diff --git a/tests/test_role_authority_projection_seam.py b/tests/test_role_authority_projection_seam.py index f6c1ad5..79f8a20 100644 --- a/tests/test_role_authority_projection_seam.py +++ b/tests/test_role_authority_projection_seam.py @@ -14,14 +14,14 @@ class it will be mixed into (`resolve_handlers` — the "a route that cannot be (d) IS ASSERTED ELSEWHERE, since plan 034 task T044. The two columns are `serve_gate.GateRoutesExtension` and `serve_projection.ProjectionRoutesExtension`, -and `serve_projection` reaches openxFactory's `doc_health` when it is imported -(through `snapshot_registry`), which no lone checkout supplies. So this -suite's two cases for (d) sat behind a guard that skipped on every lone run, -until T044 made them assert for real and moved them to -`tests/test_seam_assembly_beside_gate_and_projection.py`. That file is in the -declared exclusion (`tests/declared_exclusion.yaml`) under `doc_health`. It -runs wherever `doc_health` is present, and everywhere else it is reported as -an open extraction. +and `serve_projection` reached openxFactory's `doc_health` when it was +imported (through `snapshot_registry`), which no lone checkout supplies. So +this suite's two cases for (d) sat behind a guard that skipped on every lone +run, until T044 made them assert for real and moved them to +`tests/test_seam_assembly_beside_gate_and_projection.py`. That file was in the +declared exclusion (`tests/declared_exclusion.yaml`) under `doc_health` until +plan 034 T059 moved `snapshot_registry`'s one `doc_health` read out of its +module level. It runs in this leg's required check now. """ from __future__ import annotations diff --git a/tests/test_seam_assembly_beside_gate_and_projection.py b/tests/test_seam_assembly_beside_gate_and_projection.py index 7404bc8..3ef98ca 100644 --- a/tests/test_seam_assembly_beside_gate_and_projection.py +++ b/tests/test_seam_assembly_beside_gate_and_projection.py @@ -26,18 +26,21 @@ `test_assembly_is_order_insensitive`. The two columns are imported at module level, beside the three § 4.5 extensions, because every case in this file needs both. Where `doc_health` is present, as it is with openxFactory's -`scripts/` on `PYTHONPATH`, all six run and pass. In a lone checkout this -module cannot be imported, so it fails at collection, on `doc_health` alone. - -WHY IT IS DECLARED. That makes this file one that a lone checkout cannot run. -So `tests/declared_exclusion.yaml` lists it under `doc_health` (R1Q6 (d), -openxFactory#656 comment 5817152735), and the root `conftest.py` leaves it -out of every whole-suite run and reports it as an OPEN extraction. -`tests/test_declared_exclusion.py` holds it to that entry: run alone, it must -fail, and only on `doc_health`. Once the doc_health direction arc (plan 034 -T008) lets these modules import in a lone checkout, this file stops failing -and that check turns red. The file then leaves the declaration, in the pull -request that clears the reason. +`scripts/` on `PYTHONPATH`, all six run and pass. + +WHY IT WAS DECLARED, AND WHY IT IS NOT NOW. At T044 this module could not be +imported in a lone checkout: `serve_projection` imports `snapshot_registry`, +which read openxFactory's `doc_health` at module level. So +`tests/declared_exclusion.yaml` listed this file under `doc_health` (R1Q6 (d), +openxFactory#656 comment 5817152735), and the root `conftest.py` left it out +of every whole-suite run. Plan 034 T059 moved that read into the one method +that uses it (`SnapshotEntry.index_entry`), so openXdox's registry can be +registered at openDox's registry seam in a checkout without `doc_health` +(`openxdox.projection_contributions`). The module now imports in a lone +checkout, and all six cases pass there, so `tests/test_declared_exclusion.py` +turned red on this file's entry, as it is built to. The entry left the +declaration in T059, the pull request that cleared its reason, and the six +cases run in this leg's required check. WHAT STAYS BEHIND. The 24 seam cases that need neither column stay in their three suites, in the required check. They cover structural conformance, the From 7dfe70ca228025e551bd67ba479bbd673edd96fa Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:47:23 +0000 Subject: [PATCH 05/44] Wire the reviewed allow-list's subtraction into the unedited-by-the-arc check (plan 034 T059, T007 batch C) F5.2 and 12.5's falsifier each end by refusing any protected suite an arc landing touched. Batch C (RULED R1Q7 (a), openxFactory#656 comment 5817152735) has the check subtract the edits entered in tests/protected_suite_respellings.yaml, and only after validating that the landing's diff for that suite is exactly the entry's recorded text. scripts/protected_suites.py is that last step. The falsifier still lists the landings and the protected suites; the script admits a touched suite only where one entry holds at the landing (before and after blobs, old occurring once and giving the after text byte for byte, and both inside the named test), and exits 2, subtracting nothing, when the allow-list breaks its own rules. tests/test_protected_suite_check.py holds each rule against scratch histories. The allow-list's header now says who reads it and states the named-test condition. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/protected_suites.py | 315 +++++++++++++++++++++++++ tests/protected_suite_respellings.yaml | 15 +- tests/test_protected_suite_check.py | 255 ++++++++++++++++++++ 3 files changed, 580 insertions(+), 5 deletions(-) create mode 100644 scripts/protected_suites.py create mode 100644 tests/test_protected_suite_check.py diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py new file mode 100644 index 0000000..f02e188 --- /dev/null +++ b/scripts/protected_suites.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""The "unedited by the arc" check of F5.2 and 12.5's falsifier, with the +reviewed allow-list subtracted (plan 034 T059; T007's batch C). + +WHAT IT REPLACES. #1144's F5.2 (5.4a's falsifier) and 12.5's falsifier each end +the same way. They list the arc's landings in this repository +(`git log --first-parent --grep='^Arc: neutral-product-standalone-operability$' +"$ARC_BASE..HEAD"`), collect every path each landing touched against the main +before it (`git diff --name-only "$c^1" "$c"`), and refuse if any of those paths +is one of their protected suites. T007's batch C amends both (RULED R1Q7 (a), +openxFactory#656 comment `5817152735`): the check SUBTRACTS the edits entered in +this repository's reviewed allow-list, `tests/protected_suite_respellings.yaml`, +and it must validate, before trusting any subtraction, that the landing's +actual diff for that path contains ONLY the entry's recorded text. A path whose +landing diff does not match stays refused, exactly like an unentered edit. +Batch C gives the wiring to T059 and T086. This is T059's. + +HOW EACH FALSIFIER CALLS IT. The protected set and the landings are computed in +the falsifier's own block, as #1144 writes them. The last step, the inline +Python that intersected them, becomes this call, from the checkout's root: + + python3 scripts/protected_suites.py "$W/x-arc.txt" "$W/gen-suites.txt" # F5.2 + python3 scripts/protected_suites.py "$W/x-arc.txt" "$W/governed.txt" # 12.5 + +where `x-arc.txt` holds the landings, one commit per line, and the second file +the protected suites, one path per line. It exits 0 when no landing touched a +protected suite outside an entry that holds, 1 when one did (naming each +landing and path), and 2 when the allow-list itself breaks its rules. + +WHEN AN ENTRY HOLDS (the file's own header states the rule, and T060 wrote it). +For a landing L that touches a protected `suite`, an entry for that suite holds +at L when all of these are true: + +* `git rev-parse L^1:` is its `before_blob`, and `git rev-parse + L:` is its `after_blob`; +* its `old` text occurs exactly once in the `before_blob` text, and replacing + it with `new` gives the `after_blob` text byte for byte; +* the replaced text lies inside the one test the entry names, in the before + text, and its replacement lies inside that test in the after text. So an + entry cannot admit an edit to any other test of the suite. + +A landing that touches a protected suite is admitted for that suite only if one +entry holds at it. Every other protected path it touches is refused. + +THE ALLOW-LIST'S OWN RULES are checked before anything is subtracted, and a +file that breaks one refuses the whole check (exit 2) rather than subtracting +less: `schema_version` 1, `kind` `protected-suite-respellings`, no key given +twice, and each entry carrying exactly its declared keys, with blobs that are +full object ids, `old` and `new` that are whole lines ending in a newline, and +entries for one suite that chain (each `before_blob` is the previous entry's +`after_blob`). + +WHAT IT DOES NOT DO. It does not decide what is protected, and it does not find +the landings: both are the falsifier's, so the check here cannot drift from the +text #1144 ratified. It reads the allow-list from the working tree, at the head +the falsifier runs at. A CREATED file: no row in openxFactory's +`docs/opendox-carve-manifest.yaml` (RULED OQ-C). +""" + +from __future__ import annotations + +import ast +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +ALLOW_LIST = Path("tests") / "protected_suite_respellings.yaml" +KIND = "protected-suite-respellings" +SCHEMA_VERSION = 1 + +#: Every key an entry may carry, and the keys each kind of edit requires. +COMMON_KEYS = frozenset({"suite", "test", "landing", "edit", "ruled", "review", + "before_blob", "after_blob", "old", "new"}) +EDIT_KEYS = {"respelling": frozenset({"respelled"}), + "admitted": frozenset({"reason"})} + +_BLOB = re.compile(r"[0-9a-f]{40}") +_SUITE = re.compile(r"tests/test_[A-Za-z0-9_]+\.py") +_LANDING = re.compile(r"opensoft/openXdox-code#[1-9][0-9]*") +_TEST = re.compile(r"test_[A-Za-z0-9_]+") + + +class AllowListInvalid(ValueError): + """The allow-list breaks one of its own rules. Nothing is subtracted.""" + + +class _UniqueKeyLoader(yaml.SafeLoader): + """YAML keeps only the last of two equal keys. Refuse the second instead.""" + + +def _mapping(loader: _UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False): + seen: set[Any] = set() + for key_node, _value in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in seen: + raise AllowListInvalid( + f"the key {key!r} is given twice (line {key_node.start_mark.line + 1})") + seen.add(key) + return loader.construct_mapping(node, deep=deep) + + +_UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _mapping) + + +def _text(value: Any, where: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise AllowListInvalid(f"{where} is not a non-empty text") + return value + + +def _whole_lines(value: Any, where: str) -> str: + text = _text(value, where) + if not text.endswith("\n"): + raise AllowListInvalid(f"{where} does not end in a newline, so it is not whole lines") + return text + + +def load_allow_list(path: Path) -> list[dict]: + """The allow-list's entries, in landing order, once every rule holds.""" + try: + raw = yaml.load(path.read_text(encoding="utf-8"), Loader=_UniqueKeyLoader) + except OSError as exc: + raise AllowListInvalid(f"{path} could not be read: {exc}") from exc + except yaml.YAMLError as exc: + raise AllowListInvalid(f"{path} is not valid YAML: {exc}") from exc + if not isinstance(raw, dict): + raise AllowListInvalid(f"{path} is not a mapping") + if set(raw) != {"schema_version", "kind", "entries"}: + raise AllowListInvalid( + f"{path} carries {sorted(map(str, raw))}, not schema_version, kind and entries") + version = raw["schema_version"] + if isinstance(version, bool) or version != SCHEMA_VERSION: + raise AllowListInvalid(f"schema_version is {version!r}, not {SCHEMA_VERSION}") + if raw["kind"] != KIND: + raise AllowListInvalid(f"kind is {raw['kind']!r}, not {KIND!r}") + entries = raw["entries"] + if not isinstance(entries, list): + raise AllowListInvalid("entries is not a list") + last_after: dict[str, str] = {} + for n, entry in enumerate(entries, 1): + where = f"entry {n}" + if not isinstance(entry, dict): + raise AllowListInvalid(f"{where} is not a mapping") + edit = entry.get("edit") + if edit not in EDIT_KEYS: + raise AllowListInvalid(f"{where}: edit is {edit!r}, not one of {sorted(EDIT_KEYS)}") + wanted = COMMON_KEYS | EDIT_KEYS[edit] + if set(entry) != wanted: + raise AllowListInvalid( + f"{where}: carries {sorted(map(str, entry))}, and a {edit} entry " + f"carries exactly {sorted(wanted)}") + for key in wanted - {"old", "new"}: + _text(entry[key], f"{where}: {key}") + if not _SUITE.fullmatch(entry["suite"]): + raise AllowListInvalid(f"{where}: suite {entry['suite']!r} is not tests/test_.py") + if not _TEST.fullmatch(entry["test"]): + raise AllowListInvalid(f"{where}: test {entry['test']!r} is not a test's name") + if not _LANDING.fullmatch(entry["landing"]): + raise AllowListInvalid( + f"{where}: landing {entry['landing']!r} is not opensoft/openXdox-code#") + for key in ("before_blob", "after_blob"): + if not _BLOB.fullmatch(entry[key]): + raise AllowListInvalid(f"{where}: {key} is not a full object id") + old = _whole_lines(entry["old"], f"{where}: old") + new = _whole_lines(entry["new"], f"{where}: new") + if old == new: + raise AllowListInvalid(f"{where}: old and new are the same text") + suite = entry["suite"] + if suite in last_after and entry["before_blob"] != last_after[suite]: + raise AllowListInvalid( + f"{where}: its before_blob is not the after_blob of the entry before it " + f"for {suite}, so the two do not chain") + last_after[suite] = entry["after_blob"] + return entries + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run(("git", "-C", str(repo), *args), check=True, + capture_output=True, text=True).stdout + + +def _blob(repo: Path, revision: str, path: str) -> str | None: + done = subprocess.run(("git", "-C", str(repo), "rev-parse", "--verify", "--quiet", + f"{revision}:{path}"), capture_output=True, text=True) + return done.stdout.strip() if done.returncode == 0 else None + + +def _blob_text(repo: Path, blob: str) -> str: + return subprocess.run(("git", "-C", str(repo), "cat-file", "blob", blob), + check=True, capture_output=True).stdout.decode("utf-8") + + +def _test_lines(text: str, test: str) -> tuple[int, int] | None: + """The 1-based line span of the module-level test function `test`, or None.""" + try: + tree = ast.parse(text) + except SyntaxError: + return None + found = [node for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == test] + if len(found) != 1: + return None + node = found[0] + return min([node.lineno, *(d.lineno for d in node.decorator_list)]), node.end_lineno + + +def _inside_the_test(text: str, start: int, piece: str, test: str) -> bool: + span = _test_lines(text, test) + if span is None: + return False + first_line = text.count("\n", 0, start) + 1 + last_line = first_line + piece.count("\n") - 1 + return span[0] <= first_line and last_line <= span[1] + + +def entry_holds(repo: Path, landing: str, entry: dict) -> str | None: + """None when `entry` holds at `landing`, else why it does not.""" + suite = entry["suite"] + before = _blob(repo, f"{landing}^1", suite) + after = _blob(repo, landing, suite) + if before != entry["before_blob"]: + return f"{suite} before the landing is {before}, not the entry's {entry['before_blob']}" + if after != entry["after_blob"]: + return f"{suite} at the landing is {after}, not the entry's {entry['after_blob']}" + before_text, after_text = _blob_text(repo, before), _blob_text(repo, after) + old, new = entry["old"], entry["new"] + if before_text.count(old) != 1: + return f"the entry's old text occurs {before_text.count(old)} times before the landing, not once" + at = before_text.index(old) + if before_text.replace(old, new, 1) != after_text: + return "replacing the entry's old text with its new text does not give the suite at the landing" + if not _inside_the_test(before_text, at, old, entry["test"]): + return f"the entry's old text is not inside {entry['test']} before the landing" + if not _inside_the_test(after_text, at, new, entry["test"]): + return f"the entry's new text is not inside {entry['test']} at the landing" + return None + + +@dataclass +class Finding: + landing: str + path: str + admitted_by: int | None + why: str + + +def check(repo: Path, landings: list[str], protected: set[str], + entries: list[dict]) -> list[Finding]: + """One finding per protected path each landing touched: admitted by the + entry (1-based) that holds there, or refused with every entry's reason.""" + findings: list[Finding] = [] + for landing in landings: + touched = {line.strip() for line in + _git(repo, "diff", "--name-only", f"{landing}^1", landing).splitlines() + if line.strip()} + for path in sorted(touched & protected): + reasons = [] + admitted = None + for n, entry in enumerate(entries, 1): + if entry["suite"] != path: + continue + why = entry_holds(repo, landing, entry) + if why is None: + admitted = n + break + reasons.append(f"entry {n}: {why}") + findings.append(Finding( + landing, path, admitted, + "" if admitted else ("; ".join(reasons) or "no entry names this suite"))) + return findings + + +def _lines(path: str) -> list[str]: + return [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines() + if line.strip()] + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + if len(argv) != 2: + print("usage: protected_suites.py ", + file=sys.stderr) + return 2 + repo = Path.cwd() + try: + entries = load_allow_list(repo / ALLOW_LIST) + except AllowListInvalid as exc: + print(f"FAIL: {ALLOW_LIST} breaks its own rules, so nothing is subtracted: {exc}", + file=sys.stderr) + return 2 + findings = check(repo, _lines(argv[0]), set(_lines(argv[1])), entries) + refused = [] + for f in findings: + if f.admitted_by is not None: + print(f"admitted: {f.landing[:12]} {f.path}, by entry {f.admitted_by} of {ALLOW_LIST}") + else: + print(f"refused: {f.landing[:12]} {f.path}: {f.why}") + refused.append(f.path) + if refused: + print("FAIL: the arc edited protected suites outside the reviewed allow-list: " + + ", ".join(sorted(set(refused))), file=sys.stderr) + return 1 + print(f"ok: {len(findings)} protected edit(s), each entered and holding") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/protected_suite_respellings.yaml b/tests/protected_suite_respellings.yaml index 226dc82..c93a645 100644 --- a/tests/protected_suite_respellings.yaml +++ b/tests/protected_suite_respellings.yaml @@ -33,10 +33,12 @@ # entry. Each later admitted edit appends its own entry in the pull request # that makes the edit. # -# WHO READS IT. F5.2 and 12.5's falsifier, once plan 034 T059 and T086 wire the -# subtraction into both checks (batch C). Until then nothing reads this file, -# and both falsifiers still refuse every protected path an arc landing touches, -# as written. +# WHO READS IT. `scripts/protected_suites.py`, since plan 034 T059 (batch C's +# wiring). It is the last step of F5.2 and of 12.5's falsifier, in place of the +# inline intersection each ended with: the falsifier still lists the landings +# and the protected suites itself, and the script subtracts the entries that +# hold. Its docstring gives both calls. Until T059 nothing read this file, and +# both falsifiers refused every protected path an arc landing touched. # # THE RULES. # * `schema_version` is the integer 1, and `kind` is @@ -62,7 +64,10 @@ # is `after_blob`; and replacing `old`, which occurs exactly once in the # `before_blob` text, with `new` gives the `after_blob` text byte for byte. # A landing whose diff for a protected path matches no entry is refused, -# exactly as an unentered edit is (batch C). +# exactly as an unentered edit is (batch C). The check T059 wired holds a +# fourth, from `test`: `old` lies inside the named test in the +# `before_blob` text, and `new` inside it in the `after_blob` text, so an +# entry cannot admit an edit to another test of the suite. # * Entries for one suite CHAIN: each one's `before_blob` is the previous # one's `after_blob`, because each edit is made to the suite the last one # left. diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py new file mode 100644 index 0000000..abafde4 --- /dev/null +++ b/tests/test_protected_suite_check.py @@ -0,0 +1,255 @@ +"""The allow-list subtraction F5.2 and 12.5's falsifier run +(`scripts/protected_suites.py`; plan 034 T059, T007's batch C). + +The check runs against a real history, so these cases build one: a scratch +repository whose commits play the arc's landings. A landing is a first-parent +commit carrying the `Arc:` line. Each case holds one rule of the check: + +* an arc landing that touches a protected suite with no entry is refused; +* one whose diff for that suite is exactly an entry's `old` to `new`, inside + the test the entry names, is admitted; +* an entry is refused as soon as its landing's diff differs from its recorded + text in any way: another edit beside it, a text that occurs twice, an edit in + another test, or blobs that are not the entry's; +* a commit without the `Arc:` line is not a landing, whatever it touches; +* entries for one suite chain, and a file that breaks its own rules refuses + the whole check rather than subtracting less. + +The last cases hold this repository's own allow-list to those rules. Which +landing each entry holds at is the falsifier's to show, at the head it runs +at: a pull request's checkout here has no history to walk. + +A CREATED file: no manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest +import yaml + +import protected_suites as ps + +REPO_ROOT = Path(__file__).resolve().parents[1] +ARC = "Arc: neutral-product-standalone-operability" +SUITE = "tests/test_governed.py" + +BEFORE = textwrap.dedent('''\ + def test_first() -> None: + assert 1 == 1 + + + def test_second() -> None: + """The second.""" + assert {"a": 1} == {"a": 1} +''') +OLD = ''' assert {"a": 1} == {"a": 1} +''' +NEW = ''' assert {"a": 1, "b": 2} == {"a": 1, "b": 2} +''' +AFTER = BEFORE.replace(OLD, NEW) + + +class Repo: + def __init__(self, root: Path) -> None: + self.root = root + self.env = {**os.environ, + "GIT_AUTHOR_NAME": "fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid", + "GIT_COMMITTER_NAME": "fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid"} + root.mkdir(parents=True) + self.git("init", "-q", "-b", "main") + + def git(self, *args: str) -> str: + return subprocess.run(("git", "-C", str(self.root), *args), check=True, + capture_output=True, text=True, env=self.env).stdout.strip() + + def commit(self, files: dict[str, str], message: str) -> str: + for rel, text in files.items(): + path = self.root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + self.git("add", "--", rel) + self.git("commit", "-q", "-m", message) + return self.git("rev-parse", "HEAD") + + def blob(self, text: str) -> str: + return subprocess.run(("git", "-C", str(self.root), "hash-object", "--stdin"), + input=text, check=True, capture_output=True, + text=True).stdout.strip() + + +@pytest.fixture +def repo(tmp_path: Path) -> Repo: + made = Repo(tmp_path / "repo") + made.commit({SUITE: BEFORE, "README.md": "base\n"}, "base") + return made + + +def _entry(repo: Repo, *, before: str = BEFORE, after: str = AFTER, old: str = OLD, + new: str = NEW, test: str = "test_second", **extra) -> dict: + entry = { + "suite": SUITE, "test": test, "landing": "opensoft/openXdox-code#1", + "edit": "admitted", "reason": "a reason", "ruled": "a ruling", + "review": "a review", "before_blob": repo.blob(before), + "after_blob": repo.blob(after), "old": old, "new": new, + } + entry.update(extra) + return entry + + +def _check(repo: Repo, entries: list[dict]) -> list[ps.Finding]: + landings = repo.git("log", "--first-parent", "--format=%H", f"--grep=^{ARC}$", + "HEAD").splitlines() + return ps.check(repo.root, landings, {SUITE}, entries) + + +# -------------------------------------------------------------------------- +# the check +# -------------------------------------------------------------------------- + +def test_an_unentered_edit_to_a_protected_suite_is_refused(repo) -> None: + repo.commit({SUITE: AFTER}, f"edit\n\n{ARC}") + [finding] = _check(repo, []) + assert finding.admitted_by is None and finding.path == SUITE + assert "no entry names this suite" in finding.why + + +def test_an_edit_that_is_exactly_its_entry_is_admitted(repo) -> None: + repo.commit({SUITE: AFTER}, f"edit\n\n{ARC}") + [finding] = _check(repo, [_entry(repo)]) + assert finding.admitted_by == 1 + + +def test_an_edit_beside_the_entered_one_is_refused(repo) -> None: + """The weakening batch C names: the entered text changes, and another + assertion is weakened in the same landing.""" + weakened = AFTER.replace("assert 1 == 1", "assert True") + repo.commit({SUITE: weakened}, f"edit\n\n{ARC}") + [finding] = _check(repo, [_entry(repo)]) + assert finding.admitted_by is None + assert "not the entry's" in finding.why + + +def test_an_entry_whose_blobs_match_but_whose_text_does_not_is_refused(repo) -> None: + """The blobs are the landing's, but the recorded text is not the edit.""" + repo.commit({SUITE: AFTER}, f"edit\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, new=NEW.replace('"b": 2', '"b": 3'))]) + assert finding.admitted_by is None + assert "does not give the suite at the landing" in finding.why + + +def test_an_old_text_that_occurs_twice_is_refused(repo) -> None: + twice = BEFORE + "\n\ndef test_third() -> None:\n" + OLD + repo.commit({SUITE: twice}, "the same assertion in a third test") + repo.commit({SUITE: twice.replace(OLD, NEW, 1)}, f"edit\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, before=twice, after=twice.replace(OLD, NEW, 1))]) + assert finding.admitted_by is None + assert "occurs 2 times" in finding.why + + +def test_an_edit_outside_the_named_test_is_refused(repo) -> None: + """The text is exact, but it lies in `test_second`, and the entry names + `test_first`.""" + repo.commit({SUITE: AFTER}, f"edit\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, test="test_first")]) + assert finding.admitted_by is None + assert "not inside test_first" in finding.why + + +def test_a_commit_without_the_trailer_is_not_a_landing(repo) -> None: + repo.commit({SUITE: AFTER}, "an edit that is no arc landing") + assert _check(repo, []) == [] + + +def test_a_landing_that_touches_no_protected_suite_is_not_reported(repo) -> None: + repo.commit({"README.md": "changed\n"}, f"docs\n\n{ARC}") + assert _check(repo, []) == [] + + +def test_two_landings_admitted_by_two_chained_entries(repo) -> None: + repo.commit({SUITE: AFTER}, f"first edit\n\n{ARC}") + third = AFTER.replace("assert 1 == 1", "assert 2 == 2") + repo.commit({SUITE: third}, f"second edit\n\n{ARC}") + first = _entry(repo) + second = _entry(repo, before=AFTER, after=third, test="test_first", + old=" assert 1 == 1\n", new=" assert 2 == 2\n") + findings = _check(repo, [first, second]) + assert sorted(f.admitted_by for f in findings) == [1, 2] + + +def test_the_command_exits_one_on_a_refusal_and_zero_when_every_edit_holds(repo, tmp_path, + monkeypatch) -> None: + repo.commit({SUITE: AFTER}, f"edit\n\n{ARC}") + landings = tmp_path / "x-arc.txt" + landings.write_text(repo.git("log", "--first-parent", "--format=%H", + f"--grep=^{ARC}$", "HEAD") + "\n", encoding="utf-8") + suites = tmp_path / "suites.txt" + suites.write_text(SUITE + "\n", encoding="utf-8") + allow = repo.root / ps.ALLOW_LIST + monkeypatch.chdir(repo.root) + allow.write_text(yaml.safe_dump({"schema_version": 1, "kind": ps.KIND, "entries": []}), + encoding="utf-8") + assert ps.main([str(landings), str(suites)]) == 1 + allow.write_text(yaml.safe_dump({"schema_version": 1, "kind": ps.KIND, + "entries": [_entry(repo)]}), encoding="utf-8") + assert ps.main([str(landings), str(suites)]) == 0 + allow.write_text("schema_version: 1\nschema_version: 1\n", encoding="utf-8") + assert ps.main([str(landings), str(suites)]) == 2 + + +# -------------------------------------------------------------------------- +# the allow-list's own rules +# -------------------------------------------------------------------------- + +def _write(tmp_path: Path, document: object) -> Path: + path = tmp_path / "allow.yaml" + path.write_text(document if isinstance(document, str) else yaml.safe_dump(document), + encoding="utf-8") + return path + + +def _valid_entry(**changes) -> dict: + entry = {"suite": SUITE, "test": "test_second", "landing": "opensoft/openXdox-code#7", + "edit": "admitted", "reason": "r", "ruled": "u", "review": "v", + "before_blob": "a" * 40, "after_blob": "b" * 40, + "old": "x\n", "new": "y\n"} + entry.update(changes) + return entry + + +@pytest.mark.parametrize("broken, why", [ + ({"schema_version": 2, "kind": ps.KIND, "entries": []}, "schema_version"), + ({"schema_version": True, "kind": ps.KIND, "entries": []}, "schema_version"), + ({"schema_version": 1, "kind": "other", "entries": []}, "kind"), + ({"schema_version": 1, "kind": ps.KIND, "entries": [], "extra": 1}, "carries"), + ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(edit="rewrite")]}, "edit is"), + ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(respelled="x")]}, "carries"), + ({"schema_version": 1, "kind": ps.KIND, + "entries": [{k: v for k, v in _valid_entry().items() if k != "review"}]}, "carries"), + ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(before_blob="abc")]}, "full object id"), + ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(old="x")]}, "newline"), + ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(new="x\n")] }, "same text"), + ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(suite="src/x.py")]}, "suite"), + ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(landing="#7")]}, "landing"), + ({"schema_version": 1, "kind": ps.KIND, + "entries": [_valid_entry(), _valid_entry(before_blob="c" * 40)]}, "do not chain"), + ("schema_version: 1\nschema_version: 1\n", "given twice"), +], ids=lambda value: value if isinstance(value, str) else None) +def test_a_list_that_breaks_its_own_rules_subtracts_nothing(tmp_path, broken, why) -> None: + with pytest.raises(ps.AllowListInvalid, match=why): + ps.load_allow_list(_write(tmp_path, broken)) + + +def test_a_respelling_names_what_it_respelled(tmp_path) -> None: + entry = _valid_entry(edit="respelling", respelled="a -> b") + del entry["reason"] + assert ps.load_allow_list(_write(tmp_path, { + "schema_version": 1, "kind": ps.KIND, "entries": [entry]})) == [entry] + + +def test_this_repositorys_allow_list_keeps_its_rules() -> None: + assert ps.load_allow_list(REPO_ROOT / ps.ALLOW_LIST), "the allow-list enters no edit" From 81a01cd0807b21df62d95ef154541afa2472542d Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:47:48 +0000 Subject: [PATCH 06/44] The overlay test expects the values block's six leaves: batch I's second admitted edit (plan 034 T059) At this pin openDox's SNAPSHOT_VALUES defaults are its neutral snapshot's values (T054), so openXdox's DISPLAY facet's values block (T060) changes six values.* leaves of the served display beside the four stage words and the named absence. test_the_overlay_changes_four_words_and_the_named_absence_and_nothing_else now expects eleven changed leaves, and no other assertion of the suite changes (RULED R1Q26 (a), openxFactory#656 comment 5851950767, on R1Q11 (a), comment 5850003126; batch I). The edit is entered in tests/protected_suite_respellings.yaml with its reason, as an admitted edit, in this pull request. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_gate_loop_views.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_gate_loop_views.py b/tests/test_gate_loop_views.py index a56906c..a5ce00c 100644 --- a/tests/test_gate_loop_views.py +++ b/tests/test_gate_loop_views.py @@ -1732,11 +1732,19 @@ def test_every_other_stage_still_renders_the_neutral_word(register_host) -> None def test_the_overlay_changes_four_words_and_the_named_absence_and_nothing_else( register_host) -> None: - """Against the same host WITHOUT the facet: five leaves differ, and they are these. + """Against the same host WITHOUT the facet: eleven leaves differ, and they are these. Both payloads come through the same chain. The facet-less host is registered second, after an explicit `unregister()`, because the registry refuses a second, different profile over a first. + + SIX OF THEM ARE THE `values` BLOCK'S, which is not a stage (plan 034 T059, + RULED R1Q26 (a), `opensoft/openxFactory#656` comment `5851950767`, on R1Q11 + (a), comment `5850003126`). At this pin openDox's `SNAPSHOT_VALUES` + defaults are its neutral snapshot's values (T054), so the facet's block + changes the six values a view matches the governed snapshot by. The edit + that admitted them is entered, with its reason, in + `tests/protected_suite_respellings.yaml`. """ from opendox import domain_profile as registry @@ -1756,6 +1764,12 @@ def test_the_overlay_changes_four_words_and_the_named_absence_and_nothing_else( "stages.completion.many": ("completed items", IMPLEMENTED_ITEMS), "stages.completion.short": ("completed", IMPLEMENTED), "stages.completion.label": ("completed", IMPLEMENTED), + "values.document_stage.captured": ("source", "brainstorm"), + "values.document_stage.organized": ("grouping", "staged"), + "values.register_state.captured": ("unselected", "latent"), + "values.register_state.proposed": ("selected", "picked"), + "values.register_state.retired": ("declined", "rejected"), + "values.register_state.superseded": ("replaced", "superseded"), } From 614f79f05c7d9570689bf4d3c07d9d6bfec6c8f6 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:47:48 +0000 Subject: [PATCH 07/44] Respell the session-source confinement test's reference to the seam T055 moved (plan 034 T059, R1Q7 (a)) openDox-code#59 (T055) routes /source through resolve_source_path over the resolved entry's own root (r4136863569), where the route used to call self.source.registry.resolve_source. The test's expected call is respelled to the route's one entry point, and its second assertion, that the registry's resolve_source reaches resolve_within exactly once, is unchanged (holder's ruling on T059). The respelling is entered in tests/protected_suite_respellings.yaml in this pull request. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_session_snapshot.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_session_snapshot.py b/tests/test_session_snapshot.py index b5e1be0..f78c445 100644 --- a/tests/test_session_snapshot.py +++ b/tests/test_session_snapshot.py @@ -445,13 +445,15 @@ def test_the_keyed_source_returns_the_worktree_bytes_and_never_falls_back( def test_the_session_source_read_is_the_existing_confinement_mechanism(): - """T035: the confinement is `registry.resolve_source` + `resolve_within`, not - a new check bolted onto the session path. Pinned on the route's own source so - a later "simplification" cannot re-implement containment beside it.""" + """T035: the confinement is the registry's per-entry `resolve_within`, reached + through the route's one entry point `resolve_source_path` over the resolved + entry's own root (openDox-code#59, r4136863569), not a new check bolted onto + the session path. Pinned on the route's own source so a later + "simplification" cannot re-implement containment beside it.""" import inspect source = inspect.getsource(serve_mod.DashboardHandler._serve_source) - assert "self.source.registry.resolve_source(" in source + assert "resolve_source_path(Path(root), rest)" in source assert inspect.getsource(reg.SnapshotRegistry.resolve_source).count( "resolve_within(") == 1 From f0f7f41c396d0c38746e5c6bf0fa9577f6c48950 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:51:44 +0000 Subject: [PATCH 08/44] Re-pin openDox-code 814516b7, openDox-code#59's head after T054 landed (plan 034 T059) openDox-code#59 merged main a691e4e4 (T054, openDox-code#57) after this branch pinned e3ef506a, so the pin follows it to 814516b7, and the comments that name the pin follow it too. The pin is still a draft's: it re-points to T062's commit before T059 lands. Measured at 814516b7: ViewBinding has styles and exports; web/, view_extension.py and tests/test_binding_stylesheets.py are unchanged from e3ef506a; tests/test_consumer_reach.py is unchanged, so NEUTRAL_MODULES still holds thirteen; the census returns the same three rows; and 2d116415..814516b7 is twenty-five first-parent commits. The whole suite reads the same at both heads (967 passed, 4 skipped, 1 deselected). Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 2 +- pyproject.toml | 2 +- src/openxdox/view_extensions.py | 8 ++++---- tests/integration/test_assembled_bundle.py | 2 +- tests/test_dependency_direction.py | 6 +++--- tests/test_gate_loop_probes.py | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d59134a..14b998e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -132,7 +132,7 @@ jobs: # imported carved session fixtures that reached `ideation_dashboard` and # `doc_health`, two packages neither leg carries. At the openDox this # leg pinned from plan 034 T040 (openDox-code `2d116415`), and at the one - # it pins since plan 034 T059 (`e3ef506a`), the chain loads in a lone + # it pins since plan 034 T059 (`814516b7`), the chain loads in a lone # checkout, and what still reaches openxFactory is what the declaration # and `LEFT_OUT` hold. Each of the sixteen files' # account of itself (why it joined the list, its counts, its review diff --git a/pyproject.toml b/pyproject.toml index 26cbb32..14d480d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,7 @@ requires-python = ">=3.12" # PR) — and not an arbitrary choice: `Draft202012Validator`, the name # `gate_console.py:563` imports, was added in jsonschema 4.18.0. dependencies = [ - "opendox @ git+https://github.com/opensoft/openDox-code@e3ef506a8036c1360d88ff75cab50f8b8e5d5fca", + "opendox @ git+https://github.com/opensoft/openDox-code@814516b778b04d4d5022e486c657774b61c86f15", "PyYAML>=6.0", "jsonschema>=4.18", ] diff --git a/src/openxdox/view_extensions.py b/src/openxdox/view_extensions.py index e3b2c9b..3c1b64b 100644 --- a/src/openxdox/view_extensions.py +++ b/src/openxdox/view_extensions.py @@ -25,7 +25,7 @@ `exports` field RULED Q2 adds is newer still". THAT IS NO LONGER TRUE, and the old wording is quoted here as provenance rather than deleted: the pin named openDox-code#55 (`2d116415`, plan 034 T037's landing) from plan 034 T040, and -names openDox-code `e3ef506a` (the head of openDox-code#59, T055) since plan 034 +names openDox-code `814516b7` (the head of openDox-code#59, T055) since plan 034 T059; at both `view_extension` is importable and `ViewBinding` takes `exports` — measured, and the three materialization assertions in `tests/test_gate_loop_views.py` run and pass against it instead of skipping. @@ -33,14 +33,14 @@ which is where that wording was corrected; the pin then crossed `0e65b5f8` (#24) to `5c137a90` (openDox-code#27, § 3.4 RULED Q7), whose `ViewBinding` first carried a `styles` field — absent at `0b4e8bbf`, present at `5c137a90` -and still at `2d116415` and at `e3ef506a`, measured by `dataclasses.fields()` +and still at `2d116415` and at `814516b7`, measured by `dataclasses.fields()` in a venv at each pin. THE `5c137a90` BUMP ITSELF READ NOTHING, and the review of `ea6991b` was right to check that: it materialized `VIEW_BINDING_SPECS` unchanged and asked nothing about the installed `ViewBinding`. THE READING IS THIS ACT'S, and this act is the pull request that bump named as waiting on it: `specs_for()` below reads `dataclasses.fields(binding_cls)` and drops `styles` where the installed dataclass has no such field. MEASURED IN A VENV AT THAT PIN, and again at -`2d116415` and at `e3ef506a`: it has one, so nothing is dropped, every binding that owns +`2d116415` and at `814516b7`: it has one, so nothing is dropped, every binding that owns selectors declares its sheet, and the four contributed stylesheets are LIVE rather than inert — which is the one thing they waited on that bump for. @@ -519,7 +519,7 @@ def specs_for(binding_cls: Any) -> tuple[dict[str, Any], ...]: So the field is DROPPED where the installed class does not take it and the column mounts unstyled. AT THE PIN THIS LEG DECLARES TODAY THE DETECTION IS THE PLAIN PATH, not a fallback: `dataclasses.fields()` finds `styles` on - `e3ef506a`'s `ViewBinding`, as on `2d116415`'s and on `5c137a90`'s where the field first + `814516b7`'s `ViewBinding`, as on `2d116415`'s and on `5c137a90`'s where the field first reached the pin, every spec crosses whole, and the four contributed sheets are LIVE — same code, same behaviour, one branch not taken. This paragraph read "the pin bump that follows openDox-code's Q7 leg turns the sheets on with no edit here"; that bump landed, and that is what diff --git a/tests/integration/test_assembled_bundle.py b/tests/integration/test_assembled_bundle.py index 158b181..703029b 100644 --- a/tests/integration/test_assembled_bundle.py +++ b/tests/integration/test_assembled_bundle.py @@ -31,7 +31,7 @@ `_ST_DECLARATION` and `_declared_st_tokens`) and the `GATE_EXCLUSIVE` tuple are openDox-code's text at `55194335`, the last commit that carried all five, byte for byte. The tuple and the three helpers openDox-code kept are unchanged at -`2d116415` and at `e3ef506a`. Their comments are kept too, so "Copilot review, round N" in them +`2d116415` and at `814516b7`. Their comments are kept too, so "Copilot review, round N" in them is a round on openDox-code#27, where that file was written. Four things changed, each because this is the composition and not a lone leg: 1. A missing assembly FAILS here, where it skipped there. The composition is diff --git a/tests/test_dependency_direction.py b/tests/test_dependency_direction.py index 52c253b..49de0b4 100644 --- a/tests/test_dependency_direction.py +++ b/tests/test_dependency_direction.py @@ -368,9 +368,9 @@ def test_the_doc_health_implementation_surface_is_exactly_declared() -> None: #: carried-forward memory. #: #: PLAN 034 T059 LOWERED IT, the first fall since slice 2b. The pin moved from -#: `2d116415` to openDox-code `e3ef506a`, the head of openDox-code#59 (T055, +#: `2d116415` to openDox-code `814516b7`, the head of openDox-code#59 (T055, #: openDox's own snapshot registry and source, corpus-root predicate, writer -#: and validator lookup behind seams of their own), twenty-three first-parent +#: and validator lookup behind seams of their own), twenty-five first-parent #: commits later. At that tree `_back_import_census()` returns three rows: #: `cli.py` (0, 1) and `serve.py` (0, 2) reach (0, 0) and leave the table, and #: `branch_session.py` falls from (0, 7) to (0, 2). The eight reaches T055 @@ -420,7 +420,7 @@ def test_the_doc_health_implementation_surface_is_exactly_declared() -> None: #: `NEUTRAL_MODULES` is the asserted half. At `2d116415` it held nine: #: `workbench`, `serve_workbench`, `consumer_reach`, `branch_session`, #: `domain_profile`, `profile_proxy`, `view_extension`, `cli` and `serve`. At -#: `e3ef506a` it holds thirteen, T055's four new modules with them: +#: `814516b7` it holds thirteen, T055's four new modules with them: #: `projection_seams`, `default_registry`, `default_projection` and `rfc3339`. #: Its `STILL_REACHING` is empty at both. #: diff --git a/tests/test_gate_loop_probes.py b/tests/test_gate_loop_probes.py index 7355bc0..a908799 100644 --- a/tests/test_gate_loop_probes.py +++ b/tests/test_gate_loop_probes.py @@ -194,7 +194,7 @@ def bundle(tmp_path) -> Path: # Its four-row table is there, not restated here. Round 1 of the review on # #21 found this file claiming the install "came from somewhere older than # the declared pin" on a check that only tested for a marker. UNDER THAT - # CHECK, had the DECLARED leg (`0b4e8bbf` then, `e3ef506a` now) itself ever + # CHECK, had the DECLARED leg (`0b4e8bbf` then, `814516b7` now) itself ever # stopped shipping `web/**`, all thirteen # probes below would have skipped and this required check would have stayed # green over the regression. UNDER THE TABLE THEY OBEY NOW THEY FAIL: the From 619684c9be56661e497e006ed0618d63d2e8268b Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:54:15 +0000 Subject: [PATCH 09/44] Enter T059's two protected edits in the reviewed allow-list (plan 034 T059) Entry 2 is batch I's second admitted edit, to the overlay test in tests/test_gate_loop_views.py (R1Q26 (a)); it chains on entry 1, T060's, from a56906c6 to a5ce00cc. Entry 3 is the respelling in tests/test_session_snapshot.py of the route's call into the confinement, as T055 moved it (R1Q7 (a), r4136863569), from b5e1be02 to f78c4452. Both name opensoft/openXdox-code#35, this pull request, since the landing's commit is not known inside it (T019's rule). scripts/protected_suites.py admits each at its commit on this branch, and entry 1 at T060's landing. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/protected_suite_respellings.yaml | 141 +++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/tests/protected_suite_respellings.yaml b/tests/protected_suite_respellings.yaml index c93a645..97d6ad9 100644 --- a/tests/protected_suite_respellings.yaml +++ b/tests/protected_suite_respellings.yaml @@ -138,3 +138,144 @@ entries: "retired": "rejected", "superseded": "superseded"}}, } + - suite: tests/test_gate_loop_views.py + test: test_the_overlay_changes_four_words_and_the_named_absence_and_nothing_else + landing: opensoft/openXdox-code#35 + edit: admitted + reason: >- + 5.3a's `values` block at a pin past plan 034 T054 (plan 034 T059). At + that pin openDox's `SNAPSHOT_VALUES` defaults are its neutral snapshot's + values, so the DISPLAY facet's `values` block (T060) changes six + `values.*` leaves of the served display beside the four stage words and + the named absence. The test that pins what the overlay changes now + expects those six leaves too, and its docstring says why. No other + assertion of the suite changes. This is the second of the two edits + batch I admits; T060 made the first. + ruled: >- + R1Q26 (a), opensoft/openxFactory#656 comment 5851950767 (kept at + 5852513402), on R1Q11 (a), comment 5850003126. It is recorded in #1144's + 12.5 falsifier by T007's batch I (openxFactory#1180, landed as 8421603a). + review: >- + Reviewed in the landing pull request on batch C's basis. Its diff for + this suite is exactly `old` to `new`, inside the one test named. The + test still compares the whole set of changed leaves by equality, so a + leaf the overlay changed and the test did not name would still fail it; + the six added leaves are each the governed value over openDox's neutral + default, measured at the pin (T060's body lists the same six). No + assertion is weakened. + before_blob: a56906c6c56b1c2db5e45aee5e93e6f07740484a + after_blob: a5ce00cc7cafb54928e64d9f7a7db34a7d252c39 + old: | + def test_the_overlay_changes_four_words_and_the_named_absence_and_nothing_else( + register_host) -> None: + """Against the same host WITHOUT the facet: five leaves differ, and they are these. + + Both payloads come through the same chain. The facet-less host is registered + second, after an explicit `unregister()`, because the registry refuses a + second, different profile over a first. + """ + from opendox import domain_profile as registry + + display_profile, view_extension = _display_profile_or_skip() + register_host(_engineering_host("DISPLAY")) + declared = _flatten(_served_display(display_profile, view_extension)) + registry.unregister() + register_host(_engineering_host()) + absent = _flatten(_served_display(display_profile, view_extension)) + assert absent["host_facet"] == "absent" + assert declared.keys() == absent.keys() + changed = {path: (absent[path], declared[path]) + for path in declared if declared[path] != absent[path]} + assert changed == { + "host_facet": ("absent", "declared"), + "stages.completion.one": ("completed item", IMPLEMENTED_ITEM), + "stages.completion.many": ("completed items", IMPLEMENTED_ITEMS), + "stages.completion.short": ("completed", IMPLEMENTED), + "stages.completion.label": ("completed", IMPLEMENTED), + new: | + def test_the_overlay_changes_four_words_and_the_named_absence_and_nothing_else( + register_host) -> None: + """Against the same host WITHOUT the facet: eleven leaves differ, and they are these. + + Both payloads come through the same chain. The facet-less host is registered + second, after an explicit `unregister()`, because the registry refuses a + second, different profile over a first. + + SIX OF THEM ARE THE `values` BLOCK'S, which is not a stage (plan 034 T059, + RULED R1Q26 (a), `opensoft/openxFactory#656` comment `5851950767`, on R1Q11 + (a), comment `5850003126`). At this pin openDox's `SNAPSHOT_VALUES` + defaults are its neutral snapshot's values (T054), so the facet's block + changes the six values a view matches the governed snapshot by. The edit + that admitted them is entered, with its reason, in + `tests/protected_suite_respellings.yaml`. + """ + from opendox import domain_profile as registry + + display_profile, view_extension = _display_profile_or_skip() + register_host(_engineering_host("DISPLAY")) + declared = _flatten(_served_display(display_profile, view_extension)) + registry.unregister() + register_host(_engineering_host()) + absent = _flatten(_served_display(display_profile, view_extension)) + assert absent["host_facet"] == "absent" + assert declared.keys() == absent.keys() + changed = {path: (absent[path], declared[path]) + for path in declared if declared[path] != absent[path]} + assert changed == { + "host_facet": ("absent", "declared"), + "stages.completion.one": ("completed item", IMPLEMENTED_ITEM), + "stages.completion.many": ("completed items", IMPLEMENTED_ITEMS), + "stages.completion.short": ("completed", IMPLEMENTED), + "stages.completion.label": ("completed", IMPLEMENTED), + "values.document_stage.captured": ("source", "brainstorm"), + "values.document_stage.organized": ("grouping", "staged"), + "values.register_state.captured": ("unselected", "latent"), + "values.register_state.proposed": ("selected", "picked"), + "values.register_state.retired": ("declined", "rejected"), + "values.register_state.superseded": ("replaced", "superseded"), + - suite: tests/test_session_snapshot.py + test: test_the_session_source_read_is_the_existing_confinement_mechanism + landing: opensoft/openXdox-code#35 + edit: respelling + respelled: >- + The route's call into the confinement, as openDox-code#59 (T055) moved it: + `self.source.registry.resolve_source(` to `resolve_source_path(Path(root), + rest)`, the route's one entry point, which applies the registered + registry's `resolve_within` to the resolved entry's own root + (r4136863569). The docstring names the same move. + ruled: >- + R1Q7 (a), opensoft/openxFactory#656 comment 5817152735, recorded in + #1144's F5.2 and 12.5 falsifiers by T007's batch C. The holder's ruling + on T059 (2026-09-30) accepted this entry as a respelling, citing T055's + r4136863569, with the expected call respelled and the second assertion + left unchanged. + review: >- + Reviewed in the landing pull request on batch C's basis. Its diff for + this suite is exactly `old` to `new`, inside the one test named. The + first assertion still pins the route's own source to the one call that + confines the path, so a re-implementation of containment beside it + still fails; the second assertion, that the registry's `resolve_source` + reaches `resolve_within` exactly once, is unchanged. No assertion is + weakened. + before_blob: b5e1be021b0a23c891ffa0694682dca331cb982c + after_blob: f78c4452aebb9d2601f9043ceb8d9c552040e9b6 + old: | + def test_the_session_source_read_is_the_existing_confinement_mechanism(): + """T035: the confinement is `registry.resolve_source` + `resolve_within`, not + a new check bolted onto the session path. Pinned on the route's own source so + a later "simplification" cannot re-implement containment beside it.""" + import inspect + + source = inspect.getsource(serve_mod.DashboardHandler._serve_source) + assert "self.source.registry.resolve_source(" in source + new: | + def test_the_session_source_read_is_the_existing_confinement_mechanism(): + """T035: the confinement is the registry's per-entry `resolve_within`, reached + through the route's one entry point `resolve_source_path` over the resolved + entry's own root (openDox-code#59, r4136863569), not a new check bolted onto + the session path. Pinned on the route's own source so a later + "simplification" cannot re-implement containment beside it.""" + import inspect + + source = inspect.getsource(serve_mod.DashboardHandler._serve_source) + assert "resolve_source_path(Path(root), rest)" in source From 6578e47117468ec6ddd2f42e7cce05a9413f2d03 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:01:56 +0000 Subject: [PATCH 10/44] The arc check takes its lists as values, not paths, and answers SonarCloud's analysis of #35 (plan 034 T059) SonarCloud's quality gate failed #35 on Security Rating C, from one finding: pythonsecurity:S8707, path injection through a CLI argument, at protected_suites._lines, which opened whatever file argv named. The check now opens no file a caller names. --landings and --suites each carry the list itself, one item per line, and each item is held to its shape before any of it reaches git: a landing is a full commit id, a suite is tests/test_.py in ASCII. An item of neither shape is refused with exit 2. The falsifier's call becomes --landings="$(cat "$W/x-arc.txt")" --suites="$(cat "$W/gen-suites.txt")". The one file read is still the allow-list, at its fixed path. The same analysis flagged code smells, which are taken too: - load_allow_list and check are split into named rule checks, which brings each under the cognitive-complexity limit; - the character classes use \w and \d under re.ASCII; - composite assertions are split in the three new test files; - each pytest.raises block holds one call. tests/test_protected_suite_check.py gains the input-shape cases: a revision, an option, a path outside tests/, and a non-ASCII name. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/protected_suites.py | 173 ++++++++++++++------- tests/test_governed_registry_and_writer.py | 17 +- tests/test_projection_contributions.py | 13 +- tests/test_protected_suite_check.py | 51 ++++-- 4 files changed, 173 insertions(+), 81 deletions(-) diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py index f02e188..3671f0e 100644 --- a/scripts/protected_suites.py +++ b/scripts/protected_suites.py @@ -19,13 +19,19 @@ the falsifier's own block, as #1144 writes them. The last step, the inline Python that intersected them, becomes this call, from the checkout's root: - python3 scripts/protected_suites.py "$W/x-arc.txt" "$W/gen-suites.txt" # F5.2 - python3 scripts/protected_suites.py "$W/x-arc.txt" "$W/governed.txt" # 12.5 - -where `x-arc.txt` holds the landings, one commit per line, and the second file -the protected suites, one path per line. It exits 0 when no landing touched a -protected suite outside an entry that holds, 1 when one did (naming each -landing and path), and 2 when the allow-list itself breaks its rules. + python3 scripts/protected_suites.py --landings="$(cat "$W/x-arc.txt")" \ + --suites="$(cat "$W/gen-suites.txt")" # F5.2 + python3 scripts/protected_suites.py --landings="$(cat "$W/x-arc.txt")" \ + --suites="$(cat "$W/governed.txt")" # 12.5 + +Each option carries its LIST, one item per line, and never a path to one: the +landings, each a full commit id as `git log --format=%H` prints it, and the +protected suites, each `tests/test_.py`. (The `=` keeps a value that +begins with a dash a value.) So the check opens no file a caller names, and it +refuses an item of neither shape (exit 2) rather than handing it to git. The one file it reads is the allow-list, at its fixed path +under the checkout it runs in. It exits 0 when no landing touched a protected +suite outside an entry that holds, 1 when one did (naming each landing and +path), and 2 when its input or the allow-list itself breaks its rules. WHEN AN ENTRY HOLDS (the file's own header states the rule, and T060 wrote it). For a landing L that touches a protected `suite`, an entry for that suite holds @@ -59,6 +65,7 @@ from __future__ import annotations +import argparse import ast import re import subprocess @@ -79,10 +86,13 @@ EDIT_KEYS = {"respelling": frozenset({"respelled"}), "admitted": frozenset({"reason"})} +#: A full object id: a blob in an entry, or a landing's commit in the input. _BLOB = re.compile(r"[0-9a-f]{40}") -_SUITE = re.compile(r"tests/test_[A-Za-z0-9_]+\.py") -_LANDING = re.compile(r"opensoft/openXdox-code#[1-9][0-9]*") -_TEST = re.compile(r"test_[A-Za-z0-9_]+") +_COMMIT = _BLOB +#: ASCII only: a suite, a test or a landing is never spelled outside it. +_SUITE = re.compile(r"tests/test_\w+\.py", re.ASCII) +_LANDING = re.compile(r"opensoft/openXdox-code#[1-9]\d*", re.ASCII) +_TEST = re.compile(r"test_\w+", re.ASCII) class AllowListInvalid(ValueError): @@ -121,8 +131,8 @@ def _whole_lines(value: Any, where: str) -> str: return text -def load_allow_list(path: Path) -> list[dict]: - """The allow-list's entries, in landing order, once every rule holds.""" +def _document(path: Path) -> dict: + """The allow-list as a mapping, read with no key given twice.""" try: raw = yaml.load(path.read_text(encoding="utf-8"), Loader=_UniqueKeyLoader) except OSError as exc: @@ -139,38 +149,58 @@ def load_allow_list(path: Path) -> list[dict]: raise AllowListInvalid(f"schema_version is {version!r}, not {SCHEMA_VERSION}") if raw["kind"] != KIND: raise AllowListInvalid(f"kind is {raw['kind']!r}, not {KIND!r}") - entries = raw["entries"] - if not isinstance(entries, list): + if not isinstance(raw["entries"], list): raise AllowListInvalid("entries is not a list") + return raw + + +def _check_keys(entry: Any, where: str) -> None: + """An entry is a mapping carrying exactly its kind's keys, each a text.""" + if not isinstance(entry, dict): + raise AllowListInvalid(f"{where} is not a mapping") + edit = entry.get("edit") + if edit not in EDIT_KEYS: + raise AllowListInvalid(f"{where}: edit is {edit!r}, not one of {sorted(EDIT_KEYS)}") + wanted = COMMON_KEYS | EDIT_KEYS[edit] + if set(entry) != wanted: + raise AllowListInvalid( + f"{where}: carries {sorted(map(str, entry))}, and a {edit} entry " + f"carries exactly {sorted(wanted)}") + for key in wanted - {"old", "new"}: + _text(entry[key], f"{where}: {key}") + + +def _check_spellings(entry: dict, where: str) -> None: + """The suite, test, landing and blobs are each spelled as the rules say.""" + if not _SUITE.fullmatch(entry["suite"]): + raise AllowListInvalid(f"{where}: suite {entry['suite']!r} is not tests/test_.py") + if not _TEST.fullmatch(entry["test"]): + raise AllowListInvalid(f"{where}: test {entry['test']!r} is not a test's name") + if not _LANDING.fullmatch(entry["landing"]): + raise AllowListInvalid( + f"{where}: landing {entry['landing']!r} is not opensoft/openXdox-code#") + for key in ("before_blob", "after_blob"): + if not _BLOB.fullmatch(entry[key]): + raise AllowListInvalid(f"{where}: {key} is not a full object id") + + +def _check_texts(entry: dict, where: str) -> None: + """`old` and `new` are whole lines, and differ.""" + old = _whole_lines(entry["old"], f"{where}: old") + new = _whole_lines(entry["new"], f"{where}: new") + if old == new: + raise AllowListInvalid(f"{where}: old and new are the same text") + + +def load_allow_list(path: Path) -> list[dict]: + """The allow-list's entries, in landing order, once every rule holds.""" + entries = _document(path)["entries"] last_after: dict[str, str] = {} for n, entry in enumerate(entries, 1): where = f"entry {n}" - if not isinstance(entry, dict): - raise AllowListInvalid(f"{where} is not a mapping") - edit = entry.get("edit") - if edit not in EDIT_KEYS: - raise AllowListInvalid(f"{where}: edit is {edit!r}, not one of {sorted(EDIT_KEYS)}") - wanted = COMMON_KEYS | EDIT_KEYS[edit] - if set(entry) != wanted: - raise AllowListInvalid( - f"{where}: carries {sorted(map(str, entry))}, and a {edit} entry " - f"carries exactly {sorted(wanted)}") - for key in wanted - {"old", "new"}: - _text(entry[key], f"{where}: {key}") - if not _SUITE.fullmatch(entry["suite"]): - raise AllowListInvalid(f"{where}: suite {entry['suite']!r} is not tests/test_.py") - if not _TEST.fullmatch(entry["test"]): - raise AllowListInvalid(f"{where}: test {entry['test']!r} is not a test's name") - if not _LANDING.fullmatch(entry["landing"]): - raise AllowListInvalid( - f"{where}: landing {entry['landing']!r} is not opensoft/openXdox-code#") - for key in ("before_blob", "after_blob"): - if not _BLOB.fullmatch(entry[key]): - raise AllowListInvalid(f"{where}: {key} is not a full object id") - old = _whole_lines(entry["old"], f"{where}: old") - new = _whole_lines(entry["new"], f"{where}: new") - if old == new: - raise AllowListInvalid(f"{where}: old and new are the same text") + _check_keys(entry, where) + _check_spellings(entry, where) + _check_texts(entry, where) suite = entry["suite"] if suite in last_after and entry["before_blob"] != last_after[suite]: raise AllowListInvalid( @@ -251,6 +281,21 @@ class Finding: why: str +def _admitting(repo: Path, landing: str, path: str, + entries: list[dict]) -> tuple[int | None, list[str]]: + """The entry (1-based) for `path` that holds at `landing`, or None and + every entry's reason for not holding.""" + reasons = [] + for n, entry in enumerate(entries, 1): + if entry["suite"] != path: + continue + why = entry_holds(repo, landing, entry) + if why is None: + return n, [] + reasons.append(f"entry {n}: {why}") + return None, reasons + + def check(repo: Path, landings: list[str], protected: set[str], entries: list[dict]) -> list[Finding]: """One finding per protected path each landing touched: admitted by the @@ -261,41 +306,51 @@ def check(repo: Path, landings: list[str], protected: set[str], _git(repo, "diff", "--name-only", f"{landing}^1", landing).splitlines() if line.strip()} for path in sorted(touched & protected): - reasons = [] - admitted = None - for n, entry in enumerate(entries, 1): - if entry["suite"] != path: - continue - why = entry_holds(repo, landing, entry) - if why is None: - admitted = n - break - reasons.append(f"entry {n}: {why}") + admitted, reasons = _admitting(repo, landing, path, entries) findings.append(Finding( landing, path, admitted, "" if admitted else ("; ".join(reasons) or "no entry names this suite"))) return findings -def _lines(path: str) -> list[str]: - return [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines() - if line.strip()] +class InputInvalid(ValueError): + """An item of a list the caller passed is neither a landing nor a suite.""" + + +def _items(text: str, shape: re.Pattern[str], what: str) -> list[str]: + """The non-empty lines of `text`, each of `shape`, in order.""" + items = [line.strip() for line in text.splitlines() if line.strip()] + for item in items: + if not shape.fullmatch(item): + raise InputInvalid(f"{item!r} is not {what}") + return items def main(argv: list[str] | None = None) -> int: - argv = sys.argv[1:] if argv is None else argv - if len(argv) != 2: - print("usage: protected_suites.py ", - file=sys.stderr) - return 2 + parser = argparse.ArgumentParser( + description="The unedited-by-the-arc check, with the reviewed allow-list subtracted.") + parser.add_argument("--landings", required=True, + help="the arc's landings, one full commit id per line") + parser.add_argument("--suites", required=True, + help="the protected suites, one tests/test_.py per line") + try: + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + except SystemExit as exc: + return 2 if exc.code else 0 repo = Path.cwd() + try: + landings = _items(args.landings, _COMMIT, "a full commit id") + suites = set(_items(args.suites, _SUITE, "a tests/test_.py path")) + except InputInvalid as exc: + print(f"FAIL: {exc}, so nothing is checked", file=sys.stderr) + return 2 try: entries = load_allow_list(repo / ALLOW_LIST) except AllowListInvalid as exc: print(f"FAIL: {ALLOW_LIST} breaks its own rules, so nothing is subtracted: {exc}", file=sys.stderr) return 2 - findings = check(repo, _lines(argv[0]), set(_lines(argv[1])), entries) + findings = check(repo, landings, suites, entries) refused = [] for f in findings: if f.admitted_by is not None: diff --git a/tests/test_governed_registry_and_writer.py b/tests/test_governed_registry_and_writer.py index b358276..9ec9a4a 100644 --- a/tests/test_governed_registry_and_writer.py +++ b/tests/test_governed_registry_and_writer.py @@ -99,7 +99,8 @@ def test_after_the_active_entry_is_dropped_the_next_one_becomes_active() -> None registry.register(_entry("alpha")) registry.drop("alpha") registry.register(_entry("beta")) - assert registry.active is not None and registry.active.repository == "beta" + assert registry.active is not None + assert registry.active.repository == "beta" def test_dropping_another_entry_keeps_the_active_one() -> None: @@ -122,9 +123,9 @@ def _boundary(root: Path, name: str = "snapshot.json") -> OutputBoundary: ids=["nan", "infinity", "minus-infinity"]) def test_a_value_json_cannot_carry_is_refused_and_nothing_is_written(tmp_path, value) -> None: target = tmp_path / "snapshot.json" + boundary = _boundary(tmp_path) with pytest.raises(snapshot_mod.SnapshotNotWritable): - snapshot_mod.write_snapshot({"kind": "k", "score": value}, target, - _boundary(tmp_path)) + snapshot_mod.write_snapshot({"kind": "k", "score": value}, target, boundary) assert list(tmp_path.iterdir()) == [] @@ -156,7 +157,8 @@ def replace(source, destination): written = snapshot_mod.write_snapshot({"kind": "k"}, target, _boundary(tmp_path)) assert written == target.resolve() assert json.loads(target.read_text(encoding="utf-8")) == {"kind": "k"} - assert len(moves) == 1 and moves[0].startswith(".snapshot.json.") + assert len(moves) == 1 + assert moves[0].startswith(".snapshot.json.") assert sorted(p.name for p in tmp_path.iterdir()) == ["snapshot.json"] @@ -168,8 +170,9 @@ def refuse(source, destination): raise OSError("the move failed") monkeypatch.setattr(os, "replace", refuse) + boundary = _boundary(tmp_path) with pytest.raises(OSError, match="the move failed"): - snapshot_mod.write_snapshot({"kind": "k"}, target, _boundary(tmp_path)) + snapshot_mod.write_snapshot({"kind": "k"}, target, boundary) assert target.read_text(encoding="utf-8") == "old\n" assert sorted(p.name for p in tmp_path.iterdir()) == ["snapshot.json"] @@ -185,9 +188,9 @@ def test_a_rewrite_keeps_the_snapshots_permissions(tmp_path) -> None: def test_the_boundary_still_decides_the_destination(tmp_path) -> None: from opendox.boundary import BoundaryViolation + boundary = _boundary(tmp_path) with pytest.raises(BoundaryViolation): - snapshot_mod.write_snapshot({"kind": "k"}, tmp_path / "elsewhere.json", - _boundary(tmp_path)) + snapshot_mod.write_snapshot({"kind": "k"}, tmp_path / "elsewhere.json", boundary) assert list(tmp_path.iterdir()) == [] diff --git a/tests/test_projection_contributions.py b/tests/test_projection_contributions.py index 3a80355..afeee68 100644 --- a/tests/test_projection_contributions.py +++ b/tests/test_projection_contributions.py @@ -253,8 +253,9 @@ def write_snapshot(snapshot, path, boundary): # pragma: no cover domain_profile.unregister() projection_seams.writer.register(OtherWriter()) try: + profile = domain_profile.load(PROFILE_FIXTURE) with pytest.raises(projection_seams.SeamAlreadyRegistered): - domain_profile.register(domain_profile.load(PROFILE_FIXTURE)) + domain_profile.register(profile) assert not domain_profile.is_registered() finally: projection_seams.writer.unregister() @@ -340,9 +341,12 @@ def SCANNED_ROOTS(self): # noqa: N802 - the governed module's own name monkeypatch.setattr(pc, "_governed", lambda name: CorpusRoot()) roots = pc.CORPUS_ROOT.SCANNED_ROOTS - assert "read when used" in repr(roots) and reads == [] + assert "read when used" in repr(roots) + assert reads == [] assert tuple(roots) == ("docs", "openspec") - assert len(roots) == 2 and roots[1] == "openspec" and "docs" in roots + assert len(roots) == 2 + assert roots[1] == "openspec" + assert "docs" in roots def test_the_change_rows_are_the_governed_enumeration_with_each_origin(monkeypatch) -> None: @@ -393,6 +397,7 @@ def test_no_validator_from_any_root_is_unavailable_naming_the_script(monkeypatch result = pc.VALIDATOR.validate(tmp_path / "s.json", search_from=(tmp_path / "a", tmp_path / "b")) assert result.outcome == projection_seams.VALIDATOR_UNAVAILABLE - assert not result.available and result.validator is None + assert not result.available + assert result.validator is None assert str(snapshot_mod.VALIDATOR_RELPATH) in result.unavailable_reason assert pc.VALIDATOR.dependency_remedy == snapshot_mod.DEPENDENCY_REMEDY diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index abafde4..120c899 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -114,7 +114,8 @@ def _check(repo: Repo, entries: list[dict]) -> list[ps.Finding]: def test_an_unentered_edit_to_a_protected_suite_is_refused(repo) -> None: repo.commit({SUITE: AFTER}, f"edit\n\n{ARC}") [finding] = _check(repo, []) - assert finding.admitted_by is None and finding.path == SUITE + assert finding.admitted_by is None + assert finding.path == SUITE assert "no entry names this suite" in finding.why @@ -181,24 +182,51 @@ def test_two_landings_admitted_by_two_chained_entries(repo) -> None: assert sorted(f.admitted_by for f in findings) == [1, 2] -def test_the_command_exits_one_on_a_refusal_and_zero_when_every_edit_holds(repo, tmp_path, +def _command(repo: Repo, *, landings: str | None = None, suites: str = SUITE + "\n") -> list[str]: + """The falsifier's call: each option carries its list, one item per line.""" + if landings is None: + landings = repo.git("log", "--first-parent", "--format=%H", f"--grep=^{ARC}$", + "HEAD") + "\n" + return [f"--landings={landings}", f"--suites={suites}"] + + +def test_the_command_exits_one_on_a_refusal_and_zero_when_every_edit_holds(repo, monkeypatch) -> None: repo.commit({SUITE: AFTER}, f"edit\n\n{ARC}") - landings = tmp_path / "x-arc.txt" - landings.write_text(repo.git("log", "--first-parent", "--format=%H", - f"--grep=^{ARC}$", "HEAD") + "\n", encoding="utf-8") - suites = tmp_path / "suites.txt" - suites.write_text(SUITE + "\n", encoding="utf-8") allow = repo.root / ps.ALLOW_LIST monkeypatch.chdir(repo.root) allow.write_text(yaml.safe_dump({"schema_version": 1, "kind": ps.KIND, "entries": []}), encoding="utf-8") - assert ps.main([str(landings), str(suites)]) == 1 + assert ps.main(_command(repo)) == 1 allow.write_text(yaml.safe_dump({"schema_version": 1, "kind": ps.KIND, "entries": [_entry(repo)]}), encoding="utf-8") - assert ps.main([str(landings), str(suites)]) == 0 + assert ps.main(_command(repo)) == 0 allow.write_text("schema_version: 1\nschema_version: 1\n", encoding="utf-8") - assert ps.main([str(landings), str(suites)]) == 2 + assert ps.main(_command(repo)) == 2 + + +@pytest.mark.parametrize("landings, suites", [ + ("HEAD\n", SUITE + "\n"), # a revision, not a commit id + ("--output=/tmp/x\n", SUITE + "\n"), # an option git would take + (None, "../outside.py\n"), # not a tests/test_.py + (None, "tests/test_é.py\n"), # outside ASCII +], ids=["revision", "option", "outside-path", "non-ascii"]) +def test_an_item_of_neither_shape_is_refused_before_anything_is_checked( + repo, monkeypatch, capsys, landings, suites) -> None: + """The lists are values, never paths, and each item is held to its shape + before any of it reaches git.""" + repo.commit({SUITE: AFTER}, f"edit\n\n{ARC}") + monkeypatch.chdir(repo.root) + (repo.root / ps.ALLOW_LIST).write_text(yaml.safe_dump( + {"schema_version": 1, "kind": ps.KIND, "entries": [_entry(repo)]}), encoding="utf-8") + command = _command(repo, landings=landings, suites=suites) + assert ps.main(command) == 2 + assert "so nothing is checked" in capsys.readouterr().err + + +def test_a_missing_option_is_a_usage_refusal(repo, monkeypatch) -> None: + monkeypatch.chdir(repo.root) + assert ps.main([f"--suites={SUITE}"]) == 2 # -------------------------------------------------------------------------- @@ -240,8 +268,9 @@ def _valid_entry(**changes) -> dict: ("schema_version: 1\nschema_version: 1\n", "given twice"), ], ids=lambda value: value if isinstance(value, str) else None) def test_a_list_that_breaks_its_own_rules_subtracts_nothing(tmp_path, broken, why) -> None: + written = _write(tmp_path, broken) with pytest.raises(ps.AllowListInvalid, match=why): - ps.load_allow_list(_write(tmp_path, broken)) + ps.load_allow_list(written) def test_a_respelling_names_what_it_respelled(tmp_path) -> None: From 7815c2da834d1dab2ce90e4be0fef64aea629b14 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:13:52 +0000 Subject: [PATCH 11/44] Hold each half of the arc check's blob and named-test rules by a case of its own (plan 034 T059) Two mutants of scripts/protected_suites.py survived the suite: skipping the before-blob check, and skipping the check that the replaced text lies inside the named test. Each was masked by its twin (the after-blob check, and the replacement's check). New cases hold each side alone: an entry naming another blob on either side; an edit whose replaced text is module code and whose replacement becomes the test's last line; and the reverse, an assertion moved out of the test. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_protected_suite_check.py | 36 ++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index 120c899..8d9b96f 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -10,7 +10,8 @@ the test the entry names, is admitted; * an entry is refused as soon as its landing's diff differs from its recorded text in any way: another edit beside it, a text that occurs twice, an edit in - another test, or blobs that are not the entry's; + another test or reaching out of the named one, or blobs that are not the + entry's; * a commit without the `Arc:` line is not a landing, whatever it touches; * entries for one suite chain, and a file that breaks its own rules refuses the whole check rather than subtracting less. @@ -161,6 +162,39 @@ def test_an_edit_outside_the_named_test_is_refused(repo) -> None: assert "not inside test_first" in finding.why +@pytest.mark.parametrize("side", ["before_blob", "after_blob"]) +def test_an_entry_whose_recorded_blob_is_not_the_landings_is_refused(repo, side) -> None: + """The text would apply, but the entry names another blob on one side, so + it records some other edit than this landing's.""" + repo.commit({SUITE: AFTER}, f"edit\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, **{side: repo.blob("some other text\n")})]) + assert finding.admitted_by is None + assert f"not the entry's {repo.blob('some other text' + chr(10))}" in finding.why + + +def test_an_edit_that_turns_module_code_into_test_code_is_refused(repo) -> None: + """What the entry replaces lies outside the named test, although what it + leaves lies inside it: a module-level line becomes the test's last line.""" + before = BEFORE + "X = 1\n" + after = BEFORE + " assert 1\n" + repo.commit({SUITE: before}, "a module-level line after the test") + repo.commit({SUITE: after}, f"edit\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, before=before, after=after, + old="X = 1\n", new=" assert 1\n")]) + assert finding.admitted_by is None + assert "old text is not inside test_second" in finding.why + + +def test_an_edit_that_moves_test_code_out_of_the_test_is_refused(repo) -> None: + """The reverse: what the entry replaces lies inside the named test, and + what it leaves lies outside it, so the test loses an assertion.""" + after = BEFORE.replace(OLD, 'X = {"a": 1}\n') + repo.commit({SUITE: after}, f"edit\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, after=after, new='X = {"a": 1}\n')]) + assert finding.admitted_by is None + assert "new text is not inside test_second" in finding.why + + def test_a_commit_without_the_trailer_is_not_a_landing(repo) -> None: repo.commit({SUITE: AFTER}, "an edit that is no arc landing") assert _check(repo, []) == [] From c34c0de6845182472e8646ad4614168d20842f77 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:26:10 +0000 Subject: [PATCH 12/44] Take Copilot's round-1 findings on #35: one-window registry reads, renames, the schema integer, unsigned fixtures (plan 034 T059) - r4139816732: SnapshotRegistry.index_document read the entries, the aggregates and the active key in three windows, so a writer between them could leave the index naming an active key its entries did not carry; compose_aggregate read _aggregates unlocked and looked each member up in a window of its own. Each is now one hold of the lock, with the document composed and the snapshots read after it. Two cases hold it, each running a writer on another thread between the reads; both are red before this commit. - r4139816490: the arc check's git diff detected renames, so a protected suite renamed to an unprotected path showed only the destination and was never checked. It now diffs with --no-renames, and a case holds it. - r4139816690: schema_version 1.0 passed, since 1.0 == 1. The check is now for the integer itself, and the rules' table gains the case. - r4139816759: the scratch-repository fixture inherited the developer's git configuration, so commit.gpgsign=true failed every case. It passes -c commit.gpgsign=false, as tests/test_trust_gaps.py does. Under a signing configuration the file read 16 passed, 19 errors before, and 37 passed after. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/protected_suites.py | 8 ++- src/openxdox/snapshot_registry.py | 37 +++++++++---- tests/test_governed_registry_and_writer.py | 63 +++++++++++++++++++++- tests/test_protected_suite_check.py | 18 ++++++- 4 files changed, 110 insertions(+), 16 deletions(-) diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py index 3671f0e..80843c9 100644 --- a/scripts/protected_suites.py +++ b/scripts/protected_suites.py @@ -145,7 +145,8 @@ def _document(path: Path) -> dict: raise AllowListInvalid( f"{path} carries {sorted(map(str, raw))}, not schema_version, kind and entries") version = raw["schema_version"] - if isinstance(version, bool) or version != SCHEMA_VERSION: + # The integer itself: not a bool, and not `1.0`, which equals 1 in Python. + if type(version) is not int or version != SCHEMA_VERSION: raise AllowListInvalid(f"schema_version is {version!r}, not {SCHEMA_VERSION}") if raw["kind"] != KIND: raise AllowListInvalid(f"kind is {raw['kind']!r}, not {KIND!r}") @@ -302,8 +303,11 @@ def check(repo: Path, landings: list[str], protected: set[str], entry (1-based) that holds there, or refused with every entry's reason.""" findings: list[Finding] = [] for landing in landings: + # --no-renames: a protected suite renamed away is a deletion at its + # own path, never only the destination's addition. touched = {line.strip() for line in - _git(repo, "diff", "--name-only", f"{landing}^1", landing).splitlines() + _git(repo, "diff", "--no-renames", "--name-only", + f"{landing}^1", landing).splitlines() if line.strip()} for path in sorted(touched & protected): admitted, reasons = _admitting(repo, landing, path, entries) diff --git a/src/openxdox/snapshot_registry.py b/src/openxdox/snapshot_registry.py index 6897939..648580f 100644 --- a/src/openxdox/snapshot_registry.py +++ b/src/openxdox/snapshot_registry.py @@ -623,12 +623,22 @@ def index_document(self, *, published: bool = False) -> dict: second place this document carries `(repository, ref)` pairs and nothing checked it, so a published index could name the branch of unmerged work. The check is here, over BOTH collections, so the premise is true of the - document rather than of one field.""" - entries = self.entries() + document rather than of one field. + + READ IN ONE WINDOW (plan 034 T059, Copilot on openXdox-code#35, + r4139816732). The entries, the aggregates and the active key are taken + under one hold of the lock, so the document never names an active key + its own entries do not carry, as it could when a writer ran between + three separate reads. The document is composed after the lock is let + go, from that one reading.""" + with self._lock: + entries = self.entries() + aggregates = self.aggregates() + active = self._active if published: for entry in entries: assert_publishable(entry.repository, entry.ref) - for aggregate in self.aggregates(): + for aggregate in aggregates: for repository, ref in aggregate.members: assert_publishable(repository, ref) doc: dict[str, Any] = { @@ -639,18 +649,17 @@ def index_document(self, *, published: bool = False) -> dict: newest = [e.generated_at for e in entries if e.generated_at] if newest: doc["generated_at"] = max(newest) - aggregates = self.aggregates() if aggregates: doc["aggregates"] = [{ "id": a.id, **({"display_name": a.display_name} if a.display_name else {}), "members": [{"repository": r, "ref": f} for r, f in a.members], } for a in aggregates] - if not published and self._active is not None: + if not published and active is not None: # Serving-side only: which entry the server considers ACTIVE. Additive, # ignored by any consumer that does not know it (and absent from a # published index, which has no notion of "active"). - doc["active"] = {"repository": self._active[0], "ref": self._active[1]} + doc["active"] = {"repository": active[0], "ref": active[1]} return doc # ---- aggregate composition (from the index, never a repository scan) ---- @@ -663,11 +672,17 @@ def compose_aggregate(self, aggregate_id: str, `repository` so a renderer can badge it. Members with no available snapshot are skipped (degrade, never refuse). `aggregate` lets a caller compose one it resolved itself (a register-DERIVED project aggregate, - add-project-merged-projection D11) without registering it.""" - aggregate = aggregate or self._aggregates.get(aggregate_id) - if aggregate is None: - return None - members = [self.get(repo, ref) for repo, ref in aggregate.members] + add-project-merged-projection D11) without registering it. + + The aggregate and its members are looked up in one hold of the lock + (plan 034 T059, r4139816732), so a writer cannot drop or replace a + member between two lookups. The members' snapshots are read after it + is let go.""" + with self._lock: + aggregate = aggregate or self._aggregates.get(aggregate_id) + if aggregate is None: + return None + members = [self.get(repo, ref) for repo, ref in aggregate.members] snapshots = [(m, m.read_json()) for m in members if m is not None] loaded = [(m, doc) for m, doc in snapshots if isinstance(doc, dict)] return compose_snapshots(aggregate, loaded) diff --git a/tests/test_governed_registry_and_writer.py b/tests/test_governed_registry_and_writer.py index 9ec9a4a..71f90dd 100644 --- a/tests/test_governed_registry_and_writer.py +++ b/tests/test_governed_registry_and_writer.py @@ -13,7 +13,8 @@ truncated snapshot (r4126138808), and `canonical_json` wrote NaN and Infinity, which no JSON reader parses (r4125900060). 4. `SnapshotRegistry` read without its lock, so a reader could answer from the - middle of a block `atomically()` holds (r4136863481). + middle of a block `atomically()` holds (r4136863481). The index and an + aggregate's composition are also one reading each (r4139816732, on #35). Each case below is red against the code before T059. @@ -216,6 +217,66 @@ def test_the_bytes_are_the_canonical_render(tmp_path) -> None: } +def _hold_between(registry, method: str, writer) -> threading.Thread: + """Run `writer` on another thread between `method`'s return and whatever + its caller reads next, as a writer interleaving there would.""" + between = threading.Event() + real = getattr(registry, method) + + def paused(*args, **kwargs): + out = real(*args, **kwargs) + between.set() + time.sleep(0.2) + return out + + setattr(registry, method, paused) + + def run() -> None: + between.wait(5) + writer() + + thread = threading.Thread(target=run) + thread.start() + return thread + + +def test_the_index_is_one_reading_of_the_registry(monkeypatch) -> None: + """A writer that runs between the index's reads cannot give it an active + key its own entries do not carry (Copilot, r4139816732).""" + monkeypatch.setattr(reg.SnapshotEntry, "index_entry", + lambda self: {"repository": self.repository, "ref": self.ref}) + registry = reg.SnapshotRegistry() + registry.register(_entry("alpha")) + + def writer() -> None: + registry.drop("alpha") + registry.register(_entry("beta"), active=True) + + thread = _hold_between(registry, "entries", writer) + document = registry.index_document() + thread.join(timeout=5) + carried = {(e["repository"], e["ref"]) for e in document["entries"]} + assert (document["active"]["repository"], document["active"]["ref"]) in carried + + +def test_an_aggregate_is_composed_from_one_reading_of_its_members(monkeypatch) -> None: + """A writer that drops a member between two member lookups cannot leave + the aggregate composed from half of them.""" + composed = [] + monkeypatch.setattr(reg.SnapshotEntry, "read_json", lambda self: {}) + monkeypatch.setattr(reg, "compose_snapshots", + lambda aggregate, loaded: composed.extend(m.repository for m, _ in loaded)) + registry = reg.SnapshotRegistry() + registry.register(_entry("alpha")) + registry.register(_entry("beta")) + registry.register_aggregate(reg.Aggregate(id="agg", members=[("alpha", "main"), + ("beta", "main")])) + thread = _hold_between(registry, "get", lambda: registry.drop("beta")) + registry.compose_aggregate("agg") + thread.join(timeout=5) + assert composed == ["alpha", "beta"] + + @pytest.mark.parametrize("read", sorted(READS)) def test_a_read_waits_for_a_held_read_modify_write(read) -> None: """A block holds the registry, registers a session and promotes it, and diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index 8d9b96f..3fe4a8a 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -65,8 +65,11 @@ def __init__(self, root: Path) -> None: self.git("init", "-q", "-b", "main") def git(self, *args: str) -> str: - return subprocess.run(("git", "-C", str(self.root), *args), check=True, - capture_output=True, text=True, env=self.env).stdout.strip() + # Signing off, whatever the developer's own configuration says, as this + # repository's other git fixtures do (tests/test_trust_gaps.py). + return subprocess.run(("git", "-C", str(self.root), "-c", "commit.gpgsign=false", + *args), check=True, capture_output=True, text=True, + env=self.env).stdout.strip() def commit(self, files: dict[str, str], message: str) -> str: for rel, text in files.items(): @@ -195,6 +198,16 @@ def test_an_edit_that_moves_test_code_out_of_the_test_is_refused(repo) -> None: assert "new text is not inside test_second" in finding.why +def test_a_protected_suite_renamed_away_is_refused(repo) -> None: + """A rename is the suite's deletion at its own path, whatever the + destination: rename detection must not hide it.""" + repo.git("mv", SUITE, "tests/renamed_away.py") + repo.git("commit", "-q", "-m", f"rename\n\n{ARC}") + [finding] = _check(repo, []) + assert finding.admitted_by is None + assert finding.path == SUITE + + def test_a_commit_without_the_trailer_is_not_a_landing(repo) -> None: repo.commit({SUITE: AFTER}, "an edit that is no arc landing") assert _check(repo, []) == [] @@ -286,6 +299,7 @@ def _valid_entry(**changes) -> dict: @pytest.mark.parametrize("broken, why", [ ({"schema_version": 2, "kind": ps.KIND, "entries": []}, "schema_version"), ({"schema_version": True, "kind": ps.KIND, "entries": []}, "schema_version"), + ({"schema_version": 1.0, "kind": ps.KIND, "entries": []}, "schema_version"), ({"schema_version": 1, "kind": "other", "entries": []}, "kind"), ({"schema_version": 1, "kind": ps.KIND, "entries": [], "extra": 1}, "carries"), ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(edit="rewrite")]}, "edit is"), From 1885aee1fa981a06c7354cb5bb4997fcf3cabca4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:27:54 +0000 Subject: [PATCH 13/44] Raise the triple's floors to CI's reading, 984 and 980, with the reason (plan 034 T059) Run 36655105352 measured c34c0dea, the head before this change, at openDox-code 814516b7: selected 984, passed 980, skipped 4, failures 0, errors 0. The +99 on both floors is T060's 11 (openXdox-code#34 named the raise as the next writer's) and T059's 88: three new test files of 37, 26 and 20 cases, and the six seam-assembly cases that run here now that their file left the declaration, less the declaration check's case for that file. EXPECT_SKIPPED stays 4, the same four skips. The floors sit on the reading, margin zero, as #25 set them (Copilot, r4139816631). Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 14b998e..b2426ec 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -274,6 +274,26 @@ jobs: # in this pin carries the gap, and none now does: the four skips left # are named below, and none of them waits for `doc_health`. # + # RAISED BY plan 034 T059 (openXdox-code#35), the same way: run + # 36655105352 (2026-09-30) measured `c34c0dea`, the head before this + # change, at openDox-code `814516b7`: selected 984, passed 980, skipped + # 4, failures 0, errors 0. The +99 on both floors has two reasons: + # * +11 is T060's (openXdox-code#34), the `values` probes it added to + # `tests/test_gate_loop_probes.py`. #34 left the floors where they + # were, since the phase-2 draft rule kept it out of this file, and + # named the raise as the next writer's. That is this one. + # * +88 is T059's: it adds three test files, of 37 + # (`tests/test_protected_suite_check.py`), 26 + # (`tests/test_governed_registry_and_writer.py`) and 20 + # (`tests/test_projection_contributions.py`) cases, and + # `tests/test_seam_assembly_beside_gate_and_projection.py` leaves the + # declaration, so its six cases run here now and pass. The + # declaration's own check loses that file's case (-1). + # Nothing that was collected before stopped being collected, no outcome + # changed, and the four skips are the same four (the reading compared + # with `main` `c41063d6` test by test). The floors sit ON the reading, + # as #25 set them and T044 kept them, so the margin is zero again. + # # THE SKIPS ARE NAMED, which is why the exact pin is safe to take. All # FOUR are `tests/test_aggregation_register_instance.py`'s, which the # sixteen-file list never ran: each skips because the "aggregation @@ -303,8 +323,8 @@ jobs: # on a ruling, which lowers them with its reason (above). Set to # the measured values, which this suite has actually met on CI # rather than an aspiration. - MIN_SELECTED: "885" - MIN_PASSED: "881" + MIN_SELECTED: "984" + MIN_PASSED: "980" # EXACT - the load-bearing number. EXPECT_SKIPPED: "4" run: | From 11c02cf402b1e873e84bd6898b9aec627c8a3cad Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:02:13 +0000 Subject: [PATCH 14/44] Take Copilot's later findings on #35: an entry admits one landing, malformed keys and edits refuse, the teardown rule stated (plan 034 T059) Copilot's reviews at c34c0de6 and 1885aee1 listed three findings in their overviews: - Replay: an entry matched by suite, blobs and text could admit a second landing that repeated its edit after the suite came back to its before_blob. The check now takes the landings oldest first, whatever order they arrive in, and an entry that has admitted one is spent. Two cases hold it: X -> Y -> X -> Y with two entries refuses the third landing, and either input order gives the same answer. - Malformed input: edit: [] reached a dict membership test and raised TypeError, and a mapping key that is itself a sequence did the same, instead of exit 2 with nothing subtracted. Both are AllowListInvalid now, and the rules' table gains both cases. - Teardown: domain_profile.unregister() leaves the governed contributions registered. That is kept, and its docstring now says why: they are the process's, a host registers them without this module too (T064), and openDox's seams refuse a governed registration once a default has been read, so a teardown that took them back could not be undone. projection_contributions.unregister() is the teardown for them, and a case pins both halves. The four checker cases are red before this commit and green after. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/protected_suites.py | 37 +++++++++++++++++++++----- src/openxdox/domain_profile.py | 15 ++++++++++- tests/test_projection_contributions.py | 22 +++++++++++++++ tests/test_protected_suite_check.py | 33 +++++++++++++++++++++++ 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py index 80843c9..0f1f6fe 100644 --- a/scripts/protected_suites.py +++ b/scripts/protected_suites.py @@ -48,6 +48,13 @@ A landing that touches a protected suite is admitted for that suite only if one entry holds at it. Every other protected path it touches is refused. +AN ENTRY ADMITS ONE LANDING (Copilot on openXdox-code#35). The landings are +taken oldest first, and an entry that has admitted one is spent: a later +landing that repeats the same edit, after the suite came back to the entry's +`before_blob`, is refused unless an entry of its own holds. An entry names its +landing by pull request, not by commit (T019's rule), so the commit it admits +is found here, once. + THE ALLOW-LIST'S OWN RULES are checked before anything is subtracted, and a file that breaks one refuses the whole check (exit 2) rather than subtracting less: `schema_version` 1, `kind` `protected-suite-respellings`, no key given @@ -107,6 +114,10 @@ def _mapping(loader: _UniqueKeyLoader, node: yaml.MappingNode, deep: bool = Fals seen: set[Any] = set() for key_node, _value in node.value: key = loader.construct_object(key_node, deep=deep) + if not isinstance(key, (str, int, float, bool)) and key is not None: + raise AllowListInvalid( + f"a key is a {type(key).__name__} (line {key_node.start_mark.line + 1}), " + "and every key is a plain scalar") if key in seen: raise AllowListInvalid( f"the key {key!r} is given twice (line {key_node.start_mark.line + 1})") @@ -160,7 +171,7 @@ def _check_keys(entry: Any, where: str) -> None: if not isinstance(entry, dict): raise AllowListInvalid(f"{where} is not a mapping") edit = entry.get("edit") - if edit not in EDIT_KEYS: + if not isinstance(edit, str) or edit not in EDIT_KEYS: raise AllowListInvalid(f"{where}: edit is {edit!r}, not one of {sorted(EDIT_KEYS)}") wanted = COMMON_KEYS | EDIT_KEYS[edit] if set(entry) != wanted: @@ -282,14 +293,19 @@ class Finding: why: str -def _admitting(repo: Path, landing: str, path: str, - entries: list[dict]) -> tuple[int | None, list[str]]: +def _admitting(repo: Path, landing: str, path: str, entries: list[dict], + spent: dict[int, str]) -> tuple[int | None, list[str]]: """The entry (1-based) for `path` that holds at `landing`, or None and - every entry's reason for not holding.""" + every entry's reason for not holding. An entry in `spent` has admitted + another landing already and admits no second one.""" reasons = [] for n, entry in enumerate(entries, 1): if entry["suite"] != path: continue + if n in spent: + reasons.append(f"entry {n}: it admitted {spent[n][:12]} already, " + "and an entry admits one landing") + continue why = entry_holds(repo, landing, entry) if why is None: return n, [] @@ -300,9 +316,14 @@ def _admitting(repo: Path, landing: str, path: str, def check(repo: Path, landings: list[str], protected: set[str], entries: list[dict]) -> list[Finding]: """One finding per protected path each landing touched: admitted by the - entry (1-based) that holds there, or refused with every entry's reason.""" + entry (1-based) that holds there, or refused with every entry's reason. + The landings are taken oldest first, whatever order they come in, and + each entry admits one of them at most.""" findings: list[Finding] = [] - for landing in landings: + spent: dict[int, str] = {} + ancestors = {landing: int(_git(repo, "rev-list", "--count", landing).strip()) + for landing in landings} + for landing in sorted(landings, key=ancestors.__getitem__): # --no-renames: a protected suite renamed away is a deletion at its # own path, never only the destination's addition. touched = {line.strip() for line in @@ -310,7 +331,9 @@ def check(repo: Path, landings: list[str], protected: set[str], f"{landing}^1", landing).splitlines() if line.strip()} for path in sorted(touched & protected): - admitted, reasons = _admitting(repo, landing, path, entries) + admitted, reasons = _admitting(repo, landing, path, entries, spent) + if admitted is not None: + spent[admitted] = landing findings.append(Finding( landing, path, admitted, "" if admitted else ("; ".join(reasons) or "no entry names this suite"))) diff --git a/src/openxdox/domain_profile.py b/src/openxdox/domain_profile.py index 1f7c518..544b6fe 100644 --- a/src/openxdox/domain_profile.py +++ b/src/openxdox/domain_profile.py @@ -1136,7 +1136,20 @@ def register(profile: DomainProfile) -> DomainProfile: def unregister() -> None: - """Drop the registration. For test isolation and for a host tearing down.""" + """Drop the registration. For test isolation and for a host tearing down. + + THE GOVERNED CONTRIBUTIONS STAY (plan 034 T059). `register()` also + registers openXdox's governed projection at openDox's seams + (`projection_contributions.register()`), and this leaves it there. It is + the process's, not the profile's: a host registers it without this module + too (openxFactory does, plan 034 T064), and openDox's seams refuse a + governed registration once one of their defaults has been read. A + teardown that took it back would therefore make registering the same + profile again fail wherever anything was served from a default in + between, so a test that takes the profile away for one case could not put + it back. A host that tears the governed projection down as well calls + `projection_contributions.unregister()`, which empties only what it holds. + """ global _registered _registered = None diff --git a/tests/test_projection_contributions.py b/tests/test_projection_contributions.py index afeee68..363b06e 100644 --- a/tests/test_projection_contributions.py +++ b/tests/test_projection_contributions.py @@ -243,6 +243,28 @@ def test_registering_openxdoxs_profile_registers_the_contributions(isolated_seam domain_profile.register(held) +def test_unregistering_the_profile_leaves_the_contributions(isolated_seams) -> None: + """The profile's teardown drops the profile only. The contributions are the + process's, and a teardown that took them back could not be undone once a + default had been read (the docstring of `domain_profile.unregister`).""" + held = _registered_profile() + domain_profile.unregister() + try: + profile = domain_profile.load(PROFILE_FIXTURE) + domain_profile.register(profile) + domain_profile.unregister() + assert not domain_profile.is_registered() + assert pc.is_registered() + domain_profile.register(profile) # and back, as a fixture does + assert domain_profile.is_registered() + pc.unregister() + assert not pc.is_registered() + finally: + domain_profile.unregister() + if held is not None: + domain_profile.register(held) + + def test_a_refused_contribution_leaves_no_profile_registered(isolated_seams) -> None: class OtherWriter: @staticmethod diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index 3fe4a8a..32b200d 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -208,6 +208,36 @@ def test_a_protected_suite_renamed_away_is_refused(repo) -> None: assert finding.path == SUITE +def test_an_entry_admits_one_landing_and_a_replay_is_refused(repo) -> None: + """Two reviewed landings take the suite X -> Y and back to X. A third, + unreviewed, repeats the first edit: the suite is at entry 1's + `before_blob` again and its diff is entry 1's text, but entry 1 has + admitted its landing and admits no second one (Copilot on #35).""" + first = repo.commit({SUITE: AFTER}, f"reviewed edit\n\n{ARC}") + second = repo.commit({SUITE: BEFORE}, f"reviewed revert\n\n{ARC}") + replay = repo.commit({SUITE: AFTER}, f"the same edit, unreviewed\n\n{ARC}") + entries = [_entry(repo), + _entry(repo, before=AFTER, after=BEFORE, old=NEW, new=OLD)] + findings = {f.landing: f for f in _check(repo, entries)} + assert findings[first].admitted_by == 1 + assert findings[second].admitted_by == 2 + assert findings[replay].admitted_by is None + assert f"it admitted {first[:12]} already" in findings[replay].why + + +def test_the_landings_are_taken_oldest_first_in_any_order(repo) -> None: + """The falsifier lists landings newest first; the check does not depend + on it.""" + first = repo.commit({SUITE: AFTER}, f"reviewed edit\n\n{ARC}") + repo.commit({SUITE: BEFORE}, "an unentered revert, no landing") + replay = repo.commit({SUITE: AFTER}, f"the same edit again\n\n{ARC}") + for order in ([first, replay], [replay, first]): + findings = {f.landing: f for f in + ps.check(repo.root, order, {SUITE}, [_entry(repo)])} + assert findings[first].admitted_by == 1 + assert findings[replay].admitted_by is None + + def test_a_commit_without_the_trailer_is_not_a_landing(repo) -> None: repo.commit({SUITE: AFTER}, "an edit that is no arc landing") assert _check(repo, []) == [] @@ -303,6 +333,9 @@ def _valid_entry(**changes) -> dict: ({"schema_version": 1, "kind": "other", "entries": []}, "kind"), ({"schema_version": 1, "kind": ps.KIND, "entries": [], "extra": 1}, "carries"), ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(edit="rewrite")]}, "edit is"), + ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(edit=[])]}, "edit is"), + ("schema_version: 1\nkind: protected-suite-respellings\nentries: []\n? [a]\n: 1\n", + "plain scalar"), ({"schema_version": 1, "kind": ps.KIND, "entries": [_valid_entry(respelled="x")]}, "carries"), ({"schema_version": 1, "kind": ps.KIND, "entries": [{k: v for k, v in _valid_entry().items() if k != "review"}]}, "carries"), From 092bc8eb7f00b3137b23fc3de9e787cb8424f2e9 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:04:21 +0000 Subject: [PATCH 15/44] Raise the triple's floors to CI's reading again, 989 and 985 (plan 034 T059) Run 36733668958 measured 11c02cf4, the head before this change, at openDox-code 814516b7: selected 989, passed 985, skipped 4, failures 0, errors 0. The five cases 11c02cf4 added (four in tests/test_protected_suite_check.py, one in tests/test_projection_contributions.py) take the raise from +99 to +104: T060's 11 and T059's 93. The paragraph is restated with the new counts, and the floors sit on the reading again, margin zero. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b2426ec..7ce859e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -275,16 +275,16 @@ jobs: # are named below, and none of them waits for `doc_health`. # # RAISED BY plan 034 T059 (openXdox-code#35), the same way: run - # 36655105352 (2026-09-30) measured `c34c0dea`, the head before this - # change, at openDox-code `814516b7`: selected 984, passed 980, skipped - # 4, failures 0, errors 0. The +99 on both floors has two reasons: + # 36733668958 (2026-09-30) measured `11c02cf4`, the head before this + # change, at openDox-code `814516b7`: selected 989, passed 985, skipped + # 4, failures 0, errors 0. The +104 on both floors has two reasons: # * +11 is T060's (openXdox-code#34), the `values` probes it added to # `tests/test_gate_loop_probes.py`. #34 left the floors where they # were, since the phase-2 draft rule kept it out of this file, and # named the raise as the next writer's. That is this one. - # * +88 is T059's: it adds three test files, of 37 + # * +93 is T059's: it adds three test files, of 41 # (`tests/test_protected_suite_check.py`), 26 - # (`tests/test_governed_registry_and_writer.py`) and 20 + # (`tests/test_governed_registry_and_writer.py`) and 21 # (`tests/test_projection_contributions.py`) cases, and # `tests/test_seam_assembly_beside_gate_and_projection.py` leaves the # declaration, so its six cases run here now and pass. The @@ -323,8 +323,8 @@ jobs: # on a ruling, which lowers them with its reason (above). Set to # the measured values, which this suite has actually met on CI # rather than an aspiration. - MIN_SELECTED: "984" - MIN_PASSED: "980" + MIN_SELECTED: "989" + MIN_PASSED: "985" # EXACT - the load-bearing number. EXPECT_SKIPPED: "4" run: | From 62a1b0da50089d387e6d52ec6e650e677b3e99bf Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:18:18 +0000 Subject: [PATCH 16/44] Resolve the source root under resolve_within's guard, and bring the seam-assembly file's docstring to its included state (plan 034 T059) Copilot's review at 092bc8eb listed two findings in its overview: - resolve_within resolved the source root before its exception guard, so a root that is itself a symlink loop raised out of the /source route instead of refusing. The root is now resolved inside the guard, and a case holds it (red before this commit). - tests/test_seam_assembly_beside_gate_and_projection.py still said, in two passages, that serve_projection reaches doc_health and that the module stops at its import. Both now say that was so from T044 to T059, and that the module imports and runs in the required check since T059. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- src/openxdox/snapshot_registry.py | 5 +++- tests/test_governed_registry_and_writer.py | 8 ++++++ ...eam_assembly_beside_gate_and_projection.py | 27 ++++++++++--------- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/openxdox/snapshot_registry.py b/src/openxdox/snapshot_registry.py index 648580f..6a7e132 100644 --- a/src/openxdox/snapshot_registry.py +++ b/src/openxdox/snapshot_registry.py @@ -430,8 +430,11 @@ def resolve_within(root: Path, url_tail: str) -> Path | None: parts = [p for p in rel.replace("\\", "/").split("/") if p not in ("", ".")] if _names_something_hidden(parts): return None - root = Path(root).resolve() + # The ROOT is resolved inside the guard too (Copilot on openXdox-code#35): + # a source root that is itself a symlink loop refuses, as a path through + # one does, rather than raising out of the `/source` route. try: + root = Path(root).resolve() resolved = (root / rel).resolve() except (OSError, RuntimeError, ValueError): return None diff --git a/tests/test_governed_registry_and_writer.py b/tests/test_governed_registry_and_writer.py index 71f90dd..8748b33 100644 --- a/tests/test_governed_registry_and_writer.py +++ b/tests/test_governed_registry_and_writer.py @@ -71,6 +71,14 @@ def test_a_symlink_to_a_document_is_still_served(served_root: Path) -> None: served_root / "docs" / "note.md").resolve() +def test_a_source_root_that_is_a_symlink_loop_serves_nothing(tmp_path: Path) -> None: + """The root is resolved under the same guard as the path, so a malformed + root refuses instead of raising out of the route.""" + loop = tmp_path / "loop" + loop.symlink_to(tmp_path / "loop") + assert reg.resolve_within(loop, "docs/note.md") is None + + def test_the_spelled_rule_still_refuses_first(served_root: Path) -> None: assert reg.resolve_within(served_root, ".git/config") is None assert reg.resolve_within(served_root, "%2egit/config") is None diff --git a/tests/test_seam_assembly_beside_gate_and_projection.py b/tests/test_seam_assembly_beside_gate_and_projection.py index 3ef98ca..c83026f 100644 --- a/tests/test_seam_assembly_beside_gate_and_projection.py +++ b/tests/test_seam_assembly_beside_gate_and_projection.py @@ -8,15 +8,15 @@ states first. The fourth, (d), is that the extension assembles cleanly beside the OTHER existing contribution columns, in either declaration order. Two of those columns are `serve_gate.GateRoutesExtension` and -`serve_projection.ProjectionRoutesExtension`, and `serve_projection` reaches -openxFactory's `doc_health` when it is imported, through `snapshot_registry`. -So each suite imported the two behind a guard that SKIPPED when `doc_health` -was absent, saying "doc_health reachability is BUILD-arc work (§ 3.5/3.6) ... -this test will assert for real once that lands". A lone checkout never has -`doc_health`, so the six cases behind that guard skipped on every run of this -leg's required check. 9.4 names them: "every one of openXdox's six skips -carries the same reason ... The whole-product assertions are precisely the -ones that skip". +`serve_projection.ProjectionRoutesExtension`, and until plan 034 T059 +`serve_projection` reached openxFactory's `doc_health` when it was imported, +through `snapshot_registry`. So each suite imported the two behind a guard +that SKIPPED when `doc_health` was absent, saying "doc_health reachability is +BUILD-arc work (§ 3.5/3.6) ... this test will assert for real once that +lands". A lone checkout never has `doc_health`, so the six cases behind that +guard skipped on every run of this leg's required check until T044. 9.4 named +them: "every one of openXdox's six skips carries the same reason ... The +whole-product assertions are precisely the ones that skip". WHAT T044 DID. It removed the guard, so each case asserts for real, and it moved the six here. Each keeps its body and its comments, except that the @@ -68,10 +68,11 @@ ) # The two ALREADY-LANDED contribution columns every case below assembles -# beside. `serve_projection` reaches `doc_health` when it is imported -# (through `snapshot_registry`), so in a lone checkout this module stops at -# its import, at collection, on `doc_health` alone. That is the failure the -# declaration holds this file to. No guard turns it into a skip any more. +# beside. From T044 to T059 `serve_projection` reached `doc_health` when it +# was imported (through `snapshot_registry`), so in a lone checkout this +# module stopped at its import, on `doc_health` alone, and the declaration +# held it to that failure. Since T059 both import in a lone checkout, and +# this module runs in the required check. No guard turns a case into a skip. from openxdox.serve_gate import GateRoutesExtension from openxdox.serve_projection import ProjectionRoutesExtension From e78600569bc4c742691dcc1c6b89d80f04f1a084 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:25:51 +0000 Subject: [PATCH 17/44] A landing the checkout does not hold is an input refusal, exit 2, not a protected-suite refusal (plan 034 T059) Copilot's review at 62a1b0d4 listed one finding in its overview: a well-formed commit id with no object behind it reached git rev-list, and the CalledProcessError escaped as a traceback with exit 1, the status that means the arc edited a protected suite. The check now catches a failed git read and exits 2, naming the command, with nothing checked. A case holds it (red before this commit). Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/protected_suites.py | 14 ++++++++++++-- tests/test_protected_suite_check.py | 11 +++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py index 0f1f6fe..525255a 100644 --- a/scripts/protected_suites.py +++ b/scripts/protected_suites.py @@ -31,7 +31,8 @@ refuses an item of neither shape (exit 2) rather than handing it to git. The one file it reads is the allow-list, at its fixed path under the checkout it runs in. It exits 0 when no landing touched a protected suite outside an entry that holds, 1 when one did (naming each landing and -path), and 2 when its input or the allow-list itself breaks its rules. +path), and 2 when its input or the allow-list itself breaks its rules, or when +the checkout does not hold the history a landing needs. WHEN AN ENTRY HOLDS (the file's own header states the rule, and T060 wrote it). For a landing L that touches a protected `suite`, an entry for that suite holds @@ -377,7 +378,16 @@ def main(argv: list[str] | None = None) -> int: print(f"FAIL: {ALLOW_LIST} breaks its own rules, so nothing is subtracted: {exc}", file=sys.stderr) return 2 - findings = check(repo, landings, suites, entries) + try: + findings = check(repo, landings, suites, entries) + except subprocess.CalledProcessError as exc: + # A landing this checkout does not hold, or one with no parent: the + # history the check needs is not here, which is not a refusal. + detail = (exc.stderr or "").strip().splitlines() + print(f"FAIL: git could not read the history the check needs " + f"({' '.join(map(str, exc.cmd[3:]))}: {detail[0] if detail else exc.returncode}), " + "so nothing is checked", file=sys.stderr) + return 2 refused = [] for f in findings: if f.admitted_by is not None: diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index 32b200d..e45478b 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -301,6 +301,17 @@ def test_an_item_of_neither_shape_is_refused_before_anything_is_checked( assert "so nothing is checked" in capsys.readouterr().err +def test_a_landing_this_checkout_does_not_hold_is_an_input_refusal(repo, monkeypatch, + capsys) -> None: + """A well-formed commit id with no object behind it is missing history, + not an edit the arc made: exit 2, never 1 (Copilot on #35).""" + monkeypatch.chdir(repo.root) + (repo.root / ps.ALLOW_LIST).write_text(yaml.safe_dump( + {"schema_version": 1, "kind": ps.KIND, "entries": []}), encoding="utf-8") + assert ps.main(_command(repo, landings="0" * 40 + "\n")) == 2 + assert "git could not read the history" in capsys.readouterr().err + + def test_a_missing_option_is_a_usage_refusal(repo, monkeypatch) -> None: monkeypatch.chdir(repo.root) assert ps.main([f"--suites={SUITE}"]) == 2 From f22c73b00bede352d11884e947aa55b3e0966b90 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:31:55 +0000 Subject: [PATCH 18/44] Raise the triple's floors to CI's reading, 991 and 987 (plan 034 T059) Run 36736683387 measured e7860056, the head before this change, at openDox-code 814516b7: selected 991, passed 987, skipped 4, failures 0, errors 0. The two cases 62a1b0d4 and e7860056 added (the symlink-loop root in tests/test_governed_registry_and_writer.py, the missing-history landing in tests/test_protected_suite_check.py) take the raise to +106: T060's 11 and T059's 95. The floors sit on the reading again, margin zero. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7ce859e..fdf7e41 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -275,15 +275,15 @@ jobs: # are named below, and none of them waits for `doc_health`. # # RAISED BY plan 034 T059 (openXdox-code#35), the same way: run - # 36733668958 (2026-09-30) measured `11c02cf4`, the head before this - # change, at openDox-code `814516b7`: selected 989, passed 985, skipped - # 4, failures 0, errors 0. The +104 on both floors has two reasons: + # 36736683387 (2026-09-30) measured `e7860056`, the head before this + # change, at openDox-code `814516b7`: selected 991, passed 987, skipped + # 4, failures 0, errors 0. The +106 on both floors has two reasons: # * +11 is T060's (openXdox-code#34), the `values` probes it added to # `tests/test_gate_loop_probes.py`. #34 left the floors where they # were, since the phase-2 draft rule kept it out of this file, and # named the raise as the next writer's. That is this one. - # * +93 is T059's: it adds three test files, of 41 - # (`tests/test_protected_suite_check.py`), 26 + # * +95 is T059's: it adds three test files, of 42 + # (`tests/test_protected_suite_check.py`), 27 # (`tests/test_governed_registry_and_writer.py`) and 21 # (`tests/test_projection_contributions.py`) cases, and # `tests/test_seam_assembly_beside_gate_and_projection.py` leaves the @@ -323,8 +323,8 @@ jobs: # on a ruling, which lowers them with its reason (above). Set to # the measured values, which this suite has actually met on CI # rather than an aspiration. - MIN_SELECTED: "989" - MIN_PASSED: "985" + MIN_SELECTED: "991" + MIN_PASSED: "987" # EXACT - the load-bearing number. EXPECT_SKIPPED: "4" run: | From f70ed5d1e08d4c339a27acc93b16121887ca4b46 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:31:55 +0000 Subject: [PATCH 19/44] State write_snapshot's refusal order as the code has it: the rendering, then the boundary (plan 034 T059) Copilot's review at e7860056 listed one finding in its overview: the docstring said permit_output runs first, and in the next sentence that the rendering runs before it, which the code does. It now says the rendering is refused first, as SnapshotNotWritable, with nothing asked of the boundary, and that the boundary's check runs before anything is written. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- src/openxdox/snapshot.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/openxdox/snapshot.py b/src/openxdox/snapshot.py index 78f2c18..5eb651a 100644 --- a/src/openxdox/snapshot.py +++ b/src/openxdox/snapshot.py @@ -105,9 +105,11 @@ def write_snapshot(snapshot: dict[str, Any], path: Path | str, boundary) -> Path THE BOUNDARY STILL DECIDES THE DESTINATION. `permit_output` is the check `write_output` makes, root and allowlist, with its refusal and its ledger, - and it runs first, so a refused target leaves nothing behind. The - rendering runs before it, so a snapshot JSON cannot carry is refused with - nothing written either. The sibling is created exclusively beside the + and it runs before anything is written, so a refused target leaves + nothing behind. THE RENDERING RUNS BEFORE IT: a snapshot JSON cannot carry + is refused first, as `SnapshotNotWritable`, with nothing written and + nothing asked of the boundary, so a write that is wrong on both counts is + refused for its content. The sibling is created exclusively beside the permitted target, with the mode an ordinary write would give it, or with the target's own permission bits where the target exists, so a refresh never widens a restricted snapshot. Its name is a dot-file with no From 9d3155371b4086357000c71d9808dfd6b2d725ef Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:38:16 +0000 Subject: [PATCH 20/44] An entry's old text must start a line, as it ends one (plan 034 T059) Copilot at f70ed5d1 (r4146436523): the whole-lines rule checked only the trailing newline, so old: "1 == 1\n" could admit a change to the tail of "assert 1 == 1", an edit never shown whole. The occurrence must now start at the text's start or just after a newline. A case holds it (red before this commit), and entries 1 to 3 still hold at a simulated squash landing. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/protected_suites.py | 8 ++++++-- tests/test_protected_suite_check.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py index 525255a..bfa1e99 100644 --- a/scripts/protected_suites.py +++ b/scripts/protected_suites.py @@ -40,8 +40,9 @@ * `git rev-parse L^1:` is its `before_blob`, and `git rev-parse L:` is its `after_blob`; -* its `old` text occurs exactly once in the `before_blob` text, and replacing - it with `new` gives the `after_blob` text byte for byte; +* its `old` text occurs exactly once in the `before_blob` text, starting a + line (it ends one, by the file's rules), and replacing it with `new` gives + the `after_blob` text byte for byte; * the replaced text lies inside the one test the entry names, in the before text, and its replacement lies inside that test in the after text. So an entry cannot admit an edit to any other test of the suite. @@ -277,6 +278,9 @@ def entry_holds(repo: Path, landing: str, entry: dict) -> str | None: if before_text.count(old) != 1: return f"the entry's old text occurs {before_text.count(old)} times before the landing, not once" at = before_text.index(old) + if at and before_text[at - 1] != "\n": + # Whole lines: the occurrence starts a line, as it ends one. + return "the entry's old text does not start a line before the landing, so it is not whole lines" if before_text.replace(old, new, 1) != after_text: return "replacing the entry's old text with its new text does not give the suite at the landing" if not _inside_the_test(before_text, at, old, entry["test"]): diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index e45478b..1bf0b64 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -156,6 +156,18 @@ def test_an_old_text_that_occurs_twice_is_refused(repo) -> None: assert "occurs 2 times" in finding.why +def test_an_old_text_that_is_the_tail_of_a_line_is_refused(repo) -> None: + """`old` ends in a newline, but it is only the end of a line: the entry + would admit a change to part of an assertion, never shown whole + (Copilot on #35).""" + after = BEFORE.replace("assert 1 == 1", "assert 2 == 2") + repo.commit({SUITE: after}, f"edit\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, after=after, test="test_first", + old="1 == 1\n", new="2 == 2\n")]) + assert finding.admitted_by is None + assert "does not start a line" in finding.why + + def test_an_edit_outside_the_named_test_is_refused(repo) -> None: """The text is exact, but it lies in `test_second`, and the entry names `test_first`.""" From 0fdd65c19f67f6ee075d175f0568e40d0fd2dd6d Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:44:50 +0000 Subject: [PATCH 21/44] Raise the triple's floors to CI's reading, 992 and 988 (plan 034 T059) Run 36738271008 measured 9d315537, the head before this change, at openDox-code 814516b7: selected 992, passed 988, skipped 4, failures 0, errors 0. The partial-line case 9d315537 added takes the raise to +107: T060's 11 and T059's 96. Margin zero again (Copilot's overview at 9d315537). Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index fdf7e41..c1e2901 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -275,14 +275,14 @@ jobs: # are named below, and none of them waits for `doc_health`. # # RAISED BY plan 034 T059 (openXdox-code#35), the same way: run - # 36736683387 (2026-09-30) measured `e7860056`, the head before this - # change, at openDox-code `814516b7`: selected 991, passed 987, skipped - # 4, failures 0, errors 0. The +106 on both floors has two reasons: + # 36738271008 (2026-09-30) measured `9d315537`, the head before this + # change, at openDox-code `814516b7`: selected 992, passed 988, skipped + # 4, failures 0, errors 0. The +107 on both floors has two reasons: # * +11 is T060's (openXdox-code#34), the `values` probes it added to # `tests/test_gate_loop_probes.py`. #34 left the floors where they # were, since the phase-2 draft rule kept it out of this file, and # named the raise as the next writer's. That is this one. - # * +95 is T059's: it adds three test files, of 42 + # * +96 is T059's: it adds three test files, of 43 # (`tests/test_protected_suite_check.py`), 27 # (`tests/test_governed_registry_and_writer.py`) and 21 # (`tests/test_projection_contributions.py`) cases, and @@ -323,8 +323,8 @@ jobs: # on a ruling, which lowers them with its reason (above). Set to # the measured values, which this suite has actually met on CI # rather than an aspiration. - MIN_SELECTED: "991" - MIN_PASSED: "987" + MIN_SELECTED: "992" + MIN_PASSED: "988" # EXACT - the load-bearing number. EXPECT_SKIPPED: "4" run: | From a1b75f3a26d8c1d12c1d877642d608b773a3d986 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:54:58 +0000 Subject: [PATCH 22/44] Read doc_health only for the sentinel an entry with no revision gets (plan 034 T059) Copilot at 0fdd65c1 (r4146580826): index_entry imported pin_sentinels on every call, so an index of entries that carry their own source_revision failed where doc_health is absent, although the sentinel was never needed. The import now happens only for an entry with no revision. A blocked-module subprocess case holds both halves: a versioned entry is indexed without doc_health, and a revisionless one still fails on doc_health (red before this commit). DOC_HEALTH_SURFACE still reads one deferred import here. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- src/openxdox/snapshot_registry.py | 17 ++++++++++++----- tests/test_projection_contributions.py | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/openxdox/snapshot_registry.py b/src/openxdox/snapshot_registry.py index 6a7e132..2daa2a1 100644 --- a/src/openxdox/snapshot_registry.py +++ b/src/openxdox/snapshot_registry.py @@ -83,8 +83,8 @@ # comes from the declaration that a verification guarding on the exact string # reads. # -# IT IS IMPORTED WHERE IT IS READ, in `SnapshotEntry.index_entry` (plan 034 -# T059). This module is openXdox's contribution at openDox's snapshot registry +# IT IS IMPORTED WHERE IT IS READ, in `SnapshotEntry.index_entry`, for an +# entry with no revision of its own (plan 034 T059). This module is openXdox's contribution at openDox's snapshot registry # seam (`openxdox.projection_contributions`), and openDox probes a # registration's names when it is made. `pin_sentinels` is this module's one # reach into openxFactory's `doc_health`, which a lone openXdox-code checkout @@ -301,15 +301,22 @@ def index_entry(self) -> dict: only that the revision was not established: the repository is readable, and whether the snapshot's own generation lacked a revision, could not fetch one, or never recorded one is not knowable from here. Writing a - stronger member would assert a condition nobody established.""" - from doc_health import pin_sentinels + stronger member would assert a condition nobody established. + `doc_health` is read only for the sentinel (plan 034 T059, Copilot on + openXdox-code#35, r4146580826), so an entry that carries its revision + is indexed where `doc_health` is absent too.""" + source_revision = self.source_revision + if not source_revision: + from doc_health import pin_sentinels + + source_revision = pin_sentinels.UNKNOWN out: dict[str, Any] = { "repository": self.repository, "ref": self.ref, "snapshot": self.location or ( self.snapshot_path.name if self.snapshot_path else f"{self.repository}-snapshot.json"), - "source_revision": self.source_revision or pin_sentinels.UNKNOWN, + "source_revision": source_revision, } if self.generated_at: out["generated_at"] = self.generated_at diff --git a/tests/test_projection_contributions.py b/tests/test_projection_contributions.py index 363b06e..6e7b5dd 100644 --- a/tests/test_projection_contributions.py +++ b/tests/test_projection_contributions.py @@ -352,6 +352,24 @@ def test_without_doc_health_a_governed_generation_fails_on_doc_health() -> None: assert done.stdout.strip().splitlines()[-1] == "refused: doc_health" +def test_without_doc_health_an_entry_that_carries_its_revision_is_indexed() -> None: + """`doc_health` is read only for the sentinel an entry with no revision + gets, so an index of entries that carry theirs is served without it, and + the sentinel alone still fails on `doc_health` (Copilot on #35).""" + done = _run_blocked(''' + import json + from openxdox import snapshot_registry as reg + versioned = reg.SnapshotEntry(repository="alpha", ref="main", source_revision="a" * 40) + print(json.dumps(versioned.index_entry()["source_revision"])) + try: + reg.SnapshotEntry(repository="beta", ref="main").index_entry() + except ModuleNotFoundError as exc: + print("refused:", exc.name) + ''') + assert done.returncode == 0, done.stderr + assert done.stdout.strip().splitlines()[-2:] == ['"' + "a" * 40 + '"', "refused: doc_health"] + + def test_the_scanned_roots_are_read_when_used(monkeypatch) -> None: reads = [] From 4feb80092a3ba81de09ebd4adb04d10a88c6450c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:01:31 +0000 Subject: [PATCH 23/44] Raise the triple's floors to CI's reading, 993 and 989 (plan 034 T059) Run 36740358207 measured a1b75f3a, the head before this change, at openDox-code 814516b7: selected 993, passed 989, skipped 4, failures 0, errors 0. The versioned-entry case a1b75f3a added takes the raise to +108: T060's 11 and T059's 97. Margin zero again. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c1e2901..34efbaa 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -275,16 +275,16 @@ jobs: # are named below, and none of them waits for `doc_health`. # # RAISED BY plan 034 T059 (openXdox-code#35), the same way: run - # 36738271008 (2026-09-30) measured `9d315537`, the head before this - # change, at openDox-code `814516b7`: selected 992, passed 988, skipped - # 4, failures 0, errors 0. The +107 on both floors has two reasons: + # 36740358207 (2026-09-30) measured `a1b75f3a`, the head before this + # change, at openDox-code `814516b7`: selected 993, passed 989, skipped + # 4, failures 0, errors 0. The +108 on both floors has two reasons: # * +11 is T060's (openXdox-code#34), the `values` probes it added to # `tests/test_gate_loop_probes.py`. #34 left the floors where they # were, since the phase-2 draft rule kept it out of this file, and # named the raise as the next writer's. That is this one. - # * +96 is T059's: it adds three test files, of 43 + # * +97 is T059's: it adds three test files, of 43 # (`tests/test_protected_suite_check.py`), 27 - # (`tests/test_governed_registry_and_writer.py`) and 21 + # (`tests/test_governed_registry_and_writer.py`) and 22 # (`tests/test_projection_contributions.py`) cases, and # `tests/test_seam_assembly_beside_gate_and_projection.py` leaves the # declaration, so its six cases run here now and pass. The @@ -323,8 +323,8 @@ jobs: # on a ruling, which lowers them with its reason (above). Set to # the measured values, which this suite has actually met on CI # rather than an aspiration. - MIN_SELECTED: "992" - MIN_PASSED: "988" + MIN_SELECTED: "993" + MIN_PASSED: "989" # EXACT - the load-bearing number. EXPECT_SKIPPED: "4" run: | From 544fb731dc27616ba94ab4fa32ed6f1b4a13cf4d Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:51:24 +0000 Subject: [PATCH 24/44] Let batch C's allow-list admit an added test (plan 034 T061) T007's batch F admits two edits for F7.1, and one of them ADDS a test: tests/test_snapshot.py::test_the_validator_is_the_installed_consumers_own. The check's fourth condition asked that `old` and `new` both lie inside the named test, which an added test cannot meet: it is absent from the `before_blob` text. So where the named test is absent before the landing, the fourth condition reads instead: `new` is `old` followed by the test's whole definition and blank lines, and nothing else. An entry still admits one landing, and cannot admit a change to a neighbouring test or code beside the added one. Four cases cover it: an added test admitted; one that changes its neighbour, one beside other code, and one naming a test found nowhere, each refused. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/protected_suites.py | 32 +++++++++++++++++ tests/protected_suite_respellings.yaml | 4 +++ tests/test_protected_suite_check.py | 49 +++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py index bfa1e99..4942881 100644 --- a/scripts/protected_suites.py +++ b/scripts/protected_suites.py @@ -46,6 +46,11 @@ * the replaced text lies inside the one test the entry names, in the before text, and its replacement lies inside that test in the after text. So an entry cannot admit an edit to any other test of the suite. +* AN ADDED TEST (plan 034 T061; T007's batch F admits one). Where the named + test does not exist before the landing, `new` is `old` followed by that test's + whole definition and blank lines, and nothing else, so the edit adds the one + test and changes no line it does not add. `old` is then the text the test is + added after, and may lie in a neighbouring test, which it leaves as it was. A landing that touches a protected suite is admitted for that suite only if one entry holds at it. Every other protected path it touches is refused. @@ -264,6 +269,29 @@ def _inside_the_test(text: str, start: int, piece: str, test: str) -> bool: return span[0] <= first_line and last_line <= span[1] +def _only_the_added_test(after_text: str, at: int, old: str, new: str, test: str) -> str | None: + """None when `new` is `old` followed by the named test's whole definition + and blank lines, and nothing else; else why not. The test is found in the + text at the landing, and must lie wholly inside what the edit appended.""" + span = _test_lines(after_text, test) + if span is None: + return f"{test} is not one module-level test before the landing or at it" + if not new.startswith(old): + return (f"{test} is added by this landing, and the entry's new text does not " + "begin with its old text, so the edit changes more than it adds") + first = after_text.count("\n", 0, at + len(old)) + 1 + tail = new[len(old):].splitlines() + last = first + len(tail) - 1 + if not (first <= span[0] and span[1] <= last): + return f"{test} does not lie wholly inside the text the entry appends" + outside = [line for number, line in enumerate(tail, first) + if not span[0] <= number <= span[1] and line.strip()] + if outside: + return (f"the entry appends {len(outside)} line(s) that are not {test}'s own: " + f"{outside[0].strip()[:60]!r}") + return None + + def entry_holds(repo: Path, landing: str, entry: dict) -> str | None: """None when `entry` holds at `landing`, else why it does not.""" suite = entry["suite"] @@ -283,6 +311,10 @@ def entry_holds(repo: Path, landing: str, entry: dict) -> str | None: return "the entry's old text does not start a line before the landing, so it is not whole lines" if before_text.replace(old, new, 1) != after_text: return "replacing the entry's old text with its new text does not give the suite at the landing" + if _test_lines(before_text, entry["test"]) is None: + # An ADDED test (plan 034 T061; T007 batch F admits one): it has no body + # before the landing to lie inside, so the edit must add it and only it. + return _only_the_added_test(after_text, at, old, new, entry["test"]) if not _inside_the_test(before_text, at, old, entry["test"]): return f"the entry's old text is not inside {entry['test']} before the landing" if not _inside_the_test(after_text, at, new, entry["test"]): diff --git a/tests/protected_suite_respellings.yaml b/tests/protected_suite_respellings.yaml index 97d6ad9..1bd6412 100644 --- a/tests/protected_suite_respellings.yaml +++ b/tests/protected_suite_respellings.yaml @@ -68,6 +68,10 @@ # fourth, from `test`: `old` lies inside the named test in the # `before_blob` text, and `new` inside it in the `after_blob` text, so an # entry cannot admit an edit to another test of the suite. +# * AN ADDED TEST (plan 034 T061; batch F admits one). Where `test` does not +# exist in the `before_blob` text, the fourth condition reads instead: `new` +# is `old` followed by that test's whole definition and blank lines, and +# nothing else. `old` is then the text the test is added after. # * Entries for one suite CHAIN: each one's `before_blob` is the previous # one's `after_blob`, because each edit is made to the suite the last one # left. diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index 1bf0b64..203b335 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -14,7 +14,9 @@ entry's; * a commit without the `Arc:` line is not a landing, whatever it touches; * entries for one suite chain, and a file that breaks its own rules refuses - the whole check rather than subtracting less. + the whole check rather than subtracting less; +* an ADDED test (plan 034 T061) is admitted only where the edit adds it and + nothing else. The last cases hold this repository's own allow-list to those rules. Which landing each entry holds at is the falsifier's to show, at the head it runs @@ -250,6 +252,51 @@ def test_the_landings_are_taken_oldest_first_in_any_order(repo) -> None: assert findings[replay].admitted_by is None +THIRD = """ + +def test_third() -> None: + \"\"\"The third, added.\"\"\" + assert 3 == 3 +""" + + +def test_an_added_test_is_admitted_when_the_edit_adds_it_and_nothing_else(repo) -> None: + """Plan 034 T061 (batch F admits an added test): the named test has no body + before the landing, so `new` is `old` and then the test, and nothing else.""" + after = BEFORE + THIRD + repo.commit({SUITE: after}, f"add\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, after=after, test="test_third", + old=OLD, new=OLD + THIRD)]) + assert finding.admitted_by == 1 + + +def test_an_added_test_whose_edit_also_changes_its_neighbour_is_refused(repo) -> None: + changed = NEW + THIRD + after = BEFORE.replace(OLD, changed) + repo.commit({SUITE: after}, f"add\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, after=after, test="test_third", + old=OLD, new=changed)]) + assert finding.admitted_by is None + assert "does not begin with its old text" in finding.why + + +def test_an_added_test_beside_other_added_code_is_refused(repo) -> None: + helper = "\n\nHELPER = 1\n" + after = BEFORE + helper + THIRD + repo.commit({SUITE: after}, f"add\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, after=after, test="test_third", + old=OLD, new=OLD + helper + THIRD)]) + assert finding.admitted_by is None + assert "not test_third's own" in finding.why + + +def test_an_entry_naming_a_test_that_exists_nowhere_is_refused(repo) -> None: + repo.commit({SUITE: AFTER}, f"edit\n\n{ARC}") + [finding] = _check(repo, [_entry(repo, test="test_nowhere")]) + assert finding.admitted_by is None + assert "not one module-level test" in finding.why + + def test_a_commit_without_the_trailer_is_not_a_landing(repo) -> None: repo.commit({SUITE: AFTER}, "an edit that is no arc landing") assert _check(repo, []) == [] From 2435e7573bdf84cdfa56c7abc0e80bb0ea903a8d Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:51:24 +0000 Subject: [PATCH 25/44] Ship the consumer validator and its own three schemas as package data (plan 034 T061, 7.3) #1144 7.3 (RULED R1Q14 (a)), as T007's batch I amends it on R1Q27 (a): the consumer's validator validates its own three kinds (7.1's openXdox-spec three) from its installed distribution, wherever it runs, and the family's other seven only where the running tree supplies their schemas. * openxdox.contracts (new): the three schemas, byte copies of opensoft/openXdox-spec at f088b097 (the commit the openXdox root pins), copies.yaml recording each one's sha256 (the root manifest's digests), and the validator, byte for byte scripts/validate-ideation-dashboard-contracts.py. A copy is read only after its digest is checked against the record. * The validator's schema lookup (schema_source): the tree's own contracts/ first, as before (openxFactory's farm composes the family that way, and the packaged copy reads the copies beside it that way); then, for the three, the installed distribution found through the import system; then, for the seven, CONTRACTS_DIR, which never supplies the three. A copy that differs from its record is refused by name (harness exit 2), never read. * pyproject.toml: the package-data line, and `referencing` and `rfc3339-validator` as runtime dependencies, since the package now ships a validator that imports the first and refuses to run without the second. rfc3339-validator leaves the test extra. * tests/test_packaged_validator.py (new): the two validator copies equal, each copy its recorded digest at the root pin, the record refused for each way it can be wrong, and the package-data line held to the files it ships. * tests/test_validator_schema_home.py: revised for the schemas' new home, with cases for the three from the distribution, a tampered copy, and the packaged layout reading its own copies. * tests/test_dependency_direction.py: a .py under src/ that is not a module is named, and its imports are held to the declared dependencies. * tests/test_validate_ideation_dashboard_contracts.py: test_every_schema_the_consumer_validates_is_on_disk, F7.1's second test, read as batch I reads it. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 47 +- .../validate-ideation-dashboard-contracts.py | 138 +- src/openxdox/contracts/__init__.py | 237 ++ src/openxdox/contracts/copies.yaml | 43 + .../schemas/gate-action-record.schema.yaml | 855 +++++++ ...ation-dashboard-snapshot-index.schema.yaml | 163 ++ .../ideation-dashboard-snapshot.schema.yaml | 469 ++++ .../validate-ideation-dashboard-contracts.py | 2142 +++++++++++++++++ tests/test_dependency_direction.py | 61 +- tests/test_packaged_validator.py | 216 ++ ...t_validate_ideation_dashboard_contracts.py | 37 + tests/test_validator_schema_home.py | 173 +- 12 files changed, 4532 insertions(+), 49 deletions(-) create mode 100644 src/openxdox/contracts/__init__.py create mode 100644 src/openxdox/contracts/copies.yaml create mode 100644 src/openxdox/contracts/schemas/gate-action-record.schema.yaml create mode 100644 src/openxdox/contracts/schemas/ideation-dashboard-snapshot-index.schema.yaml create mode 100644 src/openxdox/contracts/schemas/ideation-dashboard-snapshot.schema.yaml create mode 100755 src/openxdox/contracts/validate-ideation-dashboard-contracts.py create mode 100644 tests/test_packaged_validator.py diff --git a/pyproject.toml b/pyproject.toml index 14d480d..033a93b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,18 +94,38 @@ requires-python = ">=3.12" # reasoning; this slice is the owed fix — the allowlist entry comes back out # in the same act. `referencing` and `rfc3339-validator` (named alongside # jsonschema in this leg's own `DEPENDENCY_REMEDY` string, `src/openxdox/ -# snapshot.py:105`) are NOT added here: neither is imported anywhere under +# snapshot.py:105`) were NOT added then: neither was imported anywhere under # `src/` — they are jsonschema's own optional format-checking companions, and -# adding an undeclared dependency is not what any reach in this tree calls for. +# adding an undeclared dependency was not what any reach in the tree called for. +# Plan 034 T061 adds both, for the reason the paragraph below gives. # # LOWER BOUND: `>=4.18`, this leg's own already-stated floor # (`DEPENDENCY_REMEDY` at `src/openxdox/snapshot.py:105`, unchanged by this # PR) — and not an arbitrary choice: `Draft202012Validator`, the name # `gate_console.py:563` imports, was added in jsonschema 4.18.0. +# +# `referencing` and `rfc3339-validator`, plan 034 T061 (#1144 7.3; R1Q27 (a): +# the consumer's validator validates its own three kinds "from its installed +# distribution, wherever it runs"). The premise above changed: the validator +# now ships INSIDE the package, as `openxdox/contracts/validate-ideation- +# dashboard-contracts.py`, so an install runs it. It imports `referencing` at +# module level (`Registry`, `Resource`, `referencing.jsonschema.DRAFT202012`), +# and it refuses to run without `rfc3339-validator`: jsonschema registers its +# `date-time` checker only when that package is importable, and the validator +# will not validate with `date-time` unenforced. Left out, a plain install +# would carry a validator it cannot run, and every snapshot it generated would +# read validator-unavailable. `tests/test_dependency_direction.py` holds the +# packaged validator's imports to this list. +# LOWER BOUNDS: `referencing>=0.28.4` is the floor `jsonschema>=4.18` itself +# requires, so declaring it adds nothing an install of jsonschema would not +# already bring. `rfc3339-validator>=0.1.4` is the floor the `test` extra +# stated for it until now (openxFactory's validator-input lock pins 0.1.4). dependencies = [ "opendox @ git+https://github.com/opensoft/openDox-code@814516b778b04d4d5022e486c657774b61c86f15", "PyYAML>=6.0", "jsonschema>=4.18", + "referencing>=0.28.4", + "rfc3339-validator>=0.1.4", ] [project.optional-dependencies] @@ -123,15 +143,16 @@ dependencies = [ # over the whole suite at `e28930bf`, 23 red results asked for exactly this # package (21 in `tests/test_doxbench_blank_reason.py`, 2 in # `tests/test_snapshot.py`), and installing it cleared the 21. -# It is a TEST dependency and stays out of `dependencies` for the reason the -# jsonschema paragraph above gives: nothing under `src/` imports it, and the -# validator script lands at the repository root, outside the package. +# It was a TEST dependency while the validator script sat at the repository +# root, outside the package. Since plan 034 T061 the package ships the +# validator, so it is a runtime dependency (the paragraph above `dependencies` +# gives the reason), and this extra no longer names it: `.[test]` installs it +# all the same. # LOWER BOUND: `>=0.1.4`, derived the way PyYAML's was — what openxFactory # itself runs. Its validator-input lock pins `rfc3339-validator==0.1.4` # (`requirements/hermes-runtime-contracts.in`, which records the same reason). test = [ "pytest>=8,<9", - "rfc3339-validator>=0.1.4", ] [build-system] @@ -166,6 +187,20 @@ where = ["src"] # in an INSTALLED package rather than only in a source checkout. [tool.setuptools.package-data] openxdox = ["web/views/*.js", "web/views/*.css"] +# THE CONSUMER VALIDATOR AND ITS OWN THREE SCHEMAS, SHIPPED AS PACKAGE DATA (plan +# 034 T061; #1144 7.3, RULED R1Q14 (a): "openXdox's validator and its three +# schemas (7.1's openXdox-spec three) are located through the INSTALLED openXdox +# distribution, and no parent walk remains"). A wheel ships no `scripts/`, so an +# install finds its validator here (`openxdox.snapshot.find_validator`), and that +# validator finds its three schemas beside it, each held to the record's digest. +# Without this line a wheel ships the package's two `.py` files and neither the +# record nor a copy (measured: a wheel built from this tree with the line +# removed), so the installed validator refuses each of its own three kinds by +# name. The three patterns name the record, the copies and the validator and +# nothing else; `build_py` would ship the validator as a `.py` file anyway, and +# it is named here so this one line lists what an install needs to validate. +# `tests/test_packaged_validator.py` holds this line to the files it ships. +"openxdox.contracts" = ["copies.yaml", "schemas/*.schema.yaml", "validate-ideation-dashboard-contracts.py"] [tool.pytest.ini_options] # The ROOTDIR ANCHOR. With no pytest ini table anywhere, pytest infers a diff --git a/scripts/validate-ideation-dashboard-contracts.py b/scripts/validate-ideation-dashboard-contracts.py index a3081ad..fe10c33 100755 --- a/scripts/validate-ideation-dashboard-contracts.py +++ b/scripts/validate-ideation-dashboard-contracts.py @@ -92,6 +92,8 @@ from __future__ import annotations import argparse +import hashlib +import importlib.util import os import subprocess import sys @@ -126,7 +128,11 @@ # ... rather than each script guessing at `../`". So nothing above ROOT is read # by position, the class § 8.9 residue (iii) names (a reader adopting its # enclosing tree). The packaged examples sit beside `contracts/` in each tree -# that carries the family. +# that carries the family. Since plan 034 T061 this directory choice decides +# the examples and the messages, and each schema's own source is decided by +# `schema_source` below: this validator's own three come from its installed +# distribution where this tree carries none of them, and CONTRACTS_DIR supplies +# only the family's other seven. _DECLARED_CONTRACTS = os.environ.get("CONTRACTS_DIR", "") _OWN_CONTRACTS = ROOT / "contracts" _OWN_SCHEMAS = _OWN_CONTRACTS / "schemas" @@ -160,6 +166,117 @@ SCHEMAS_DIR = CONTRACTS / "schemas" EXAMPLES_DIR = CONTRACTS.parent / "examples" / "ideation-dashboard" +# THE CONSUMER'S OWN THREE, FROM ITS INSTALLED DISTRIBUTION (plan 034 T061; +# #1144 7.3, RULED R1Q14 (a), and T007's batch I on R1Q27 (a), +# `opensoft/openxFactory#656` comments 5850003126 and 5851950767). This validator +# validates its own three kinds, 7.1's openXdox-spec three, wherever it runs, and +# the family's other kinds only where the tree it runs from supplies their +# schemas. So each family schema is found in ONE of three places, in this order: +# 1. this tree's own `contracts/schemas/`, read first, as before. openxFactory's +# farm (`doxbench_contracts._composed_validator`) supplies the whole family +# that way, and the packaged copy of this script +# (`openxdox/contracts/validate-ideation-dashboard-contracts.py`) finds the +# three packaged copies beside it that way; +# 2. for the three, the INSTALLED openxdox distribution's packaged copies +# (`openxdox/contracts/schemas/`), found through the import system and never +# by position. That is where a source checkout's `scripts/` copy, which has +# no `contracts/` of its own, reads them; +# 3. for the other seven, the directory `CONTRACTS_DIR` names, which never +# supplies the three. +# A packaged copy is read only once its sha256 equals the one `copies.yaml` +# records beside it, wherever it is found (`openxdox.contracts` applies the +# same rule), so a copy edited in place is refused, never read. A schema no +# place supplies is refused BY NAME where an instance needs it (harness exit 2). +OWN_KIND_SCHEMAS = frozenset({ + "ideation-dashboard-snapshot.schema.yaml", + "ideation-dashboard-snapshot-index.schema.yaml", + "gate-action-record.schema.yaml", +}) +COPIES_RECORD = "copies.yaml" + + +class ContractRefused(Exception): + """A packaged copy cannot be trusted, so it is not read (harness exit 2).""" + + +def distribution_contracts() -> Path | None: + """`openxdox/contracts/` of the openxdox distribution the running interpreter + has installed, or None where it has none. Found through the import system + (`find_spec` imports nothing of the package itself), never by position.""" + try: + spec = importlib.util.find_spec("openxdox.contracts") + except (ImportError, ValueError): + return None + if spec is None or not spec.submodule_search_locations: + return None + return Path(list(spec.submodule_search_locations)[0]).resolve() + + +def verified_copy(contracts: Path, name: str) -> Path: + """`/schemas/`, once its sha256 equals the digest the record + beside it (`/copies.yaml`) gives for it. `ContractRefused` + otherwise: an unreadable record, no digest for the name, a copy that cannot + be read, or a copy that differs.""" + record_path = contracts / COPIES_RECORD + try: + record = yaml.safe_load(record_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise ContractRefused(f"{record_path} cannot be read ({type(exc).__name__}), " + f"so the packaged {name} is not read") from exc + rows = record.get("copies") if isinstance(record, dict) else None + wanted = {Path(str(row.get("path", ""))).name: row.get("sha256") + for row in (rows if isinstance(rows, list) else []) if isinstance(row, dict)} + digest = wanted.get(name) + if not isinstance(digest, str) or len(digest) != 64: + raise ContractRefused(f"{record_path} records no sha256 for {name}, so the " + "packaged copy is not read") + path = contracts / "schemas" / name + try: + actual = hashlib.sha256(path.read_bytes()).hexdigest() + except OSError as exc: + raise ContractRefused(f"the packaged {path} cannot be read " + f"({type(exc).__name__})") from exc + if actual != digest: + raise ContractRefused( + f"the packaged {path} is not the spec leg's file: its sha256 is {actual}, " + f"and {record_path} records {digest}. A packaged copy is never edited in " + "place; copy the spec leg's file again at the recorded commit") + return path + + +def schema_source(name: str) -> tuple[Path | None, str]: + """Where this run reads the family schema `name`, and through which channel, + or (None, why no channel supplies it).""" + own = _OWN_SCHEMAS / name + if own.is_file(): + if name in OWN_KIND_SCHEMAS and (_OWN_CONTRACTS / COPIES_RECORD).is_file(): + return verified_copy(_OWN_CONTRACTS, name), "this tree's own packaged copies" + return own, "this tree's own contracts/" + if name in OWN_KIND_SCHEMAS: + distribution = distribution_contracts() + if distribution is not None and (distribution / "schemas" / name).is_file(): + return (verified_copy(distribution, name), + f"the installed openxdox distribution ({distribution})") + found = ("an installed openxdox distribution without it" + if distribution is not None else + "no installed openxdox distribution this interpreter can import") + return None, (f"it is one of this validator's own three kinds, read from this " + f"tree's own contracts/ or else the installed openxdox " + f"distribution, and neither carries it ({found}; CONTRACTS_DIR " + "never supplies the three)") + if _DECLARED_CONTRACTS: + declared = Path(_DECLARED_CONTRACTS).resolve() / "schemas" + if (declared / name).is_file(): + return declared / name, f"CONTRACTS_DIR={_DECLARED_CONTRACTS}" + return None, f"it is not carried under {declared} (CONTRACTS_DIR={_DECLARED_CONTRACTS})" + return None, (f"it is not carried under {_OWN_SCHEMAS}, and CONTRACTS_DIR is not " + "set; export it as the directory that supplies this kind's schema") + + +def schema_sources() -> dict[str, tuple[Path | None, str]]: + """Every family schema's source for this run, as `schema_source` answers.""" + return {name: schema_source(name) for name in SCHEMA_FILENAMES} + SCHEMA_FILENAMES = [ "ideation-dashboard-snapshot.schema.yaml", "ideation-dashboard-snapshot-index.schema.yaml", @@ -283,16 +400,18 @@ def build_registry() -> tuple[Registry, dict[str, dict]]: directory sweep that met no recognized instance, or a register transition, would otherwise reach a verdict having loaded no schema, and the sweep would exit 0 (Copilot review of openXdox-code#28).""" - if not any((SCHEMAS_DIR / name).is_file() for name in SCHEMA_FILENAMES): + sources = schema_sources() + if not any(path is not None for path, _channel in sources.values()): raise FileNotFoundError( f"{SCHEMAS_DIR} carries none of the family's {len(SCHEMA_FILENAMES)} " - f"schemas ({CONTRACTS_SOURCE}), so nothing can be validated and no " - "mode may report success") + f"schemas ({CONTRACTS_SOURCE}), and no installed openxdox distribution " + "supplies this validator's own three, so nothing can be validated and " + "no mode may report success") resources = [] docs: dict[str, dict] = {} for name in SCHEMA_FILENAMES: - path = SCHEMAS_DIR / name - if not path.is_file(): + path, _channel = sources[name] + if path is None: continue doc = load_yaml(path) docs[name] = doc @@ -303,10 +422,11 @@ def build_registry() -> tuple[Registry, dict[str, dict]]: def doc_validator(schema_name: str, registry: Registry, docs: dict[str, dict]) -> Draft202012Validator: if schema_name not in docs: + _path, why = schema_source(schema_name) raise FileNotFoundError( - f"{schema_name} is not carried under {SCHEMAS_DIR} ({CONTRACTS_SOURCE}), " - "so no instance of that kind can be validated here (the carve split this " - "family across openXdox-spec, openDox-spec and openxFactory)") + f"{schema_name} is not supplied: {why}. So no instance of that kind can " + "be validated here (the carve split this family across openXdox-spec, " + "openDox-spec and openxFactory)") return Draft202012Validator(docs[schema_name], registry=registry, format_checker=FORMAT_CHECKER) diff --git a/src/openxdox/contracts/__init__.py b/src/openxdox/contracts/__init__.py new file mode 100644 index 0000000..2d4b29c --- /dev/null +++ b/src/openxdox/contracts/__init__.py @@ -0,0 +1,237 @@ +"""THE CONSUMER VALIDATOR, AND THE PACKAGED COPIES OF ITS OWN THREE SCHEMAS. + +WHY THIS PACKAGE EXISTS. Plan 034's T061 realizes #1144's 7.3 (RULED R1Q14 (a), +`opensoft/openxFactory#656` comment `5850003126`): *"openXdox's validator and its +three schemas (7.1's openXdox-spec three) are located through the INSTALLED +openXdox distribution, and no parent walk remains."* T007's batch I amends it +on R1Q27 (a) (comment `5851950767`): the validator validates its own three kinds +from its installed distribution, wherever it runs, and the family's other kinds +only where the tree it runs from supplies their schemas. + +So an install carries what the validator needs to validate its own kinds, and +it carries it as PACKAGE DATA (`pyproject.toml`'s `[tool.setuptools.package-data]`): + +* `validate-ideation-dashboard-contracts.py`: the validator. It is byte for + byte the repository's `scripts/validate-ideation-dashboard-contracts.py`, + which a source checkout runs, and which openxFactory's lanes and farm read + at that path. A test holds the two equal. +* `schemas/`: the three copies. Each one is byte for byte the spec leg's file + of the same name, `contracts/schemas/.schema.yaml` in + opensoft/openXdox-spec. +* `copies.yaml`: the record. It names the spec-leg commit the copies were taken + at, and each copy's id, spec-leg path and sha256. + +WHERE EACH COPY IS FOUND. The packaged validator's own tree is this package, so +its `contracts/schemas/` is these copies. A source checkout's +`scripts/validate-ideation-dashboard-contracts.py` has no `contracts/` of its +own, so it finds the copies here through the import system +(`importlib.util.find_spec("openxdox.contracts")`), the distribution the running +interpreter has installed, and never by position. + +PRESENCE IS NOT IDENTITY. A copy is read only through `verified_bytes()` or +`verified_path()`. Each recomputes the copy's sha256 and compares it with the +record BEFORE the copy is used, and refuses, with `CopyRefused`, a copy that +differs from its digest, a copy that is absent, and a copy whose digest the +record leaves empty (`neutral-product-pin`'s rule for a vendored contract, as +openDox's `opendox.contracts` applies it). The validator script makes the same +check itself, since it must run where this package cannot be imported. + +CONSUMED, NOT OWNED. openXdox-spec owns these three schemas (R1Q12 (a)). A copy +is changed in the spec leg and then copied here again, never edited here. The +copies, the record, and the `commit` it names move together, in one commit. + +IMPORT WEIGHT. The standard library, and PyYAML (a runtime dependency) when the +record is read. Importing this package reads no file. + +A CREATED FILE: no row in openxFactory's `docs/opendox-carve-manifest.yaml` +(RULED OQ-C). +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from importlib import resources +from pathlib import Path, PurePosixPath + +__all__ = [ + "COPY_IDS", + "COPY_KIND", + "CopyRefused", + "PackagedCopy", + "Record", + "RECORD_NAME", + "SPEC_LEG", + "VALIDATOR_NAME", + "package_dir", + "record", + "validator_path", + "verified_bytes", + "verified_path", +] + +RECORD_NAME = "copies.yaml" +COPY_KIND = "packaged-contract-copies" +SPEC_LEG = "opensoft/openXdox-spec" +SCHEMA_DIR = "schemas" +VALIDATOR_NAME = "validate-ideation-dashboard-contracts.py" + +#: The consumer validator's own three kinds, 7.1's openXdox-spec three. +COPY_IDS = frozenset({"gate-action-record", "ideation-dashboard-snapshot", + "ideation-dashboard-snapshot-index"}) + +_ID = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") +_COMMIT = re.compile(r"[0-9a-f]{40}") +_SHA256 = re.compile(r"[0-9a-f]{64}") + + +class CopyRefused(RuntimeError): + """A packaged copy, or the record that pins the copies, cannot be trusted. + + Raised before the copy is used. The message names the copy, what was + found, and the one remedy: copy the spec leg's file again at the recorded + commit, and record its digest in the same commit.""" + + +@dataclass(frozen=True) +class PackagedCopy: + """One copy, as the record declares it.""" + + id: str + path: str # its path in the spec leg: contracts/schemas/.schema.yaml + sha256: str # its digest at the recorded commit + + @property + def filename(self) -> str: + return PurePosixPath(self.path).name + + +@dataclass(frozen=True) +class Record: + """`copies.yaml`, read and checked.""" + + spec_leg: str + commit: str + copies: tuple[PackagedCopy, ...] + + @property + def ids(self) -> tuple[str, ...]: + return tuple(copy.id for copy in self.copies) + + def copy(self, copy_id: str) -> PackagedCopy: + for copy in self.copies: + if copy.id == copy_id: + return copy + raise CopyRefused( + f"{copy_id!r} is not one of the packaged copies {list(self.ids)} " + f"({RECORD_NAME} records no such copy, so none is read)") + + +def package_dir() -> Path: + """This package's directory on disk: where the copies and the validator sit.""" + return Path(str(resources.files(__name__))) + + +def _refuse(detail: str) -> CopyRefused: + return CopyRefused( + f"{RECORD_NAME} cannot be trusted: {detail}. The record is written with " + f"the copies it pins, in one commit, from {SPEC_LEG} at the commit the " + "openXdox root pins") + + +def _read(name: str) -> bytes: + """`name`'s bytes from the package. Every `OSError` is a refusal.""" + try: + return (package_dir() / name).read_bytes() + except (FileNotFoundError, IsADirectoryError, NotADirectoryError) as exc: + raise CopyRefused( + f"openxdox.contracts has no {name}: the package was built or " + f"installed without it ({type(exc).__name__})") from exc + except OSError as exc: + raise CopyRefused( + f"openxdox.contracts has {name}, and it cannot be read " + f"({type(exc).__name__}: {exc.strerror or exc})") from exc + + +def _checked_copy(index: int, entry: object) -> PackagedCopy: + where = f"copies[{index}]" + if not isinstance(entry, dict) or set(entry) != {"id", "path", "sha256"}: + raise _refuse(f"{where} is not a mapping of exactly id, path and sha256") + copy_id, path, digest = entry["id"], entry["path"], entry["sha256"] + if not isinstance(copy_id, str) or not _ID.fullmatch(copy_id): + raise _refuse(f"{where}.id is {copy_id!r}, not a lowercase hyphenated id") + if path != f"contracts/schemas/{copy_id}.schema.yaml": + raise _refuse(f"{where}.path is {path!r}, not " + f"'contracts/schemas/{copy_id}.schema.yaml'") + if not isinstance(digest, str) or not _SHA256.fullmatch(digest): + # An EMPTY digest lands here too: it is drift, never a pass. + raise _refuse(f"{where}.sha256 is {digest!r}, not a 64-hex digest") + return PackagedCopy(copy_id, path, digest) + + +def record() -> Record: + """The record, read from the package and checked field by field. + + Refuses, with `CopyRefused`, a record it cannot hold every copy to: a + missing or unknown field, a repeated id, an id that is not one of the + three, a path that is not the id's schema path in the spec leg, and an + empty or malformed digest.""" + import yaml + + try: + data = yaml.safe_load(_read(RECORD_NAME)) + except (yaml.YAMLError, RecursionError, ValueError) as exc: + raise _refuse(f"it is not YAML this module can read " + f"({exc.__class__.__name__})") from exc + if not isinstance(data, dict): + raise _refuse(f"it is a {type(data).__name__}, not a mapping") + expected = {"schema_version", "kind", "spec_leg", "commit", "copies"} + if set(data) != expected: + raise _refuse(f"its keys are {sorted(data, key=repr)}, not {sorted(expected)}") + if type(data["schema_version"]) is not int or data["schema_version"] != 1: + raise _refuse(f"schema_version is {data['schema_version']!r}, not 1") + if data["kind"] != COPY_KIND: + raise _refuse(f"kind is {data['kind']!r}, not {COPY_KIND!r}") + if data["spec_leg"] != SPEC_LEG: + raise _refuse(f"spec_leg is {data['spec_leg']!r}, not {SPEC_LEG!r}") + commit = data["commit"] + if not isinstance(commit, str) or not _COMMIT.fullmatch(commit): + raise _refuse(f"commit is {commit!r}, not a full 40-hex commit id") + entries = data["copies"] + if not isinstance(entries, list) or not entries: + raise _refuse("copies is not a non-empty list") + copies = tuple(_checked_copy(index, entry) for index, entry in enumerate(entries)) + ids = [copy.id for copy in copies] + if len(set(ids)) != len(ids): + raise _refuse(f"an id is given twice in {ids}") + if set(ids) != COPY_IDS: + raise _refuse(f"it records {sorted(ids)}, not the consumer's three {sorted(COPY_IDS)}") + return Record(data["spec_leg"], commit, copies) + + +def verified_bytes(copy_id: str) -> bytes: + """The copy's bytes, once its sha256 equals the record's. `CopyRefused` + otherwise, before a byte of it is parsed.""" + copy = record().copy(copy_id) + data = _read(f"{SCHEMA_DIR}/{copy.filename}") + actual = hashlib.sha256(data).hexdigest() + if actual != copy.sha256: + raise CopyRefused( + f"the packaged copy {SCHEMA_DIR}/{copy.filename} is not the spec " + f"leg's file: its sha256 is {actual}, and {RECORD_NAME} records " + f"{copy.sha256} for {copy.path} at {SPEC_LEG}@{record().commit}. " + "Copy the spec leg's file again at that commit; never edit a copy") + return data + + +def verified_path(copy_id: str) -> Path: + """The copy's path on disk, once its digest has been checked.""" + verified_bytes(copy_id) + return package_dir() / SCHEMA_DIR / record().copy(copy_id).filename + + +def validator_path() -> Path | None: + """The packaged validator, or None when this install carries none.""" + candidate = package_dir() / VALIDATOR_NAME + return candidate if candidate.is_file() else None diff --git a/src/openxdox/contracts/copies.yaml b/src/openxdox/contracts/copies.yaml new file mode 100644 index 0000000..fa2338d --- /dev/null +++ b/src/openxdox/contracts/copies.yaml @@ -0,0 +1,43 @@ +# THE RECORD of openXdox's packaged contract copies (plan 034 T061; #1144 7.3, +# as T007's batch I amends it; R1Q14 (a) and R1Q27 (a), opensoft/openxFactory#656 +# comments 5850003126 and 5851950767). +# +# WHAT IT SAYS. Each schema under `schemas/` beside this file is, byte for byte, +# the file its `path` names in `spec_leg` at `commit`, and `sha256` is that +# file's digest. These are the consumer validator's own three kinds (7.1's +# openXdox-spec three). The validator reads them from the installed +# distribution, and recomputes each digest against this record before it reads +# a copy: a copy that differs, one that is absent, and one whose digest is left +# empty are all refused (`openxdox.contracts.verified_path`, and the same check +# inside `validate-ideation-dashboard-contracts.py`). +# +# WHERE THE VALUES CAME FROM, read with `git show : | sha256sum` +# in opensoft/openXdox-spec. +# +# * `commit` is the spec-leg commit the openXdox root pins: its `spec` gitlink +# and `contracts/spec-pin.yaml` name f088b097 at opensoft/openXdox `main` +# 57e2b8f2. It is the one commit all three copies were taken at. +# * Each digest is the one the root's `contracts/manifest.yaml` records for that +# file at that commit. +# +# NEVER EDIT A COPY OR A DIGEST IN PLACE. openXdox-spec owns these schemas +# (R1Q12 (a)). A copy changes in the spec leg. It arrives here when the spec +# leg's file is copied at the pinned commit and its digest recorded, in one +# commit, with `commit` moved to the spec pin that carries it. +# +# A CREATED file: no row in openxFactory's `docs/opendox-carve-manifest.yaml` +# (RULED OQ-C). +schema_version: 1 +kind: packaged-contract-copies +spec_leg: opensoft/openXdox-spec +commit: "f088b09732e236279898b53ab9fb0f5ebc89509a" +copies: + - id: gate-action-record + path: contracts/schemas/gate-action-record.schema.yaml + sha256: "6a6cf13c76e3e6f6792cd2a4be1ed2296b24525bee800a3e9173d608e0539189" + - id: ideation-dashboard-snapshot + path: contracts/schemas/ideation-dashboard-snapshot.schema.yaml + sha256: "054259fe96686e3cace3269a09740b9cd8846fbc2f88e52dd0c93f8974aeab3b" + - id: ideation-dashboard-snapshot-index + path: contracts/schemas/ideation-dashboard-snapshot-index.schema.yaml + sha256: "7fd9b797730e25f7a97c6acc3fb728ccce1b688643e950e7384eb0f9e9a0e296" diff --git a/src/openxdox/contracts/schemas/gate-action-record.schema.yaml b/src/openxdox/contracts/schemas/gate-action-record.schema.yaml new file mode 100644 index 0000000..7f95006 --- /dev/null +++ b/src/openxdox/contracts/schemas/gate-action-record.schema.yaml @@ -0,0 +1,855 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "gate-action-record.schema.yaml" +title: "Gate-action record: human-authenticated console action audit entry" +contract_schema_version: 1 +description: >- + Records one execution of a human gate-console action for the + ideation-area dashboard (`add-ideation-dashboard`; promoted spec + requirement "Human gate console"; design decisions D16/D17). Every gate + action — `demote` (reject / move back to staging), `edit-apply` (a human + applying and committing an AI-drafted concept redline), `ratify` + (approve), and `kickoff` (post-ratification realization-workflow + dispatch, D17) — produces the SAME governed artifacts as the equivalent + manual path, PLUS one of these records (spec: "each producing the same + governed artifacts as the manual path plus a recorded action"). + + `actor` records WHO performed the action. Agents are structurally unable + to author a gate-action record at all: the gate console rejects any + agent-invoked action before one is ever written (spec scenario "An agent + invokes a gate action") — that boundary is enforced by the console and + its authenticator, not by this schema, which only shapes the record of + an action a human already took. + + `demote` is the mechanized reverse transition (D16): the same move as + the 2026-07-13 manual demotion, tooled. A demotion without a recorded + `reason` is invalid (allOf below) — the durable "why" a change went back + to staging. + + PER-ACTION ARTIFACT REQUIREMENTS (encoded schema-side, allOf below): a + `ratify` record MUST reference at least one `ratification-record` + artifact, and a `kickoff` record MUST reference at least one + `workflow-job` artifact. Each is a SINGLE-INSTANCE "this action's + artifact list contains an entry of a given kind" check — cleanly + expressible in draft 2020-12 as `if action then artifacts contains kind` + — so, unlike the register's cross-snapshot transition rules, it is + enforced structurally here rather than deferred. The gate console is + authority-bearing (D16), so an authority-bearing record naming the wrong + action-to-artifact pairing is caught at write time. Later additive deltas + extended the same per-action shape to `dispose-possible` + (`register-update`), `propose` (`workflow-job`), `create-document` + (`document`), and — `add-workbench-branch-sessions` — `edit-document` + (`commit`) and `open-pr` (`pull-request`); `share-session` + (`add-doxbench-editing-phase-b` §12) is constrained on `target.ref` alone, + with no artifact-kind requirement, because it is the one action whose record + RESIDENCY depends on whether it had anything to commit — the conditional + below states both halves; `abandon-session` is constrained + on `target.ref` + `reason` with NO artifact-kind requirement (see below). + Each was safe at the time it landed because no existing record carried that + action. This does NOT narrow the additive posture below: new action kinds + and new artifact kinds stay unconstrained, and no PRE-EXISTING action ever + gains a required companion artifact. + + WHEEL ACTION COMMISSIONS (`add-wheel-action-verbs`; additive growth of + `contract_schema_version: 1`): `promote-to-staging`, `derive-possibles`, + and `research-brief` each record a governed dispatch and therefore MUST + reference a `workflow-job` artifact. The first and third target a + `possible_id`; the cluster-scoped derivation targets a `cluster_id`. + `demote` is unchanged by that change: it was already enumerated with a + `change_id` target and required `reason`, and it still records a plan whose + execution is a separate human act. + + BRANCH SESSIONS (`add-workbench-branch-sessions`, design D13; landed + 2026-07-26 as an ADDITIVE growth of `contract_schema_version: 1` — no bump, + no existing record invalidated): the workbench's working state moved onto a + git branch materialized as a worktree, so three session actions join the + vocabulary — `edit-document` (the session-only rewrite of an existing + document, deliberately NOT `edit-apply`, which stays the main-resident + redline path), `open-pr` (push the session branch and open or update the + pull request that is the formal re-entry of the work into the governed doc + system), and `abandon-session` (end a session without saving, carrying a + required reason). `target` gains an OPTIONAL `ref` naming the session + branch, and the artifact vocabulary gains `commit` and `pull-request` as + FIRST-CLASS kinds — the `document` precedent Brett set on 2026-07-25, not + `other`, so an audit consumer can filter them from the enum. + + Two REALIZATION FACTS (codexFactory feature 007) this schema is shaped to + validate, recorded so a reader does not infer a stricter contract than the + one written: + + * a `commit` artifact's `reference` is the record's OWN action stamp, + never a sha. A commit cannot contain its own sha, so resolution is + DEFINED rather than literal: the referenced commit is the one that + INTRODUCED the record file on the session branch + (`git log --diff-filter=A -- `), which immediately after + the action is the branch tip. This schema requires only + `{kind, reference}` of an artifact, and a resolvable reference satisfies + it — so no `reference` pattern is imposed here. + * `open-pr` and `abandon-session` records are MAIN-RESIDENT — written into + the served checkout's records tree, never onto the session branch, which + the merge deletes and the abandoned-branch cleanup may later delete. + Neither action adds a commit to the branch, so NEITHER may be required + to carry a `commit` artifact: a record naming one would name something + that does not exist. An `abandon-session` record satisfies + `artifacts.minItems` by naming the branch itself as an `other`-kind + artifact, which is why its conditional constrains `target.ref` + + `reason` and imposes NO artifact-kind requirement at all. + + GATEWAY PROVENANCE (`add-workbench-branch-sessions`, design D23; Brett's + 2026-07-27 ruling, an ADDITIVE growth of `contract_schema_version: 1` — + no bump, no existing record invalidated). The gate console's human/agent + boundary is a CONSOLE-PRESENCE control — anti-CSRF / same-origin — and not + authentication: a process running as the identified human, on the human's own + machine, can read the per-serve console token or declare console presence on + the CLI and act as the human. Brett ACCEPTED that residual (distinguishing the + two needs the xForge-host identity work deferred under D22) and required the + event to be TAGGED WITH THE FACTS WE ACTUALLY KNOW instead: the OPTIONAL + `provenance` block names the SURFACE the action arrived on (`http` | `cli`) + and HOW the console-presence test was satisfied (`console-token` | `tty` | + `declared`). That converts an accepted-but-invisible residual into an + AUDITABLE one — if a process ever does act as the human, the record says which + door it came through. + + `provenance` is OPTIONAL and NO conditional requires it, for ANY action — + deliberately, and this absence is a DECISION exactly like the + `create-document`/`commit` absence below. Gate-action records written BEFORE + this growth already exist: the `dispose-possible` records committed in this + corpus under `ideation/dashboard/gate-records/`, and every record the + realization under review on codexFactory PR #49 has already emitted, whose + per-gate-action commit series on a session branch is FDA TRACEABILITY EVIDENCE + under design D18. A conditional requiring `provenance` would invalidate those + records retroactively — a schema that rejects the evidence it exists to govern + destroys exactly what the strictness would be for. The obligation is a ROUTE + obligation: the CODE always emits `provenance`, and a codexFactory test pins + that. A later change MAY make it conditionally required once no pre-growth + record is live evidence. The growth precedent is this schema's own — the + `create-document` action enum value and the first-class `document` artifact + kind, both grown here on 2026-07-25 under Brett's ruling, and the three + branch-session actions grown on 2026-07-26. + + VALIDATOR-SIDE (cross-instance, not shape-expressible): kickoff is + downstream of the ratify gate (D17) — the console MUST refuse a `kickoff` + whose target change carries no recorded ratification (spec scenario "A + non-ratified change is asked for kickoff"). A gate-action record shapes a + single action; whether the target already carries a ratify record is a + console/validator precondition across records, not a property of this one. + + Forward-compatible / additive: consumers MUST ignore unknown properties. + A later delta may add new action kinds or artifact kinds without a + `schema_version` bump; only a breaking change (a removed or retyped + field) requires one. This is why no object here sets + `additionalProperties: false`. +type: object +required: + - schema_version + - kind + - actor + - action + - target + - at + - artifacts +properties: + schema_version: {const: 1} + # Normative kind literal from D16 ("gate-action record"); hyphenated per + # the design decision, not the underscored `xfactory_*` in-file kind + # convention (same divergence as the sibling dashboard/workbench/register + # schemas). + kind: {const: gate-action-record} + actor: + type: string + minLength: 1 + # Human identity string (spec: "executable by a human only"). This + # schema only records WHO acted; that no agent path can ever reach + # this field is enforced by the console/authenticator, not here. + action: + type: string + enum: [demote, edit-apply, ratify, kickoff, dispose-possible, propose, + lens-save-recipe, lens-add-as-cluster, create-document, + edit-document, open-pr, abandon-session, promote-to-staging, + derive-possibles, research-brief, create-project, edit-project, + share-session, cleanup-abandoned-branch, approve-model] + # The gate-console actions (spec "Human gate console" + D17 kickoff): + # reject-to-staging, AI-redline-apply, approve/ratify, post-ratification + # workflow dispatch, and — add-possibles-derivation-lane ("One-way + # derived-possible disposition on the gate console") — the human + # disposition of a pending_review ai-derived possible (ADDITIVE enum + # extension; every prior record stays valid). `propose` + # (add-propose-verb) commissions proposal authoring for a staging + # topic: kickoff's recorded-dispatch mechanic at the staging->proposal + # boundary (ADDITIVE, same posture). `create-document` + # (add-workbench-bullseye-and-create) brings a NEW ideation document + # into existence through the tested authoring scaffold, create-only — + # an existing target refuses as a source-edit and is never overwritten, + # so this action carries NO edit or delete authority. It targets + # `document` (allOf below) and references the created document as a + # `document`-kind artifact (ADDITIVE enum extension; every prior record + # stays valid). The three BRANCH-SESSION actions + # (add-workbench-branch-sessions, D13) close the set: `edit-document` + # rewrites an EXISTING document inside the session worktree — session-only, + # no create and no delete authority, and no per-edit redline, because the + # pull-request review is the governance; `open-pr` pushes the session + # branch and opens or updates the pull request into the existing + # Merge-Master ritual, dispatching and never deciding (it cannot merge, + # approve, self-review, or bypass protection, and consults NO readiness + # signal — D21); `abandon-session` ends a session without saving and + # carries the durable "why", exactly as a demotion does. All three are + # ADDITIVE enum extensions; every prior record stays valid. The three + # `add-wheel-action-verbs` values are also ADDITIVE recorded commissions: + # they require a workflow-job below but add no authoring or disposition + # authority to the record. `create-project` + # (add-project-scoped-selection) is the same commission mechanic one + # register over: it targets a `project_id` and requires its workflow-job + # descriptor (workflow `project-register-edit`); the aggregation-owned + # project register is edited only by the commission's fulfilment, never + # by the recording surface (ADDITIVE; every prior record stays valid). + # `edit-project` (add-opendox-project-header) is that mechanic for an + # EXISTING project's membership — the descriptor carries the added and + # removed member lists; same boundary, same companion rule (ADDITIVE). + # `share-session` (add-doxbench-editing-phase-b §12) hands a live workbench + # session to a colleague: it commits the session's DIRTY thread sidecars and + # PUSHES the session branch, so the threads a human has been working in stop + # being local-only. It is deliberately STRICTLY LESS than `open-pr` — it + # reuses that verb's existing remote-write path, opens no pull request, + # requests no review, and holds no approval or merge authority — and it is + # the reason threads are local until a human says otherwise: a working note + # that leaves the machine without an explicit act is a disclosure nobody + # chose (ADDITIVE; every prior record stays valid). + # `approve-model` (add-doxchat-model-intake §3, contract-v1.45) is the act + # that makes a model DECLARED through the console's intake flow AVAILABLE for + # governed turns. It exists because "approved" was previously not a record at + # all: availability was the conjunction of three runtime facts (the entry is + # in the catalog the install handed to the model port, its `available` flag + # is true, and the console passed the loopback local-human verdict), with no + # approver, no instrument and nothing written down. That is tolerable while + # the only way to add a model is to edit a settings document, since whoever + # can do that IS the operator; it stops being tolerable the moment a wizard + # can, because then the act of entering a payment credential silently becomes + # the act of approving a provider for governed work. Those are two decisions + # and this action is the second one. It targets `model_declaration` and + # carries the `model_approval` block below — issuer, approver, expiry and + # audit reference, the accountability `credential-contracts` already demands + # of an issued grant (ADDITIVE enum extension; no record has ever carried + # this action, so nothing pre-existing is narrowed). + target: {$ref: "#/$defs/target"} + provenance: {$ref: "#/$defs/provenance"} + at: + type: string + format: date-time + # Wall-clock stamp of the action; this is a live audit record, not a + # deterministic projection (contrast `ideation-dashboard-snapshot`). + artifacts: + type: array + minItems: 1 + # Every action produces at least the manual path's artifacts (spec) — + # an action record with no artifact is invalid. + items: {$ref: "#/$defs/artifact"} + cleanup: + $ref: "#/$defs/cleanup" + model_approval: + $ref: "#/$defs/model_approval" + # REQUIRED for action=approve-model (allOf below) and carried by no other + # action. OPTIONAL at the record level and required by no unconditional + # rule, which is what keeps this extension additive: no record has ever + # carried this block, so nothing pre-existing is narrowed and every + # committed record stays valid. + reason: + type: string + minLength: 1 + # REQUIRED for action=demote and for a rejected dispose-possible (allOf + # below) — an unreasoned demotion or rejection is invalid; optional + # narrative for other actions. + citation: + type: string + minLength: 1 + # REQUIRED for a rejected dispose-possible (allOf below) — an uncited + # rejection is invalid, mirroring the register state machine's own + # rejected-entry rule; optional otherwise. + notes: + type: string + minLength: 1 + # Free-text human narrative; never load-bearing for validation. +allOf: + # A demotion without a recorded reason is invalid (task 2.8). + - if: {properties: {action: {const: demote}}} + then: {required: [reason]} + # The four change-lifecycle actions target a change (the original + # strictness, preserved per-action now that `target` also serves the + # possible-disposition action). + - if: {properties: {action: {enum: [demote, edit-apply, ratify, kickoff]}}} + then: {properties: {target: {required: [change_id]}}} + # A dispose-possible record targets a register possible with a recorded + # outcome and MUST reference the register-update artifact it produced + # (the updated cross-reference index). + - if: {properties: {action: {const: dispose-possible}}} + then: + properties: + target: {required: [possible_id, outcome]} + artifacts: + contains: + required: [kind] + properties: {kind: {const: register-update}} + # A rejected disposition without reason AND citation is invalid (the + # register kernel's uncited-rejection rule, surfaced on the audit record). + - if: + properties: + action: {const: dispose-possible} + target: + properties: {outcome: {const: rejected}} + required: [outcome] + required: [action, target] + then: {required: [reason, citation]} + # A ratify record MUST reference at least one ratification-record artifact + # (single-instance contains; the gate console is authority-bearing, D16). + - if: {properties: {action: {const: ratify}}} + then: + properties: + artifacts: + contains: + required: [kind] + properties: {kind: {const: ratification-record}} + # A kickoff record MUST reference at least one workflow-job artifact + # (post-ratification realization dispatch, D17). + - if: {properties: {action: {const: kickoff}}} + then: + properties: + artifacts: + contains: + required: [kind] + properties: {kind: {const: workflow-job}} + # A propose record targets a staging topic and MUST reference the + # workflow-job descriptor it dispatched (add-propose-verb: commissioning, + # never authoring). + - if: {properties: {action: {const: propose}}} + then: + properties: + target: {required: [topic_id]} + artifacts: + contains: + required: [kind] + properties: {kind: {const: workflow-job}} + # The three wheel generative actions are commissions, never the work. Each + # therefore targets its governed source and MUST reference the workflow-job + # descriptor it dispatched (`add-wheel-action-verbs`, design D2). + - if: {properties: {action: {const: promote-to-staging}}, required: [action]} + then: + properties: + target: {required: [possible_id]} + artifacts: + contains: + required: [kind] + properties: {kind: {const: workflow-job}} + - if: {properties: {action: {const: derive-possibles}}, required: [action]} + then: + properties: + target: {required: [cluster_id]} + artifacts: + contains: + required: [kind] + properties: {kind: {const: workflow-job}} + - if: {properties: {action: {const: research-brief}}, required: [action]} + then: + properties: + target: {required: [possible_id]} + artifacts: + contains: + required: [kind] + properties: {kind: {const: workflow-job}} + # A create-project record is the same commission shape against the project + # register (`add-project-scoped-selection`): it targets the slugged + # project id and MUST reference the workflow-job descriptor it dispatched. + - if: {properties: {action: {const: create-project}}, required: [action]} + then: + properties: + target: {required: [project_id]} + artifacts: + contains: + required: [kind] + properties: {kind: {const: workflow-job}} + # An edit-project record (`add-opendox-project-header`) is that shape for + # an existing project's membership edit. + - if: {properties: {action: {const: edit-project}}, required: [action]} + then: + properties: + target: {required: [project_id]} + artifacts: + contains: + required: [kind] + properties: {kind: {const: workflow-job}} + # A create-document record names the document it brought into existence + # (add-workbench-bullseye-and-create): a create record that does not name + # the created path audits nothing. Safe as a per-action conditional — no + # record has ever carried this action, so no prior record is invalidated. + # The created document rides as a `document`-kind artifact with the + # relpath as its `reference` (Brett's 2026-07-25 ruling on that change's + # open question 4 grew the artifact enum a first-class `document` value + # instead of overloading `other`), and that kind is REQUIRED here — a + # create record whose artifact list names no document is not an audit + # record of a creation. Unlike the enum growth itself this conditional is + # only safe per-action, which it is. + - if: {properties: {action: {const: create-document}}} + then: + properties: + target: {required: [document]} + artifacts: + contains: + required: [kind] + properties: {kind: {const: document}} + # --- the three BRANCH-SESSION conditionals (add-workbench-branch-sessions, + # D13). Each constrains ONLY its own NEW action, which is exactly why each is + # safe: no record has ever carried `edit-document`, `open-pr`, or + # `abandon-session`, so nothing pre-existing is narrowed and no + # `contract_schema_version` bump is owed. + # + # What deliberately does NOT appear here, stated because its absence is a + # DECISION and not an omission: the commit-per-gate-action rule is NOT a + # conditional on `create-document`. That action pre-dates branch sessions and + # is legitimately performed OUTSIDE one — against the served checkout, where + # no commit is produced — so requiring a `commit` artifact for it would + # invalidate the non-session case and narrow a PRE-EXISTING action, the one + # thing this schema's additive posture forbids. The rule therefore lives in + # the requirement and is enforced at the route, and `target.ref` stays + # OPTIONAL for the same reason even though the route populates it for every + # in-session action, `create-document` included. + # + # An `edit-document` record names the document it rewrote, the session branch + # it rewrote it on, and the commit it rode: all three, or it audits nothing. + # The `commit` artifact's `reference` is this record's own action stamp, not a + # sha — resolution is the introduced-by rule stated in the description. + - if: {properties: {action: {const: edit-document}}} + then: + properties: + target: {required: [document, ref]} + artifacts: + contains: + required: [kind] + properties: {kind: {const: commit}} + # An `open-pr` record names the session branch it pushed and the pull request + # it opened or updated. NO `commit` artifact is required — the record is + # main-resident and this action adds no commit to the branch. + - if: {properties: {action: {const: open-pr}}} + then: + properties: + target: {required: [ref]} + artifacts: + contains: + required: [kind] + properties: {kind: {const: pull-request}} + # An `abandon-session` record names the branch it abandoned and WHY — the + # unreasoned-demotion rule applied to the other ending. No artifact KIND is + # required: this action produces no commit and no pull request, and the branch + # it names is carried as an `other`-kind artifact purely to satisfy + # `artifacts.minItems`. The record is main-resident precisely so it OUTLIVES + # the branch the later cleanup may delete. + - if: {properties: {action: {const: abandon-session}}} + then: + required: [reason] + properties: + target: {required: [ref]} + # Cleanup is the explicit, local-only ending after an abandon. Its evidence + # block is validated before deletion and names exactly one tile scope. + - if: {properties: {action: {const: cleanup-abandoned-branch}}} + then: + required: [cleanup] + properties: + target: + required: [ref] + oneOf: + - required: [topic_id] + - required: [cluster_id] + - required: [possible_id] + - if: + properties: + action: {const: cleanup-abandoned-branch} + cleanup: + properties: + retention_release: + properties: {kind: {const: explicit-human-release}} + required: [kind] + required: [retention_release] + required: [action, cleanup] + then: {required: [reason]} + # A `share-session` record (add-doxbench-editing-phase-b §12) names the session + # branch it PUSHED — that branch ref IS the pushed ref, because the verb + # publishes `refs/heads/` and nothing else. ADDITIVE: no record has + # ever carried this action, so nothing pre-existing is narrowed. + # + # NO artifact kind is required, and that is a DECISION rather than an + # oversight, for the same reason `abandon-session` requires none: this action + # has TWO RESIDENCIES and only one of them produces a commit. + # + # * With DIRTY thread sidecars to publish, the action commits them WITH this + # record as exactly one commit on the session branch (FR-006's rule, met by + # `commit_gate_action`), so the record is BRANCH-RESIDENT and does carry a + # `commit` artifact. It rides the branch to the colleague, which is a + # virtue: they can see why those threads landed. + # * With nothing dirty but commits the remote has not seen — the ordinary + # state after a run of Saves, since NOTHING pushes implicitly — the action + # commits nothing at all. FR-006's own guard refuses an empty declared set + # ("an action that produced no document has nothing to co-commit"), so the + # record is MAIN-RESIDENT here, exactly as `open-pr`'s is. + # + # Requiring a `commit` artifact would therefore invalidate the second, which is + # the COMMON case, and requiring `pull-request` would be a lie: this verb opens + # none, requests no review, and holds no approval or merge authority. + - if: {properties: {action: {const: share-session}}} + then: + properties: + target: {required: [ref]} + # An `approve-model` record (add-doxchat-model-intake §3, contract-v1.45) NAMES + # THE DECLARATION IT APPROVED and CARRIES THE GRANT'S ACCOUNTABILITY. Both + # halves are required together and neither is useful alone: a record that did + # not name the declaration could not be read back against the model it made + # available, and one that named it without an issuer, an approver, an expiry + # and an audit reference would answer "who approved this model, when, and under + # what authority" with silence — which is the same question + # `credential-contracts` already forces every issued grant to answer, and the + # reason this action exists rather than a bare `available: true` somewhere. + # + # NO artifact kind is required, and that is a DECISION rather than an omission. + # This action produces no commit, no document, no pull request and no + # workflow-job: the approval's own artifact IS this record, and the settings + # document it unblocks is written after the record exists. The audit reference + # rides `model_approval.audit_ref` where a reader can read it as the fact it + # is, rather than as an `other`-kind artifact whose meaning a consumer would + # have to guess. `abandon-session` and `share-session` set the precedent for an + # action that requires none. + - if: {properties: {action: {const: approve-model}}, required: [action]} + then: + required: [model_approval] + properties: + target: {required: [model_declaration]} +$defs: + # --- what the action acted on --- + target: + type: object + # Per-action required fields live in the allOf conditionals above: the + # four change-lifecycle actions require `change_id`; `dispose-possible` + # requires `possible_id` + `outcome`; the wheel commissions require + # `possible_id`, `cluster_id`, and `possible_id` respectively; + # `edit-document` requires `document` + `ref`; `open-pr`, + # `abandon-session` and `share-session` require `ref`. (The + # unconditional `change_id` requirement moved into the conditional when the + # disposition action landed — every previously valid record stays valid.) + properties: + change_id: + type: string + minLength: 1 + # The proposal/change this action targets (matches a snapshot + # `change.id`). + possible_id: + type: string + minLength: 1 + # The possibles-register entry a dispose-possible, + # promote-to-staging, or research-brief action targets (matches a + # `register_entry.id`). + cluster_id: + type: string + minLength: 1 + # The cross-reference cluster a derive-possibles action scopes. The + # console revalidates its existence against the pinned snapshot; this + # schema records the target identity only. + project_id: + type: string + minLength: 1 + # The project-register project a create-project action commissions + # (add-project-scoped-selection; ADDITIVE — every prior record stays + # valid). Slugged from the proposed name at commission time; the + # register edit itself is the commission's fulfilment. + set: + type: string + minLength: 1 + # The workbench reference-set slug a lens verb lands + # (add-lens-gate-verbs; ADDITIVE — every prior record stays + # valid): lens-save-recipe writes the manifest of this set, + # lens-add-as-cluster additionally submits its pending_review + # human-seen entry. + topic_id: + type: string + minLength: 1 + # The staging topic a propose action commissions (matches a + # `ideation/staging//` directory / staged tile + # `staging_id`). + outcome: + type: string + enum: [accepted, rejected, deferred] + # The human verdict a dispose-possible action recorded (mirrors + # `derivation_human_disposition.outcome` in the register kernel). + document: + type: string + minLength: 1 + # A repository-relative document path this action acted on. Three + # senses, all structurally unrestricted by action: the document + # WITHIN a change that an `edit-apply` redlined; REQUIRED for + # `create-document` (add-workbench-bullseye-and-create; allOf + # above) — the path the create-only authoring scaffold brought into + # existence; and REQUIRED for `edit-document` + # (add-workbench-branch-sessions; allOf above) — the path the + # session-only rewrite replaced inside the session WORKTREE, relative + # to the worktree root rather than to the served checkout. Broadened + # commentary only; the field, its type, and its optionality are + # unchanged, so every prior record stays valid. + model_declaration: + type: string + minLength: 1 + # The MODEL DECLARATION an `approve-model` action approved + # (add-doxchat-model-intake §3; ADDITIVE — every prior record stays + # valid). Its value is the model-provider BINDING id the declaration + # names, which is also the catalog handle a turn would then select, so + # one identifier resolves the whole chain: this record, the declaration + # in the install's settings document, the binding that names the broker + # and the credential REFERENCE, and the catalog entry the human picks. + # It is deliberately NOT the credential reference and deliberately NOT a + # provider account identifier: a governance record names the thing that + # was decided about, and the credential itself lives in the broker's + # custody where this record cannot reach it. + ref: + type: string + minLength: 1 + # The git ref — the SESSION BRANCH — a branch-session action acted on + # (add-workbench-branch-sessions, D13; ADDITIVE — every prior record + # stays valid). REQUIRED for `edit-document`, `open-pr`, and + # `abandon-session` (allOf above) and OPTIONAL everywhere else, on + # purpose: `create-document` pre-dates sessions and is legitimately + # invoked outside one, where there is no ref to name, so an + # unconditional requirement would invalidate every existing record and + # narrow a pre-existing action. Inside a session the ROUTE populates it + # for every action — a session record that does not name its branch + # audits nothing — which is a route obligation, not a schema one. + # --- which gateway the action arrived through (add-workbench-branch-sessions, + # D23; Brett's 2026-07-27 ruling). OPTIONAL at the record level and required by + # NO conditional — see the description's GATEWAY PROVENANCE paragraph for why + # (pre-growth records already exist, in this corpus and among those PR #49's + # realization has emitted, and must not be invalidated retroactively; that + # commit series is FDA-traceability evidence under design D18). Both fields are + # required WITHIN the block, which is safe for + # the same reason the per-action conditionals are: no record has ever carried + # this block, so a half-filled provenance has never been written. A record + # WITHOUT `provenance` stays valid; a record WITH it must say both things or it + # tags nothing. + provenance: + type: object + required: [surface, console_presence] + properties: + surface: + type: string + enum: [http, cli, intent-plane] + # The gateway the invocation arrived on: the loopback HTTP console, or + # the CLI parity verb. Recorded because the two doors have different + # presence tests and different residuals, and an auditor asking "how did + # this happen" needs the door named. + console_presence: + type: string + enum: [console-token, tty, declared, ingress-auth] + # HOW the console-presence test was satisfied: the per-serve console + # token issued at `/capabilities` (`console-token`, the HTTP door), an + # interactive terminal (`tty`), or an explicit operator declaration + # (`declared`, e.g. `XF_HUMAN_CONSOLE=1`). There is deliberately NO + # value meaning "not shown": an invocation that cannot demonstrate + # console presence is REFUSED before a record is written, so no record + # can honestly carry one. `declared` is the WEAKEST of the three and is + # named as its own value rather than folded into the others precisely so + # an audit consumer can filter for it. `intent-plane` + `ingress-auth` + # (ADDITIVE growth, add-ideation-intent-plane task 4.3): the apply lane + # replaying a gate-intent whose actor the inbox stamped from the + # ingress-authenticated identity — the record names the third door. + # --- the grant accountability an approved model answers in + # (add-doxchat-model-intake §3, contract-v1.45). REQUIRED for `approve-model` + # by the conditional above and carried by no other action, so no pre-existing + # record is narrowed. Every field is required WITHIN the block, which is safe + # for the same reason `provenance`'s two are: no record has ever carried this + # block, so a half-filled approval has never been written — and a half-filled + # one is precisely the shape that would let a reader believe a model was + # answerable when it was not. + # + # WHY THESE FOUR FIELDS AND NOT A NEW VOCABULARY: `credential-contracts` + # already holds that a grant template lacking `issued_by`, `approved_by`, + # `expires_at` or `audit_ref` is invalid. A model added through a credential + # broker is a credential-bearing capability, so it is answerable in the same + # terms — respelling them here would have made the same fact unmatchable + # across the two families. + model_approval: + type: object + required: [issued_by, approved_by, expires_at, audit_ref, install_posture] + properties: + issued_by: + type: string + minLength: 1 + # WHO PUT THE CREDENTIAL INTO CUSTODY — the principal the broker + # recorded as the issuer of the reference this model's binding names. + # Distinct from `approved_by` on purpose: enrolling a subscription and + # approving a provider for governed work are two decisions, and the whole + # reason this action exists is that a wizard would otherwise collapse + # them into one. + approved_by: + type: string + minLength: 1 + # WHO APPROVED IT for this console. Normally equal to the record's own + # `actor`; kept as its own field because `actor` records who performed + # the ACT and this field records whose AUTHORITY the model runs under, + # and a console that later gains delegation would need those to be able + # to differ without re-shaping the record. + expires_at: + type: string + format: date-time + # WHEN THE APPROVAL STOPS BEING GOOD. An approval with no expiry is a + # standing grant nobody re-examines, which is the posture + # `credential-contracts` refuses for every other grant in this + # workspace. Derived from a producer-side policy constant rather than + # taken from the requester: an expiry the requester chooses is an expiry + # that is always far away. + audit_ref: + type: string + minLength: 1 + # THE AUDIT REFERENCE the custody act is keyed by — the reference the + # broker returned when it took the credential. Disclosable BY + # CONSTRUCTION: the broker's declaration records no token material + # against an audit reference (not the token, not a prefix, not a hash), + # which is what makes it safe for a governance record and safe to show a + # human. It is a REFERENCE and never the credential; a value that looked + # like a raw secret would be caught by the credential validator's own + # `sk-`/`ghp_`/`-----BEGIN` refusal. + install_posture: + type: string + enum: [single-operator, shared] + # WHICH INSTALL THE APPROVAL WAS MADE ON, and therefore which rule it + # was made under (Brett's OQ-3 ruling, 2026-08-21, which splits BY + # INSTALL). A recorded gate action suffices on a single-operator + # loopback console — the human is spending their own subscription on + # their own corpus, and requiring a consent instrument for that is + # ceremony without a second party. Where the model is enrolled on a + # tenant or shared install, or where a turn will process another party's + # material, a consent instrument is REQUIRED and `consent_ref` names it + # (conditional below). The posture is STATED rather than inferred from + # the presence of `consent_ref`, because a record whose rule has to be + # reconstructed from which fields it happens to carry cannot be read + # back against the ruling it was made under. + consent_ref: + type: string + minLength: 1 + # The `consent-instrument` this approval was taken under. REQUIRED on a + # `shared` posture and FORBIDDEN on a `single-operator` one — both + # halves, because "only that case carries `consent_ref`" is what the + # ruling says, and a single-operator record that carried one would claim + # a second party that does not exist. + allOf: + - if: + required: [install_posture] + properties: {install_posture: {const: shared}} + then: {required: [consent_ref]} + - if: + required: [install_posture] + properties: {install_posture: {const: single-operator}} + then: {not: {required: [consent_ref]}} + cleanup: + type: object + required: [status, pre_delete_head, abandonment, retention_release] + properties: + status: + type: string + enum: [prepared, completed] + pre_delete_head: + type: string + pattern: "^[0-9a-f]{40,64}$" + abandonment: + type: object + required: [kind, reference, summary] + properties: + kind: + type: string + enum: [abandon-session-record, abandon-ending-marker] + reference: {type: string, minLength: 1} + summary: {type: string, minLength: 1} + retention_release: + type: object + required: [kind, scope_kind, scope_id, references] + properties: + kind: + type: string + enum: [active-proposal, archived-change, executed-demotion, + legacy-executed-demotion, explicit-human-release] + scope_kind: + type: string + enum: [staged-topic, cluster, possible] + scope_id: {type: string, minLength: 1} + change_id: {type: string, minLength: 1} + references: + type: array + uniqueItems: true + items: {type: string, minLength: 1} + reason: {type: string, minLength: 1} + superseding_references: + type: array + uniqueItems: true + items: {type: string, minLength: 1} + recorded_at: {type: string, format: date-time} + allOf: + - if: + properties: {kind: {const: explicit-human-release}} + required: [kind] + then: {required: [reason]} + - if: + properties: + kind: + enum: [active-proposal, archived-change, executed-demotion, + legacy-executed-demotion] + required: [kind] + then: + required: [change_id, recorded_at] + properties: + references: {minItems: 1} + # --- an artifact this action produced --- + artifact: + type: object + required: [kind, reference] + properties: + kind: + type: string + enum: + - transition-manifest + - ratification-record + - redline + - workflow-job + - register-update + - document + - commit + - pull-request + - other + # The manual-path artifact kinds an action can produce (spec: "the + # same governed artifacts as the manual path"). SCHEMA-SIDE (allOf + # above): a `ratify` record requires a `ratification-record` entry + # and a `kickoff` record requires a `workflow-job` entry. + # + # `document` — an ideation/corpus document this action brought into + # existence or rewrote, referenced by repository-relative path + # (add-workbench-bullseye-and-create; Brett's 2026-07-25 ruling on + # that change's design open question 4, which rejected carrying a + # created document as `other`). ADDITIVE enum extension in the + # `add-lens-gate-verbs` shape: no `contract_schema_version` bump, and + # every prior record stays valid because no record is required to + # carry this kind — an audit consumer can now filter document- + # producing actions from the enum rather than only from `action`. + # + # `commit` — the SINGLE session commit a file-producing gate action + # rode, carrying that action's documents AND this record together + # (add-workbench-branch-sessions, D4/D13). Its `reference` is the + # record's own ACTION STAMP and never a sha: a commit cannot contain + # its own sha, so resolution is DEFINED as the commit that INTRODUCED + # the record file on the session branch. REQUIRED on an + # `edit-document` record (allOf above) and NEVER required of + # `create-document`, whose non-session use produces no commit (D13). + # + # `pull-request` — the pull request an `open-pr` action opened or + # updated, referenced by URL: the formal re-entry of a session's work + # into the governed doc system, dispatched into the existing + # Merge-Master ritual and decided there, never here. REQUIRED on an + # `open-pr` record (allOf above). + # + # Both are ADDITIVE enum extensions in the same shape as `document`, + # and both are FIRST-CLASS kinds rather than `other` on the precedent + # Brett set on 2026-07-25 — an audit consumer should be able to filter + # session commits and session pull requests from the artifact + # vocabulary and not only from `action`. No `contract_schema_version` + # bump: no pre-existing record is required to carry either kind. + reference: + type: string + minLength: 1 + # Path or id locating the produced artifact. diff --git a/src/openxdox/contracts/schemas/ideation-dashboard-snapshot-index.schema.yaml b/src/openxdox/contracts/schemas/ideation-dashboard-snapshot-index.schema.yaml new file mode 100644 index 0000000..c259ba5 --- /dev/null +++ b/src/openxdox/contracts/schemas/ideation-dashboard-snapshot-index.schema.yaml @@ -0,0 +1,163 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "ideation-dashboard-snapshot-index.schema.yaml" +title: "Ideation-dashboard snapshot index: the (repository, ref) locator" +contract_schema_version: 1 +description: >- + The thin LOCATOR that enumerates which ideation-dashboard snapshots exist and + where each one lives (`add-dashboard-repo-selector`; spec requirement + "Snapshot index contract"; design decision D3). One entry per available + (repository, ref) pair, each naming its repository, its ref, the snapshot's + location, the `source_revision` that snapshot projects, and the generated-at + stamp derived from that revision. The repository selector renders this index, + a refresh re-fetches it, and every renderer discovers available snapshots + ONLY through it. + + ITS OWN CONTRACT, NOT A SNAPSHOT FIELD (D3). An index describes a SET of + snapshots; a snapshot (`ideation-dashboard-snapshot.schema.yaml`) describes ONE + repository's corpus at ONE revision. Folding the index into the snapshot would + make every snapshot carry facts about its siblings, which breaks the snapshot's + determinism property — the same working tree MUST yield a byte-identical + snapshot, and sibling facts change without that tree changing. Keeping the + index separate also keeps it CHEAP to fetch often while the snapshots stay + large and stable, which is exactly the traffic shape a refresh wants. + + KEYED ON THE PAIR (repository, ref), `ref` DEFAULTING TO `main` (D4). A + CONSUMER that names no ref means `main`; an index ENTRY always states its ref + explicitly, so the artifact never leaves the key implicit (only an + `aggregates[].members` entry may omit it, where absent means `main`). The + served plane exercises only `(repository, main)`: a snapshot + generated for any other ref is session-local derived data that MUST NEVER be + published to a data source and MUST NEVER appear in a published index (spec + scenario "A non-main snapshot is offered for publication"). The key is carried + from the first release anyway so a later session-scoped or runtime-plane + consumer binds to this locator without it being re-cut. + + WHAT THIS ARTIFACT MUST NOT CARRY: no document, cluster, possible, staged-topic, + change, keyword, or funnel data of any kind, and no readiness, completeness, or + tally derived from them. It is a locator, not a projection — that data belongs + to the snapshot the entry locates (spec scenario "The index is asked to carry + projection data"). Grouping (`project`/`project_group`) is likewise NOT carried + here: the project register is the grouping roster and renderers read the + resolved grouping from the SNAPSHOT, never from a second list. + + VALIDATOR-SIDE RULES this shape cannot express (enforced by + opensoft/openXdox-code's `scripts/validate-ideation-dashboard-contracts.py`, and these comments ARE the + requirements): + * every (repository, ref) pair is UNIQUE within one index — two entries for + the same pair make "which snapshot is this repository's?" ambiguous; and + * no projection data anywhere in the index — a `documents`, `clusters`, + `possibles`, `staged_topics`, `changes`, or `keyword_index` key, at the + root or inside an entry, is refused (the locator-not-projection rule). + + DETERMINISM. Like the snapshot, this artifact carries no wall clock: an + entry's `generated_at` is derived from its `source_revision`'s commit date (or + omitted, exactly as the snapshot omits it outside a git checkout), so a + republished index diffs only when a snapshot it locates actually moved. + + Forward-compatible / additive: consumers MUST ignore unknown properties, so no + object here sets `additionalProperties: false` and additive growth needs no + `contract_schema_version` bump — only a breaking change (a removed or retyped + field) does. This is the ideation-dashboard family posture. +type: object +required: + - schema_version + - kind + - entries +properties: + schema_version: {const: 1} + # Normative kind literal; hyphenated like its sibling snapshot/register kinds, + # not the underscored `xfactory_*` in-file convention. + kind: {const: ideation-dashboard-snapshot-index} + generated_at: + type: string + format: date-time + # OPTIONAL index-level stamp, derived from the newest entry's revision date — + # never the process clock (see the determinism note above). + entries: + type: array + # MAY be empty (no snapshot published yet): an empty index is the honest + # bootstrap state, and a renderer offering no repository is a truthful + # renderer. Each present entry locates exactly one (repository, ref). + items: {$ref: "#/$defs/entry"} + aggregates: + type: array + # OPTIONAL composed views: an aggregate names the member (repository, ref) + # pairs a cross-repository selection composes FROM, so a composing renderer + # reads the index and the snapshots it names and never scans a repository + # (spec scenario "An aggregate selection is rendered"). Still pure locator + # data — an aggregate carries no projection of its own. The RENDERING shape + # of such a selection (merged funnel with repository badges, drill-in from a + # roll-up, or counts-only overview) is deliberately NOT fixed by this + # contract: it is the staged topic's open question 1. + items: {$ref: "#/$defs/aggregate"} +$defs: + # --- one available snapshot, addressed by the (repository, ref) pair --- + entry: + type: object + required: [repository, ref, snapshot, source_revision] + properties: + repository: + type: string + minLength: 1 + # Canonical repository id — the same id the located snapshot carries in + # its own `repository` field and the id the project register names. + ref: + type: string + minLength: 1 + # The git ref the located snapshot projects. `main` for every published + # entry; a non-`main` ref marks session-local derived data that is never + # published (see the keying note above). + snapshot: + type: string + minLength: 1 + # WHERE the snapshot is, relative to this index (a sibling filename or a + # relative path — never an absolute filesystem path and never a host, so + # one index serves a raw-file tree, a blob prefix, and a local directory + # unchanged; the data source is configuration, not contract). + source_revision: + type: string + minLength: 1 + # The revision the located snapshot projects — the SAME value that + # snapshot's `generation.source_revision` carries. The index never + # restates it differently: provenance is one fact in two places, and a + # mismatch is a publication bug. + generated_at: + type: string + format: date-time + # Derived from `source_revision`'s commit date, never the wall clock; + # omitted when the snapshot itself omits it. This is the stamp the + # freshness header and the stale banner display. + display_name: + type: string + minLength: 1 + # Optional human label for the selector; `repository` remains the key. + # --- a composed cross-repository selection (locator only) --- + aggregate: + type: object + required: [id, members] + properties: + id: + type: string + minLength: 1 + # Stable id of the composed view (for example the aggregation + # repository's own id, `xFactory`, for the all-submodules dev view). + display_name: + type: string + minLength: 1 + members: + type: array + minItems: 1 + # The (repository, ref) pairs this view composes from. Each member + # SHOULD name an `entries` pair present in the same index; a member that + # names no available entry composes without it rather than failing the + # view (the same degrade-not-refuse rule as an unfetchable entry). + items: {$ref: "#/$defs/member"} + member: + type: object + required: [repository] + properties: + repository: {type: string, minLength: 1} + ref: + type: string + minLength: 1 + # Absent means `main` (D4's default applies to members too). diff --git a/src/openxdox/contracts/schemas/ideation-dashboard-snapshot.schema.yaml b/src/openxdox/contracts/schemas/ideation-dashboard-snapshot.schema.yaml new file mode 100644 index 0000000..edbecfa --- /dev/null +++ b/src/openxdox/contracts/schemas/ideation-dashboard-snapshot.schema.yaml @@ -0,0 +1,469 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "ideation-dashboard-snapshot.schema.yaml" +title: "Ideation-dashboard deterministic projection snapshot" +contract_schema_version: 1 +description: >- + The single data path for the ideation-area dashboard (`add-ideation-dashboard`; + promoted spec requirements "Snapshot projection contract", "Realization funnel + model", "Project grouping hierarchy", "Cluster canvas working surface", and + "Keyword lens set-builder"). A deterministic generator scans `ideation/` plus + active and archived OpenSpec changes for ONE repository and emits this + snapshot; every renderer reads only the snapshot, never the repository + directly (spec scenario "A renderer needs data the snapshot lacks"). The + snapshot is a projection, never a source of truth: when it disagrees with the + repository the resolution is regeneration and dashboard artifacts are never + hand-edited (spec scenario "The dashboard disagrees with the repository"). + + Deterministic: the same working tree MUST yield a byte-identical snapshot + (spec scenario "The generator runs twice on the same tree"), so the envelope + carries no wall-clock field — the generation stamp is the tree-derived + `source_revision`, and any `generated_at` is derived from that revision's + commit date, never the process clock. + + Forward-compatible / additive: consumers MUST ignore unknown properties. A new + view acquires data by delta declaring new snapshot fields and the generator + populating them; because unknown fields are tolerated, additive growth needs + no `schema_version` bump and only a breaking change (a removed or retyped + field) does. This is why — unlike the immutable `xfactory-document-catalog` + record — no object here sets `additionalProperties: false`. Fields fed by + not-yet-landed upstream changes are optional until those changes land: cluster + `readiness`/`conflict_flags` arrive verbatim from the ideation-cross-reference + index (add-ideation-cross-reference-readiness); document `inferred_topics` and + keyword `inferred_doc_count` arrive from document-cataloging; document + `completeness` (a deterministic per-document maturity signal) and staged-topic + `health` (a folder-scoped health aggregate derived from it) grow from + add-staging-workbench — the generator realization populates both, this schema + growth lands first. The regenerated projection carries no lifecycle `status` + (it is overwritten nightly, not an immutable `record` like the document + catalog). +type: object +required: + - schema_version + - kind + - repository + - generation + - documents + - clusters + - possibles + - staged_topics + - changes + - keyword_index +properties: + schema_version: {const: 1} + # Normative kind literal from the promoted spec ("Snapshot projection + # contract"); hyphenated per that requirement, not the underscored + # `xfactory_*` in-file kind convention. + kind: {const: ideation-dashboard-snapshot} + repository: + type: string + minLength: 1 + # Canonical repository id this snapshot projects; keeps DomainxFactory + # instances and an aggregation roll-up additive (v1 = openxFactory only). + project: + type: string + minLength: 1 + # Resolved from the project register (D10); ABSENT = ungrouped, rendered as + # its own implicit project (spec scenario "A repository is absent ..."). + # Since multi-project membership (add-project-scoped-selection, Brett's + # 2026-08-06 ruling) this is the PRIMARY project — the first project in + # register order declaring this repository — so grouped roll-ups keep + # rendering each repository under exactly one heading. + projects: + type: array + items: {type: string, minLength: 1} + # ADDITIVE (multi-project membership): EVERY project declaring this + # repository, in register order; `project` is always its first element + # when both are present. Absent = ungrouped (or a pre-growth snapshot); + # consumers needing full membership read this, never re-derive it. + project_group: + type: string + minLength: 1 + # Resolved project group for `project`; absent when ungrouped. Descriptive + # navigation only — no lifecycle or authority semantics. + generation: {$ref: "#/$defs/generation"} + documents: + type: array + items: {$ref: "#/$defs/document"} + clusters: + type: array + items: {$ref: "#/$defs/cluster"} + possibles: + type: array + items: {$ref: "#/$defs/possible"} + staged_topics: + type: array + items: {$ref: "#/$defs/staged_topic"} + changes: + type: array + items: {$ref: "#/$defs/change"} + keyword_index: + type: array + # Seeds the keyword-lens rail; per-doc match computation is renderer-side + # from each document's `topics` (spec: "Keyword lens set-builder"). + items: {$ref: "#/$defs/keyword_entry"} +$defs: + # --- shared vocabularies --- + lifecycle_status: + type: string + # The controlled `Status:` header vocabulary (openxFactory's docs/document-lifecycle.md). + # Tracks `doc_health.TAXONOMY`; a value outside this enum fails snapshot + # validation, so the two move in the same change. + enum: [brainstorm, staged, draft, ratified, standard, superseded, retired, record, projection] + evidence_pin: + type: object + # A pinned passage with cataloging-contract provenance (section ref + + # passage hash); backs the canvas evidence board (D12). + required: [document, section, passage_sha256] + properties: + document: {type: string, minLength: 1} # document `id` the passage lives in + section: {type: string, minLength: 1} # section reference within the doc + passage_sha256: {type: string, pattern: "^[0-9a-f]{64}$"} # hash of the pinned passage + # --- generation stamp ("Snapshot projection contract") --- + generation: + type: object + required: [source_revision] + properties: + source_revision: + type: string + minLength: 1 + # Git revision of the pinned checkout this snapshot projects; the + # determinism anchor and the viewer's divergence check (spec scenario + # "Snapshot and content disagree"). + generated_at: + type: string + format: date-time + # Provenance only; to preserve byte-identity it is derived from + # `source_revision`'s commit date (or omitted), never the wall clock. + generator_version: + type: string + minLength: 1 + # Generator build id; deterministic per build. + # --- source documents (funnel column 1) --- + document: + type: object + required: [id, path, stage] + properties: + id: {type: string, minLength: 1} # stable doc id (edges reference this) + path: {type: string, minLength: 1} # repo-relative path + stage: {$ref: "#/$defs/lifecycle_status"} # lifecycle Status: header + kind: {type: [string, "null"]} # Kind: header + summary: {type: [string, "null"]} # Summary: header + topics: + type: array + items: {type: string, minLength: 1} + # DECLARED Topics: header subjects — the doc→cluster edge source; never + # conflated with `inferred_topics`. + inferred_topics: + type: array + # Structurally separate from declared `topics`; optional until + # document-cataloging lands (spec: declared kept distinct from inferred). + items: + type: object + required: [tag, confidence, state] + properties: + tag: {type: string, minLength: 1} + confidence: {type: number, minimum: 0, maximum: 1} + state: {type: string, enum: [pending, suggested, reviewed]} + dates: + type: object + properties: + captured: {type: string, format: date} # Captured: header + last_transition: {type: string, format: date} # last lifecycle move + destinations: + type: object + # Where this doc is heading downstream (link refs only). + properties: + staged_topics: {type: array, items: {type: string, minLength: 1}} # staging ids + changes: {type: array, items: {type: string, minLength: 1}} # change ids + capabilities: {type: array, items: {type: string, minLength: 1}} # capability names + completeness: + type: object + # Deterministic per-document maturity signal (add-staging-workbench; + # design D1/D2; requirement "Deterministic per-document completeness + # signal"). `score` is a FIXED-weight combination of the five named + # signals below at a fixed decimal precision — both are v1 contract + # constants (see `completeness_signal` below), never a per-run input. + # Optional: absent on a pre-growth snapshot, on an excluded document + # (`_document_exclusion_reason`), or before the generator realization + # populates it — a renderer MUST treat absence as "no bars", never as + # a zero score (design D7). BOUNDED to judgment-free surfaces: this + # score and its signals MUST NOT feed the readiness recommendation + # gate and MUST NOT produce a doc-health finding; the ONE sanctioned + # gate consumer anywhere in the corpus is the staged-to-proposal + # readiness gate (add-staging-workbench), and even that gate never + # reads this object directly — it consumes document scores only + # indirectly, through the `staged_topic.health` aggregate below. + required: [score, structure, length, open_markers, keyword_coverage, link_degree] + properties: + score: {type: number, minimum: 0, maximum: 1} + structure: {$ref: "#/$defs/completeness_signal"} + length: {$ref: "#/$defs/completeness_signal"} + open_markers: {$ref: "#/$defs/completeness_signal"} + keyword_coverage: {$ref: "#/$defs/completeness_signal"} + link_degree: {$ref: "#/$defs/completeness_signal"} + # --- per-document completeness signal (add-staging-workbench design D1/D2) --- + completeness_signal: + type: object + # One NAMED completeness signal: a normalized `value` (0..1, saturating at + # a FIXED constant so padding past the threshold cannot outscore + # substance) beside the RAW `count` that produced it, so a rendered bar is + # always explainable in the signal's own terms. The five signals are: + # `structure` (fraction of the document's expected structural elements + # present — the expected set is fixed per `Kind:` with a common fallback: + # H1 title, the governance header block, at least one section); `length` + # (body size normalized against a fixed saturation threshold, so padding + # past it cannot outscore substance); `open_markers` (an INVERSE signal — + # the standing TODO/TBD/FIXME/open-question-marker count normalized + # against a fixed saturation count and subtracted from 1, so MORE + # standing markers yields a LOWER value, never higher); `keyword_coverage` + # (fraction of the document's declared `Topics:` subjects that resolve to + # the snapshot's keyword vocabulary); `link_degree` (the document's + # snapshot edge degree — cluster document edges plus `destinations` — + # normalized against a fixed saturation degree). All five weights, every + # saturation constant, and the decimal precision of `value`/`score` are + # FIXED v1 contract constants (design D2) — deterministic and + # reproducible from the pinned tree alone, with no model call, no wall + # clock, no network, and no judgment input of any kind (design D1); a + # tunable weight configuration is a possible successor, never a per-run + # input (open question 1). + required: [value, count] + properties: + value: {type: number, minimum: 0, maximum: 1} + count: + type: integer + minimum: 0 + # The raw measurement behind `value` (element count, word count, + # marker count, resolved-topic count, or edge degree — per signal). + # --- topic clusters (funnel column 2; D11/D12 working surface) --- + cluster: + type: object + required: [id, name, topics, document_edges] + properties: + id: {type: string, minLength: 1} + name: {type: string, minLength: 1} + topics: + type: array + items: {type: string, minLength: 1} # the cluster's topic set + document_edges: + type: array + # Many-to-many doc→cluster edges from Topics: headers; one edge per + # member doc naming the topics that matched (spec scenario "One doc + # feeds several clusters"). These edges are the ONLY canvas members. + items: + type: object + required: [document, matched_topics] + properties: + document: {type: string, minLength: 1} + matched_topics: {type: array, items: {type: string, minLength: 1}} + tallies: + type: object + # Funnel tallies count LINKS, not cards (spec: "Realization funnel model"). + properties: + document_links: {type: integer, minimum: 0} + possible_links: {type: integer, minimum: 0} + readiness: + type: object + # Verbatim tier scores from the ideation-cross-reference index; rendered + # without re-scoring (spec scenario "Readiness scores exist for a + # cluster"). Optional until add-ideation-cross-reference-readiness + # supplies it; shape owned there, so unconstrained here. + conflict_flags: + type: array + # Verbatim conflict flags from the same index; optional until it lands. + lineage: + type: object + # Downstream artifacts for the canvas lineage strip — DISTINCT from the + # member edges above (spec scenario "Member pane derivation"). + properties: + staged_picks: {type: array, items: {type: string, minLength: 1}} # staging ids + proposals: {type: array, items: {type: string, minLength: 1}} # change ids + realized: {type: array, items: {type: string, minLength: 1}} # change ids + # --- possibles register (funnel column 3; consolidated in the cross-ref index) --- + possible: + type: object + required: [id, title, claim, state] + properties: + id: {type: string, minLength: 1} + title: {type: string, minLength: 1} + claim: {type: string, minLength: 1} + state: {type: string, enum: [latent, picked, rejected, superseded]} + reason: + type: string + minLength: 1 + # REQUIRED for rejected/superseded (allOf below); the durable "why". + citation: + type: string + minLength: 1 + # REQUIRED for rejected/superseded — an uncited rejection is invalid. + claiming_clusters: + type: array + items: {type: string, minLength: 1} + # Many-to-many cluster→possible edges; one entry per claiming cluster + # (spec scenario "One possible is claimed by several clusters"). + pick: + type: object + # Present when state=picked (allOf below): the organize-gate pick edge. + required: [staging_id] + properties: + staging_id: {type: string, minLength: 1} # cited at the pick + change_id: {type: string, minLength: 1} # inherited at the proposal gate + option_set: + type: object + # Choose-one grouping of alternative shapes of one feat; siblings share + # `id`. Choosing one drafts the others' superseded transitions (D12). + required: [id] + properties: + id: {type: string, minLength: 1} + members: {type: array, items: {type: string, minLength: 1}} # sibling possible ids + supporting_evidence: + type: array + items: {$ref: "#/$defs/evidence_pin"} + # doc + section ref + passage hash; seeds the canvas evidence board. + allOf: + # An uncited rejection/supersession is invalid (task 2.3 register rule). + - if: {properties: {state: {enum: [rejected, superseded]}}} + then: {required: [reason, citation]} + - if: {properties: {state: {const: picked}}} + then: {required: [pick]} + # --- staged picks (funnel column 4) --- + staged_topic: + type: object + required: [staging_id] + properties: + staging_id: {type: string, minLength: 1} + files: {type: array, items: {type: string, minLength: 1}} # topic-folder files + readiness_state: + type: string + minLength: 1 + # Exit-readiness state; verbatim from the cross-ref index when present. + target_change: + type: string + minLength: 1 + # Exit path — the change id this topic promotes into. + health: + type: object + # Per-staged-topic health aggregate (add-staging-workbench design D8; + # requirement "Staged-topic health signal"), derived EXCLUSIVELY from + # the topic FOLDER's own corpus documents — documents that merely + # declare the topic as a destination (a document's + # `destinations.staged_topics`) are inbound context, never health + # inputs. `status` is DERIVED from `blockers`, never scored directly: + # `ready` (blockers empty), `stub` (the folder carries no corpus + # documents), `developing` (otherwise) — so the icon and the gate can + # never disagree about WHY. The ready threshold (`READY_MIN_SCORE`, + # v1 = 0.60 per Brett's 2026-07-25 ruling, calibrated at realization) + # is a FIXED v1 contract constant, pinned beside the completeness + # weights — never a per-run input. Deterministic and reproducible + # from the pinned tree alone, exactly like `document.completeness`. + # NOT a readiness judgment: MUST NOT feed the readiness + # recommendation gate or re-score any readiness tier, and no cluster + # or possible gains any aggregate (design D3 — this is the ONE + # sanctioned aggregate in the whole snapshot). This object is the + # staged tile's display truth ONLY: the staged-to-proposal readiness + # gate re-evaluates health LIVE against the pinned checkout at + # request time through the same scoring module the generator uses, + # never against this field, so a stale snapshot can show `ready` + # while the live gate still refuses (design D9). Optional: absent on + # a pre-growth snapshot or before the generator realization + # populates it. + required: [standing_open_items, doc_score_min, doc_score_mean, blockers, status] + properties: + standing_open_items: + type: integer + minimum: 0 + # Sum of the member documents' `open_markers` RAW counts (not + # the normalized `value`). + doc_score_min: + type: number + minimum: 0 + maximum: 1 + # Minimum `completeness.score` over the folder's member + # documents, at the same fixed decimal precision. + doc_score_mean: + type: number + minimum: 0 + maximum: 1 + # Mean `completeness.score` over the folder's member documents, + # at the same fixed decimal precision. + blockers: + type: array + items: {$ref: "#/$defs/health_blocker"} + # Empty array <=> status: ready. + status: + type: string + enum: [ready, developing, stub] + # --- staged-topic health blocker (add-staging-workbench design D8) --- + health_blocker: + type: object + # A named, countable reason a staged topic's health is not `ready` — + # always explainable in one hover or one gate-refusal message (design + # D8). The staged-to-proposal readiness gate cites these VERBATIM when it + # refuses. + required: [kind, document] + properties: + kind: + type: string + enum: [standing_open_items, below_ready_threshold] + document: {type: string, minLength: 1} # the member document this blocker names + count: + type: integer + minimum: 0 + # REQUIRED for kind=standing_open_items — the document's standing + # open-marker raw count. + score: + type: number + minimum: 0 + maximum: 1 + # REQUIRED for kind=below_ready_threshold — the document's + # completeness score (compare against the READY_MIN_SCORE constant). + threshold: + type: number + minimum: 0 + maximum: 1 + # Emitted beside `score` for kind=below_ready_threshold: the + # READY_MIN_SCORE constant the score fell short of, carried in the + # blocker so a renderer can show "score vs constant" verbatim + # without hardcoding the contract constant. + allOf: + - if: {properties: {kind: {const: standing_open_items}}} + then: {required: [count]} + - if: {properties: {kind: {const: below_ready_threshold}}} + then: {required: [score]} + # --- changes (funnel columns 5-6: proposals + realized; D6 active + archived) --- + change: + type: object + required: [id, status] + properties: + id: {type: string, minLength: 1} + status: {type: string, enum: [active, archived]} + ratification: + type: object + # Present once ratified; both fields required together. + required: [ratifier, date] + properties: + ratifier: {type: string, minLength: 1} + date: {type: string, format: date} + code_surface: {type: [string, "null"]} # release-realization front-matter + target_release: {type: [string, "null"]} + task_progress: + type: object + # Task completion for the progress bar. + properties: + completed: {type: integer, minimum: 0} + total: {type: integer, minimum: 0} + origin_staging_id: {type: [string, "null"]} # staging topic this change came from + # --- keyword-lens rail seed ("Keyword lens set-builder") --- + keyword_entry: + type: object + required: [keyword, declared_doc_count] + properties: + keyword: {type: string, minLength: 1} + declared_doc_count: + type: integer + minimum: 0 + # Docs carrying this keyword as a DECLARED Topics: subject. + inferred_doc_count: + type: integer + minimum: 0 + # Docs carrying it as an INFERRED tag — kept separate from declared; + # optional until document-cataloging lands. diff --git a/src/openxdox/contracts/validate-ideation-dashboard-contracts.py b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py new file mode 100755 index 0000000..fe10c33 --- /dev/null +++ b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py @@ -0,0 +1,2142 @@ +#!/usr/bin/env python3 +"""Validate ideation-area dashboard contract artifacts (add-ideation-dashboard). + +Change task 2.4 (`openspec/changes/add-ideation-dashboard/tasks.md`): a strict +validator for the ideation-dashboard snapshot, the workbench manifest, the +possibles-register consolidation section (and its cross-snapshot TRANSITIONS), +plus the sibling project-register (task 2.6) and gate-action record (task 2.8) +schemas — the same contract family. It layers the deterministic invariants JSON +Schema alone cannot express (documented in each schema's own comments — those +comments ARE the requirements) on top of plain draft-2020-12 validation, and it +attaches a `FormatChecker` so `date`/`date-time` are actually enforced rather +than left as annotations. + +The schema family under `contracts/schemas/` (every one the contracts directory +carries is loaded into one offline registry, so the register kernel's +cross-file `$ref` into the snapshot's `evidence_pin` resolves): + ideation-dashboard-snapshot.schema.yaml (kind: ideation-dashboard-snapshot) + ideation-dashboard-snapshot-index.schema.yaml + (kind: + ideation-dashboard-snapshot-index; + the (repository, ref) locator, + add-dashboard-repo-selector) + ideation-workbench.schema.yaml (kind: ideation-workbench) + ideation-possibles-register.schema.yaml (envelope-less $defs kernel; + fixtures wrap it under a plain + `possibles_register:` container key) + project-register.schema.yaml (kind: project-register) + gate-action-record.schema.yaml (kind: gate-action-record) + +Validator-side rules beyond plain schema conformance: + + Snapshot referential integrity: cluster `document_edges` -> existing + `documents[].id`; possible `claiming_clusters` -> existing + `clusters[].id`; `option_set.members` -> existing `possibles[].id`. + `keyword_index` consistency with declared topics is a WARNING + (the generator may deliberately scope keywords), never an error. + Index every (repository, ref) pair is UNIQUE within one snapshot index, + and the index carries NO projection data (documents/clusters/ + possibles/staged_topics/changes/keyword_index, at the root or in an + entry) — it is a locator, not a projection (D3). + Workbench recipe `pinned` keywords must be a subset of `checked`; + `recipe.new_candidates` must be disjoint from members ∪ excluded; + AND — the committed-manifest guard — a well-formed workbench + manifest found in TRACKED repository content (outside the + reference `examples/` tree, which is static material) is an error: + saved manifests belong under gitignored `ideation/workbench/`. + Register `id` uniqueness within one register (including AI-derived entries); + and, in TRANSITION mode (`--transition OLD NEW`), state-machine + legality across the pair: latent->picked|rejected|superseded, + picked->superseded only, rejected/superseded terminal, NO entry + deletion / resurrection (a removed id is an error; a re-used id with + a different identity is an error). Per-state field requirements + (reason+citation for rejected/superseded; pick.staging_id for + picked) are re-checked through the transition path. + Derived AI-derived register entries (add-possibles-derivation-lane; the + kernel's additive `origin`/`derivation` delta). Single-instance: + an `origin: ai-derived` entry carries a `derivation` block, cites at + least one `claiming_clusters` edge AND one `supporting_evidence` pin + (never an unsourced assertion), and its machine + `derivation.disposition` is `pending_review` only. TRANSITION mode: + the one-way disposition lifecycle — a possible's origin is fixed (an + accepted derived possible retains `origin: ai-derived`), and a + disposed derived possible (a recorded `human_disposition`) is never + edited back to the undisposed `pending_review` state. + Project `id` uniqueness (projects and groups); every group-member project + id must exist; a project in at most one group (the snapshot's + `project_group` is singular). Repository membership is + MULTI-PARENT (Brett's 2026-08-06 ruling): a repository may live + in any number of projects; the snapshot's singular `project` is + the first-declaring PRIMARY and `projects` carries them all. + Gate a `kickoff` record requires its target change to carry a recorded + ratification (D17). This is a cross-instance precondition: supply + `--context FILE|DIR` (a snapshot whose `changes[]` carry + ratification, and/or sibling gate-action `ratify` records), or, in + a directory sweep, sibling records provide it automatically. With + no context available the precondition is reported SKIPPED, never + silently passed. + +Usage: + # Default: self-test the packaged examples, scan the repo tree for real + # instances, and run the committed-manifest guard. + python3 scripts/validate-ideation-dashboard-contracts.py [REPO] [--strict] + + # Validate one file (kind auto-detected) or a directory sweep. + python3 scripts/validate-ideation-dashboard-contracts.py PATH [--context F] [--strict] + + # Register transition legality across two register sections. + python3 scripts/validate-ideation-dashboard-contracts.py --transition OLD NEW [--strict] + +Exit codes: 0 ok, 1 findings (or warnings under --strict), 2 harness error. +""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +try: + import yaml +except ImportError: # pragma: no cover + print("ERROR PyYAML is required", file=sys.stderr) + sys.exit(2) + +try: + from jsonschema import Draft202012Validator, FormatChecker + from referencing import Registry, Resource + from referencing.jsonschema import DRAFT202012 +except ImportError: # pragma: no cover + print("ERROR jsonschema>=4.18 and referencing are required", file=sys.stderr) + sys.exit(2) + +ROOT = Path(__file__).resolve().parents[1] +# WHERE THE CONTRACTS THIS VALIDATOR READS LIVE (split-opendox-two-layer-product +# § 8.9 residue (i)). As carved, `ROOT / "contracts"` named a directory the +# openXdox-code leg does not have, so the validator could run from nowhere. +# This tree's own `contracts/` still comes first when the tree carries one: the +# pre-shed layout, and openxFactory's `doxbench_contracts._composed_validator`, +# which composes the whole family beside a copy of this script. Otherwise the +# contracts are the directory `CONTRACTS_DIR` names: the channel the assembly +# root's `AGENTS-shape.md` (openRepoShape's, digest-pinned in opensoft/openXdox) +# declares for "a contract the code READS but does not OWN", which "lives in the +# SPEC leg" — the code leg's tooling "takes `CONTRACTS_DIR` from the environment +# ... rather than each script guessing at `../`". So nothing above ROOT is read +# by position, the class § 8.9 residue (iii) names (a reader adopting its +# enclosing tree). The packaged examples sit beside `contracts/` in each tree +# that carries the family. Since plan 034 T061 this directory choice decides +# the examples and the messages, and each schema's own source is decided by +# `schema_source` below: this validator's own three come from its installed +# distribution where this tree carries none of them, and CONTRACTS_DIR supplies +# only the family's other seven. +_DECLARED_CONTRACTS = os.environ.get("CONTRACTS_DIR", "") +_OWN_CONTRACTS = ROOT / "contracts" +_OWN_SCHEMAS = _OWN_CONTRACTS / "schemas" +# A `contracts/` or `contracts/schemas/` that is a LINK OUT OF THIS TREE is +# somebody else's contracts under this tree's own name: the escaping-link case +# `openxdox.snapshot.find_validator` refuses for `scripts/`. It is never read, +# whatever CONTRACTS_DIR says, and the run fails closed naming it. That holds +# whatever the link reaches: a directory with `schemas/`, one without, or +# nothing at all (a dangling link). So each of the two NAMES is checked as it +# resolves, before any directory is chosen, and not only when it is a +# directory; otherwise a valid CONTRACTS_DIR would let an escaping link pass +# unread (Copilot review of openXdox-code#28, round 3). The check is on the +# two DIRECTORY names: a real one whose schema FILES are links (openxFactory's +# composed farm) is still this tree's own. +for _own in (_OWN_CONTRACTS, _OWN_SCHEMAS): + if ((_own.is_symlink() or _own.exists()) + and not _own.resolve().is_relative_to(ROOT)): + print(f"ERROR harness failure: {_own} resolves to {_own.resolve()}, " + "outside this tree, so it is not read; remove the link and name " + "the contracts directory with CONTRACTS_DIR", file=sys.stderr) + sys.exit(2) +if _OWN_SCHEMAS.is_dir(): + CONTRACTS, CONTRACTS_SOURCE = ROOT / "contracts", "this tree's own contracts/" +elif _DECLARED_CONTRACTS: + CONTRACTS = Path(_DECLARED_CONTRACTS).resolve() + CONTRACTS_SOURCE = f"CONTRACTS_DIR={_DECLARED_CONTRACTS}" +else: + CONTRACTS = ROOT / "contracts" + CONTRACTS_SOURCE = ("this tree carries no contracts/ and CONTRACTS_DIR is not " + "set; export it as the spec leg's contracts directory") +SCHEMAS_DIR = CONTRACTS / "schemas" +EXAMPLES_DIR = CONTRACTS.parent / "examples" / "ideation-dashboard" + +# THE CONSUMER'S OWN THREE, FROM ITS INSTALLED DISTRIBUTION (plan 034 T061; +# #1144 7.3, RULED R1Q14 (a), and T007's batch I on R1Q27 (a), +# `opensoft/openxFactory#656` comments 5850003126 and 5851950767). This validator +# validates its own three kinds, 7.1's openXdox-spec three, wherever it runs, and +# the family's other kinds only where the tree it runs from supplies their +# schemas. So each family schema is found in ONE of three places, in this order: +# 1. this tree's own `contracts/schemas/`, read first, as before. openxFactory's +# farm (`doxbench_contracts._composed_validator`) supplies the whole family +# that way, and the packaged copy of this script +# (`openxdox/contracts/validate-ideation-dashboard-contracts.py`) finds the +# three packaged copies beside it that way; +# 2. for the three, the INSTALLED openxdox distribution's packaged copies +# (`openxdox/contracts/schemas/`), found through the import system and never +# by position. That is where a source checkout's `scripts/` copy, which has +# no `contracts/` of its own, reads them; +# 3. for the other seven, the directory `CONTRACTS_DIR` names, which never +# supplies the three. +# A packaged copy is read only once its sha256 equals the one `copies.yaml` +# records beside it, wherever it is found (`openxdox.contracts` applies the +# same rule), so a copy edited in place is refused, never read. A schema no +# place supplies is refused BY NAME where an instance needs it (harness exit 2). +OWN_KIND_SCHEMAS = frozenset({ + "ideation-dashboard-snapshot.schema.yaml", + "ideation-dashboard-snapshot-index.schema.yaml", + "gate-action-record.schema.yaml", +}) +COPIES_RECORD = "copies.yaml" + + +class ContractRefused(Exception): + """A packaged copy cannot be trusted, so it is not read (harness exit 2).""" + + +def distribution_contracts() -> Path | None: + """`openxdox/contracts/` of the openxdox distribution the running interpreter + has installed, or None where it has none. Found through the import system + (`find_spec` imports nothing of the package itself), never by position.""" + try: + spec = importlib.util.find_spec("openxdox.contracts") + except (ImportError, ValueError): + return None + if spec is None or not spec.submodule_search_locations: + return None + return Path(list(spec.submodule_search_locations)[0]).resolve() + + +def verified_copy(contracts: Path, name: str) -> Path: + """`/schemas/`, once its sha256 equals the digest the record + beside it (`/copies.yaml`) gives for it. `ContractRefused` + otherwise: an unreadable record, no digest for the name, a copy that cannot + be read, or a copy that differs.""" + record_path = contracts / COPIES_RECORD + try: + record = yaml.safe_load(record_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise ContractRefused(f"{record_path} cannot be read ({type(exc).__name__}), " + f"so the packaged {name} is not read") from exc + rows = record.get("copies") if isinstance(record, dict) else None + wanted = {Path(str(row.get("path", ""))).name: row.get("sha256") + for row in (rows if isinstance(rows, list) else []) if isinstance(row, dict)} + digest = wanted.get(name) + if not isinstance(digest, str) or len(digest) != 64: + raise ContractRefused(f"{record_path} records no sha256 for {name}, so the " + "packaged copy is not read") + path = contracts / "schemas" / name + try: + actual = hashlib.sha256(path.read_bytes()).hexdigest() + except OSError as exc: + raise ContractRefused(f"the packaged {path} cannot be read " + f"({type(exc).__name__})") from exc + if actual != digest: + raise ContractRefused( + f"the packaged {path} is not the spec leg's file: its sha256 is {actual}, " + f"and {record_path} records {digest}. A packaged copy is never edited in " + "place; copy the spec leg's file again at the recorded commit") + return path + + +def schema_source(name: str) -> tuple[Path | None, str]: + """Where this run reads the family schema `name`, and through which channel, + or (None, why no channel supplies it).""" + own = _OWN_SCHEMAS / name + if own.is_file(): + if name in OWN_KIND_SCHEMAS and (_OWN_CONTRACTS / COPIES_RECORD).is_file(): + return verified_copy(_OWN_CONTRACTS, name), "this tree's own packaged copies" + return own, "this tree's own contracts/" + if name in OWN_KIND_SCHEMAS: + distribution = distribution_contracts() + if distribution is not None and (distribution / "schemas" / name).is_file(): + return (verified_copy(distribution, name), + f"the installed openxdox distribution ({distribution})") + found = ("an installed openxdox distribution without it" + if distribution is not None else + "no installed openxdox distribution this interpreter can import") + return None, (f"it is one of this validator's own three kinds, read from this " + f"tree's own contracts/ or else the installed openxdox " + f"distribution, and neither carries it ({found}; CONTRACTS_DIR " + "never supplies the three)") + if _DECLARED_CONTRACTS: + declared = Path(_DECLARED_CONTRACTS).resolve() / "schemas" + if (declared / name).is_file(): + return declared / name, f"CONTRACTS_DIR={_DECLARED_CONTRACTS}" + return None, f"it is not carried under {declared} (CONTRACTS_DIR={_DECLARED_CONTRACTS})" + return None, (f"it is not carried under {_OWN_SCHEMAS}, and CONTRACTS_DIR is not " + "set; export it as the directory that supplies this kind's schema") + + +def schema_sources() -> dict[str, tuple[Path | None, str]]: + """Every family schema's source for this run, as `schema_source` answers.""" + return {name: schema_source(name) for name in SCHEMA_FILENAMES} + +SCHEMA_FILENAMES = [ + "ideation-dashboard-snapshot.schema.yaml", + "ideation-dashboard-snapshot-index.schema.yaml", + "ideation-workbench.schema.yaml", + "ideation-possibles-register.schema.yaml", + "xfactory-workbench-model-catalog.schema.yaml", + "xfactory-workbench-chat-turn.schema.yaml", + "project-register.schema.yaml", + "gate-action-record.schema.yaml", + "demotion-execution-receipt.schema.yaml", + "gate-intent.schema.yaml", +] + +# Whole-document schemas keyed by the hyphenated `kind` literal each declares. +KIND_TO_SCHEMA = { + "ideation-dashboard-snapshot": "ideation-dashboard-snapshot.schema.yaml", + "ideation-dashboard-snapshot-index": "ideation-dashboard-snapshot-index.schema.yaml", + "ideation-workbench": "ideation-workbench.schema.yaml", + "project-register": "project-register.schema.yaml", + "gate-action-record": "gate-action-record.schema.yaml", + "demotion-execution-receipt": "demotion-execution-receipt.schema.yaml", + "gate-intent": "gate-intent.schema.yaml", + # doxBench wire family (add-workbench-integrated-editor-chat task 2.1): + # instance kinds use the retained `workbench-*` identifier family; the + # chat-turn file holds three envelopes discriminated by a oneOf. + "workbench-model-catalog": "xfactory-workbench-model-catalog.schema.yaml", + # THE ONE SERVED TURN FAMILY (contract-v3.0, + # retire-doxbench-chat-turn-v1). Three v1 rows stood above these — + # `workbench-chat-turn`, `-success`, `-failure` — DEPRECATED at + # contract-v1.34 and still validated for thirteen minors and one major, + # because a deprecation that stopped validating would have broken the very + # clients it existed to keep working. The recorded removal target has been + # reached, so an instance declaring one of those kinds is now an UNKNOWN + # kind to this validator, exactly like any other kind it does not serve. + "workbench-chat-turn-v2": "xfactory-workbench-chat-turn.schema.yaml", + "workbench-chat-turn-v2-success": "xfactory-workbench-chat-turn.schema.yaml", + "workbench-chat-turn-v2-failure": "xfactory-workbench-chat-turn.schema.yaml", +} + +# The snapshot's projection collections — the data an INDEX must never carry +# (`ideation-dashboard-snapshot-index.schema.yaml`, design D3: the index is a +# locator, not a projection). +PROJECTION_KEYS = ( + "documents", "clusters", "possibles", "staged_topics", "changes", + "keyword_index", +) +DEFAULT_REF = "main" # a consumer that names no ref means `main` (D4) + +# The possibles register is an envelope-less `$defs` kernel (no kind/envelope of +# its own — it is embedded as a section of the cross-reference index). Fixtures +# and register instances wrap the section under this plain container key, the +# same convention document-cataloging's locator/handling-gate kernels use. +REGISTER_SCHEMA = "ideation-possibles-register.schema.yaml" +REGISTER_CONTAINER_KEY = "possibles_register" +REGISTER_SECTION_REF = f"{REGISTER_SCHEMA}#/$defs/possibles_register" + +# A single FormatChecker shared by every validator: the ledger's first rule is +# that date/date-time must be enforced, not merely annotated. jsonschema only +# registers the date-time checker when rfc3339-validator is importable, so a +# bare environment would silently accept malformed timestamps — fail closed +# instead of validating vacuously. +FORMAT_CHECKER = FormatChecker() +if not {"date", "date-time"} <= set(FORMAT_CHECKER.checkers): # pragma: no cover + print( + "ERROR jsonschema is missing its date/date-time format checkers; " + "install rfc3339-validator (see " + "requirements/hermes-runtime-contracts.in) so `format: date` and " + "`format: date-time` are enforced", + file=sys.stderr, + ) + sys.exit(2) + +# Legal possibles-register state transitions (documented in the schema). +LEGAL_TRANSITIONS: dict[str, set[str]] = { + "latent": {"latent", "picked", "rejected", "superseded"}, + "picked": {"picked", "superseded"}, + "rejected": {"rejected"}, + "superseded": {"superseded"}, +} +TERMINAL_STATES = {"rejected", "superseded"} + + +class Findings: + def __init__(self) -> None: + self.errors: list[str] = [] + self.warnings: list[str] = [] + self.notes: list[str] = [] + + def error(self, code: str, msg: str) -> None: + self.errors.append(f"ERROR [{code}] {msg}") + + def warn(self, code: str, msg: str) -> None: + self.warnings.append(f"WARN [{code}] {msg}") + + def note(self, msg: str) -> None: + self.notes.append(f"note {msg}") + + +def load_yaml(path: Path) -> Any: + with path.open(encoding="utf-8") as fh: + return yaml.safe_load(fh) + + +# --------------------------- schema registry --------------------------- + +def build_registry() -> tuple[Registry, dict[str, dict]]: + """Offline registry over the family schemas SCHEMAS_DIR CARRIES, so the + register kernel's cross-file `$ref` into the snapshot's `evidence_pin` + resolves (same approach as scripts/validate-document-catalog.py / + validate-avatar-client.py). + + A family schema SCHEMAS_DIR does not carry is left out here and refused BY + NAME where an instance needs it (`doc_validator`, harness exit 2). Since the + carve the ten are split across openXdox-spec, openDox-spec and openxFactory, + and this product's own spec leg carries three of them; a run that meets any + other kind has validated nothing and must say so, never pass (§ 8.9 residue + (i)). + + A SCHEMAS_DIR that carries NONE of the ten is refused HERE, before any mode + runs, as a harness failure (exit 2). An empty registry validates nothing. A + directory sweep that met no recognized instance, or a register transition, + would otherwise reach a verdict having loaded no schema, and the sweep would + exit 0 (Copilot review of openXdox-code#28).""" + sources = schema_sources() + if not any(path is not None for path, _channel in sources.values()): + raise FileNotFoundError( + f"{SCHEMAS_DIR} carries none of the family's {len(SCHEMA_FILENAMES)} " + f"schemas ({CONTRACTS_SOURCE}), and no installed openxdox distribution " + "supplies this validator's own three, so nothing can be validated and " + "no mode may report success") + resources = [] + docs: dict[str, dict] = {} + for name in SCHEMA_FILENAMES: + path, _channel = sources[name] + if path is None: + continue + doc = load_yaml(path) + docs[name] = doc + rid = doc.get("$id", name) + resources.append((rid, Resource.from_contents(doc, default_specification=DRAFT202012))) + return Registry().with_resources(resources), docs + + +def doc_validator(schema_name: str, registry: Registry, docs: dict[str, dict]) -> Draft202012Validator: + if schema_name not in docs: + _path, why = schema_source(schema_name) + raise FileNotFoundError( + f"{schema_name} is not supplied: {why}. So no instance of that kind can " + "be validated here (the carve split this family across openXdox-spec, " + "openDox-spec and openxFactory)") + return Draft202012Validator(docs[schema_name], registry=registry, format_checker=FORMAT_CHECKER) + + +def section_validator(registry: Registry) -> Draft202012Validator: + """Validator for the whole `possibles_register` section, resolved through + the registry exactly as the cross-reference index `$ref`s it.""" + return Draft202012Validator({"$ref": REGISTER_SECTION_REF}, registry=registry, + format_checker=FORMAT_CHECKER) + + +def iter_errors(validator: Draft202012Validator, instance: Any): + return sorted(validator.iter_errors(instance), key=lambda e: [str(p) for p in e.absolute_path]) + + +def detect(doc: Any) -> str | None: + """Return a routing tag for a loaded document: a known `kind`, or the + register-section container, or None (unrecognized).""" + if not isinstance(doc, dict): + return None + kind = doc.get("kind") + if kind in KIND_TO_SCHEMA: + return kind + if isinstance(doc.get(REGISTER_CONTAINER_KEY), list): + return "possibles-register-section" + return None + + +# --------------------------- context (kickoff precondition) --------------------------- + +def ratified_change_ids_from(doc: Any) -> set[str]: + """Extract change ids that carry a recorded ratification from a context + document — a snapshot (`changes[].ratification`) or a gate-action `ratify` + record (its `target.change_id`).""" + out: set[str] = set() + if not isinstance(doc, dict): + return out + if doc.get("kind") == "ideation-dashboard-snapshot": + for ch in doc.get("changes") or []: + if isinstance(ch, dict) and ch.get("ratification") and ch.get("id"): + out.add(ch["id"]) + if doc.get("kind") == "gate-action-record" and doc.get("action") == "ratify": + cid = (doc.get("target") or {}).get("change_id") + if cid: + out.add(cid) + return out + + +def load_context(path: Path | None) -> tuple[set[str] | None, list[str]]: + """Return (ratified_change_ids, notes). None means no context was available + (kickoff precondition must then be SKIPPED, not passed).""" + if path is None: + return None, [] + notes: list[str] = [] + ratified: set[str] = set() + files: list[Path] + if path.is_dir(): + files = sorted(list(path.rglob("*.yaml")) + list(path.rglob("*.yml"))) + elif path.is_file(): + files = [path] + else: + return None, [f"context path {path} not found; kickoff precondition unavailable"] + for fp in files: + try: + ratified |= ratified_change_ids_from(load_yaml(fp)) + except yaml.YAMLError: + continue + notes.append(f"context: {len(ratified)} ratified change id(s) resolved from {path}") + return ratified, notes + + +# --------------------- deprecation warnings (read, never restated) --------------------- + +def deprecated_kinds(docs: dict[str, dict]) -> dict[str, dict]: + """Every instance kind a loaded schema declares DEPRECATED, keyed by kind. + + Read from the schemas' own top-level `deprecated_envelopes` blocks. This + validator never carries its own list of what is deprecated: the release owns + that statement, and a second copy here would be a second authority that could + disagree with the bytes consumers actually pin. + + KEPT AT contract-v3.0 WITH NOTHING TO REPORT (retire-doxbench-chat-turn-v1 + task 4.2). The chat-turn v1 family was this mechanism's only subject in the + estate, and its `deprecated_envelopes` block left with the envelopes it + named — so this function now returns `{}` over the packaged schemas. It is + GENERAL machinery, not v1 machinery: it reads whatever ANY loaded schema + declares. Removing it because its only current subject went would delete the + estate's only machine-readable deprecation reader and leave the next + deprecation inert, which is the precise failure this whole retirement + exists to correct.""" + declared: dict[str, dict] = {} + for doc in docs.values(): + if not isinstance(doc, dict): + continue + for entry in doc.get("deprecated_envelopes") or []: + if isinstance(entry, dict) and isinstance(entry.get("kind"), str): + declared[entry["kind"]] = entry + return declared + + +def warn_if_deprecated_kind(f: Findings, label: str, tag: str, + docs: dict[str, dict]) -> None: + """WARN, and still accept — the deprecating-change class the versioning + policy defines ("the conformance validator emits warnings but still accepts + it"). Without this the deprecation was inert: a release could claim to start + the clock the breaking path requires while every conforming instance of the + deprecated shape validated in silence.""" + entry = deprecated_kinds(docs).get(tag) + if entry is None: + return + # CONSEQUENCE, stated: under `--strict` (opt-in, "treat warnings as errors") + # a deprecated instance now FAILS. That is what strict mode means and what a + # consumer asking for it wants — a way to find the shapes that will not + # survive the removal target. The default invocation, which is what this + # repository's own gates run, still exits 0. + f.warnings.append( + f"{label}: kind {tag!r} is DEPRECATED as of " + f"{entry.get('deprecated_in', 'an unstated release')} — superseded by " + f"{entry.get('superseded_by', 'no stated replacement')}; removal target " + f"{entry.get('removal_target', 'unstated')}") + + +# --------------------- per-instance validation (schema + rules) --------------------- + +def validate_instance( + f: Findings, label: str, doc: Any, registry: Registry, docs: dict[str, dict], + ratified_changes: set[str] | None, model_ctx: dict[str, int] | None = None, +) -> str | None: + """Validate one loaded document by detected kind: schema conformance plus + the family's single-instance validator-side rules. Returns the routing tag + (so callers can aggregate), or None if unrecognized.""" + tag = detect(doc) + if tag is None: + f.error("kind", f"{label}: unrecognized document (no known kind, no {REGISTER_CONTAINER_KEY!r} section)") + return None + + if tag == "possibles-register-section": + validator = section_validator(registry) + for e in iter_errors(validator, doc[REGISTER_CONTAINER_KEY]): + loc = "/".join(str(p) for p in e.absolute_path) or "" + f.error("schema", f"{label}: {REGISTER_CONTAINER_KEY}/{loc}: {e.message}") + check_register_unique_ids(f, label, doc[REGISTER_CONTAINER_KEY]) + check_derived_entries(f, label, doc[REGISTER_CONTAINER_KEY]) + return tag + + schema_name = KIND_TO_SCHEMA[tag] + for e in iter_errors(doc_validator(schema_name, registry, docs), doc): + loc = "/".join(str(p) for p in e.absolute_path) or "" + f.error("schema", f"{label}: {loc}: {e.message}") + warn_if_deprecated_kind(f, label, tag, docs) + + if tag == "ideation-dashboard-snapshot": + check_snapshot_referential_integrity(f, label, doc) + elif tag == "ideation-dashboard-snapshot-index": + check_snapshot_index_rules(f, label, doc) + elif tag == "ideation-workbench": + check_workbench_rules(f, label, doc) + elif tag == "project-register": + check_project_register_rules(f, label, doc) + elif tag == "gate-action-record": + check_gate_precondition(f, label, doc, ratified_changes) + check_cleanup_record(f, label, doc) + elif tag == "demotion-execution-receipt": + check_demotion_receipt(f, label, doc) + elif tag == "workbench-model-catalog": + check_model_catalog(f, label, doc) + elif tag == "workbench-chat-turn-v2": + check_turn_request_v2(f, label, doc, model_ctx) + elif tag == "workbench-chat-turn-v2-success": + check_turn_success_v2(f, label, doc) + elif tag == "workbench-chat-turn-v2-failure": + # `check_turn_failure` keeps its family-neutral name. It was written to + # judge BOTH released failure envelopes, on the released reason that "a + # v2 failure discloses exactly what a v1 failure does, so it is judged + # by exactly the same function"; one family survives contract-v3.0 and + # the rules it applies — the `limit`/`request_limit_exceeded` pairing + # and the public-string leak scan — are the FAMILY's, not this + # envelope's. + check_turn_failure(f, label, doc) + return tag + + +# --------------------------- snapshot referential integrity --------------------------- + +def check_snapshot_referential_integrity(f: Findings, label: str, doc: dict) -> None: + """Internal referential integrity (snapshot schema comments: edges reference + existing ids). keyword_index/declared-topic consistency is a WARNING.""" + doc_ids = {d.get("id") for d in doc.get("documents") or [] if isinstance(d, dict)} + cluster_ids = {c.get("id") for c in doc.get("clusters") or [] if isinstance(c, dict)} + possible_ids = {p.get("id") for p in doc.get("possibles") or [] if isinstance(p, dict)} + + for c in doc.get("clusters") or []: + if not isinstance(c, dict): + continue + cid = c.get("id") + for edge in c.get("document_edges") or []: + ref = edge.get("document") if isinstance(edge, dict) else None + if ref is not None and ref not in doc_ids: + f.error("snapshot-dangling-edge", + f"{label}: cluster {cid!r} document_edge references unknown document {ref!r}") + + for p in doc.get("possibles") or []: + if not isinstance(p, dict): + continue + pid = p.get("id") + for ref in p.get("claiming_clusters") or []: + if ref not in cluster_ids: + f.error("snapshot-dangling-cluster-ref", + f"{label}: possible {pid!r} claiming_clusters references unknown cluster {ref!r}") + opt = p.get("option_set") + if isinstance(opt, dict): + for ref in opt.get("members") or []: + if ref not in possible_ids: + f.error("snapshot-dangling-optionset-member", + f"{label}: possible {pid!r} option_set references unknown possible {ref!r}") + + # keyword_index consistency vs declared Topics: — WARNING only (the + # generator may scope keywords rather than emit every declared topic). + declared_counts: dict[str, int] = {} + for d in doc.get("documents") or []: + if not isinstance(d, dict): + continue + for t in d.get("topics") or []: + declared_counts[t] = declared_counts.get(t, 0) + 1 + for entry in doc.get("keyword_index") or []: + if not isinstance(entry, dict): + continue + kw = entry.get("keyword") + declared = entry.get("declared_doc_count") + actual = declared_counts.get(kw, 0) + if isinstance(declared, int) and declared != actual: + f.warn("keyword-index-drift", + f"{label}: keyword {kw!r} declared_doc_count={declared} but {actual} document(s) " + f"declare it as a Topics: subject") + + +# --------------------------- snapshot-index rules --------------------------- + +def check_snapshot_index_rules(f: Findings, label: str, doc: dict) -> None: + """The two validator-side rules the index shape cannot express + (`ideation-dashboard-snapshot-index.schema.yaml`; add-dashboard-repo-selector + tasks 1.1-1.2): + + * UNIQUE (repository, ref) — two entries for one pair make "which snapshot + is this repository's?" ambiguous; and + * NO PROJECTION DATA — the index is a locator, so a projection collection + at the root or inside an entry is refused (design D3). + """ + seen: dict[tuple[str, str], int] = {} + for entry in doc.get("entries") or []: + if not isinstance(entry, dict): + continue + pair = (entry.get("repository"), entry.get("ref") or DEFAULT_REF) + seen[pair] = seen.get(pair, 0) + 1 + _check_no_projection_data(f, label, entry, f"entry {pair[0]!r}@{pair[1]!r}") + for (repo, ref), n in sorted(seen.items(), key=lambda kv: [str(x) for x in kv[0]]): + if n > 1: + f.error("snapshot-index-duplicate-repo-ref", + f"{label}: (repository, ref) pair ({repo!r}, {ref!r}) appears {n} times " + f"(every pair is unique within one index)") + _check_no_projection_data(f, label, doc, "index root") + + +def _check_no_projection_data(f: Findings, label: str, obj: dict, where: str) -> None: + """The locator-not-projection rule: none of the snapshot's projection + collections may appear in an index (root or entry).""" + for key in PROJECTION_KEYS: + if key in obj: + f.error("snapshot-index-carries-projection-data", + f"{label}: {where} carries projection key {key!r} — the index locates " + f"snapshots and never restates their contents (D3)") + + +# --------------------------- workbench rules --------------------------- + +def check_workbench_rules(f: Findings, label: str, doc: dict) -> None: + """Workbench manifest validator-side rules (schema comments): pinned ⊆ + checked, and new_candidates disjoint from members ∪ excluded.""" + recipe = doc.get("recipe") + if isinstance(recipe, dict): + checked = set(recipe.get("checked") or []) + pinned = set(recipe.get("pinned") or []) + stray = pinned - checked + if stray: + f.error("workbench-pinned-not-checked", + f"{label}: recipe pinned keyword(s) {sorted(stray)} are not in checked") + member_docs = {m.get("document") for m in doc.get("members") or [] if isinstance(m, dict)} + excluded_docs = {e.get("document") for e in doc.get("excluded") or [] if isinstance(e, dict)} + overlap = set(recipe.get("new_candidates") or []) & (member_docs | excluded_docs) + if overlap: + f.error("workbench-candidate-overlap", + f"{label}: recipe new_candidates {sorted(overlap)} already appear in members/excluded") + + +# --------------------------- register single-instance --------------------------- + +def check_register_unique_ids(f: Findings, label: str, entries: list) -> None: + seen: dict[str, int] = {} + for e in entries or []: + if isinstance(e, dict) and "id" in e: + seen[e["id"]] = seen.get(e["id"], 0) + 1 + for rid, n in seen.items(): + if n > 1: + f.error("register-duplicate-id", f"{label}: register id {rid!r} appears {n} times") + + +# --------------------------- derived-entry (ai-derived) rules --------------------------- + +def _origin(entry: dict) -> str: + """Normalized register-entry origin — absent defaults to human-authored + (the kernel's additive `origin` delta).""" + return entry.get("origin") or "human-authored" + + +def _human_outcome(entry: dict) -> str | None: + """The recorded human disposition outcome on a derived entry, or None when + the entry is undisposed (the machine `derivation.disposition` is + `pending_review` and no `human_disposition` has been recorded).""" + deriv = entry.get("derivation") + if isinstance(deriv, dict): + hd = deriv.get("human_disposition") + if isinstance(hd, dict): + return hd.get("outcome") + return None + + +def check_derived_entry(f: Findings, label: str, entry: dict) -> None: + """Single-instance rules for an `origin: ai-derived` register entry + (add-possibles-derivation-lane): it carries a `derivation` block, cites at + least one `claiming_clusters` edge AND one `supporting_evidence` pin (never + an unsourced assertion), and its machine `derivation.disposition` is + `pending_review` only. Human-authored entries (origin absent) are skipped.""" + if not isinstance(entry, dict) or _origin(entry) != "ai-derived": + return + rid = entry.get("id") + deriv = entry.get("derivation") + if not isinstance(deriv, dict): + # Also caught by the schema allOf; reported here with a register code so + # the delegated register validator names it directly. + f.error("register-derived-missing-derivation", + f"{label}: register id {rid!r} is origin ai-derived but carries no derivation block") + return + if not (entry.get("claiming_clusters") or []): + f.error("register-derived-unsourced", + f"{label}: derived register id {rid!r} cites no claiming_clusters topic-cluster edge " + f"(a derived possible must cite at least one cluster edge and one evidence pin)") + if not (entry.get("supporting_evidence") or []): + f.error("register-derived-unsourced", + f"{label}: derived register id {rid!r} cites no supporting_evidence passage pin " + f"(a derived possible must cite at least one cluster edge and one evidence pin)") + if deriv.get("disposition") != "pending_review": + f.error("register-derived-bad-disposition", + f"{label}: derived register id {rid!r} machine derivation.disposition is " + f"{deriv.get('disposition')!r}; machine output is always 'pending_review' " + f"(the human verdict lives in derivation.human_disposition)") + + +def check_derived_entries(f: Findings, label: str, entries: list) -> None: + for e in entries or []: + check_derived_entry(f, label, e) + + +# --------------------------- register transition --------------------------- + +def _entry_map(entries: list) -> dict[str, dict]: + return {e["id"]: e for e in entries or [] if isinstance(e, dict) and "id" in e} + + +def _identity(entry: dict) -> tuple: + prov = entry.get("provenance") or {} + return (entry.get("claim"), prov.get("document"), prov.get("section")) + + +def check_register_transition(f: Findings, old_label: str, old: list, new_label: str, new: list) -> None: + """State-machine legality across an (old, new) register pair (schema comments: + legal transitions, terminal states, no deletion / no resurrection, stable + identity). Per-state field requirements are re-checked on the new side.""" + check_register_unique_ids(f, old_label, old) + check_register_unique_ids(f, new_label, new) + old_map, new_map = _entry_map(old), _entry_map(new) + + # No entry deletion / no resurrection: an id present in old must remain. + for rid in old_map: + if rid not in new_map: + f.error("register-deletion", + f"transition: register id {rid!r} present in {old_label} was removed in {new_label} " + f"(entries are never deleted; a returning idea gets a NEW id)") + + for rid, new_entry in new_map.items(): + old_entry = old_map.get(rid) + if old_entry is None: + # A brand-new entry — no transition to check, but per-state fields + # and the derived-entry shape still apply. + check_entry_state_fields(f, new_label, new_entry) + check_derived_entry(f, new_label, new_entry) + continue + if _identity(old_entry) != _identity(new_entry): + f.error("register-reused-id", + f"transition: register id {rid!r} was re-used for a different possible " + f"(claim/provenance identity changed between {old_label} and {new_label})") + # A possible's origin is fixed: an accepted derived possible retains + # `origin: ai-derived`; provenance is never laundered in place. + if _origin(old_entry) != _origin(new_entry): + f.error("register-derived-origin-changed", + f"transition: register id {rid!r} origin changed {_origin(old_entry)!r} -> " + f"{_origin(new_entry)!r} — a possible's origin is fixed (an accepted derived " + f"possible retains origin ai-derived)") + # The derived-possible disposition is one-way: once a human_disposition + # is recorded it is never edited back to the undisposed pending_review. + old_outcome = _human_outcome(old_entry) + if old_outcome in {"accepted", "rejected", "deferred"} and _human_outcome(new_entry) is None: + f.error("register-derived-undispose", + f"transition: register id {rid!r} was disposed {old_outcome!r} but is now undisposed " + f"— a derived possible's disposition is one-way and is never edited back to pending_review") + old_state = old_entry.get("state") + new_state = new_entry.get("state") + legal = LEGAL_TRANSITIONS.get(old_state, set()) + if new_state not in legal: + hint = "" + if old_state == "picked" and new_state == "rejected": + hint = " (abandoning a picked possible is a supersession, never a rejection)" + elif old_state in TERMINAL_STATES: + hint = f" ({old_state} is terminal; a returning idea gets a NEW id, never a resurrection)" + f.error("register-illegal-transition", + f"transition: register id {rid!r} {old_state!r} -> {new_state!r} is not a legal move{hint}") + check_entry_state_fields(f, new_label, new_entry) + check_derived_entry(f, new_label, new_entry) + + +def check_entry_state_fields(f: Findings, label: str, entry: dict) -> None: + """Re-verify per-state field requirements through the transition path + (redundant with the schema allOf, but the ledger asks for it here too).""" + rid = entry.get("id") + state = entry.get("state") + if state in TERMINAL_STATES: + if not entry.get("reason") or not entry.get("citation"): + f.error("register-uncited-terminal", + f"{label}: register id {rid!r} state {state!r} requires both reason and citation") + if state == "picked": + pick = entry.get("pick") + if not isinstance(pick, dict) or not pick.get("staging_id"): + f.error("register-picked-no-staging", + f"{label}: register id {rid!r} state 'picked' requires pick.staging_id") + + +# --------------------------- project register --------------------------- + +def check_project_register_rules(f: Findings, label: str, doc: dict) -> None: + """id uniqueness (projects + groups), group-member existence, and the + project->group single-parent rule (a project in at most one group). + REPOSITORY membership is multi-parent since Brett's 2026-08-06 ruling on + `add-project-scoped-selection`: a repository may live in any number of + projects; the snapshot's singular `project` is the PRIMARY + (first-declaring in register order) and the additive `projects` list + carries full membership.""" + projects = doc.get("projects") or [] + groups = doc.get("project_groups") or [] + + proj_ids: dict[str, int] = {} + for p in projects: + if not isinstance(p, dict): + continue + pid = p.get("id") + if pid is not None: + proj_ids[pid] = proj_ids.get(pid, 0) + 1 + for pid, n in proj_ids.items(): + if n > 1: + f.error("project-duplicate-id", f"{label}: project id {pid!r} appears {n} times") + + grp_ids: dict[str, int] = {} + proj_group_parent: dict[str, list[str]] = {} + for g in groups: + if not isinstance(g, dict): + continue + gid = g.get("id") + if gid is not None: + grp_ids[gid] = grp_ids.get(gid, 0) + 1 + for member in g.get("projects") or []: + proj_group_parent.setdefault(member, []).append(gid) + if member not in proj_ids: + f.error("project-dangling-group-member", + f"{label}: project group {gid!r} references unknown project {member!r}") + for gid, n in grp_ids.items(): + if n > 1: + f.error("project-group-duplicate-id", f"{label}: project group id {gid!r} appears {n} times") + for member, parents in proj_group_parent.items(): + if len(parents) > 1: + f.error("project-multi-parent-project", + f"{label}: project {member!r} belongs to multiple groups {parents} " + f"(single-parent D10; snapshot carries a singular project_group field)") + + check_project_schema_election(f, label, projects) + + +# THE FOUR RULES THE SHAPE CANNOT EXPRESS (`add-project-repo-schema`). +# +# The JSON-Schema half already constrains `role` to the three values and +# requires both keys of an entry. What it cannot say is anything RELATING one +# field to another, and all four rules here are relations: +# +# 1. a role names a repository the project does not list — `repositories` +# stays the SINGLE membership answer, and a role beside a membership that +# does not exist is a row two readers would answer differently; +# 2. the same repository is given a role twice in one project — the second +# row is either a contradiction or a duplicate, and neither is a state a +# derivation from an assembly-root manifest could produce; +# 3. more than one repository is given `role: assembly` — an electing project +# has exactly ONE per-project root, which is the whole content of Brett +# Heap's 2026-09-02 ruling "yes, assembly is per project"; +# 4. `reference` without `schema` — a record of which document an election +# followed, for an election nobody declared. +# +# NONE OF THEM IS AN AUTHORITY RULE, and the distinction is load-bearing. They +# check that the row is INTERNALLY COHERENT. A consumer that reads `role: spec` +# as "spec authority lives in that repository" is defective no matter how well +# this function passes — the register is a map, not a governance boundary, and +# the ratified doctrine of `add-wallet-carried-review-authority` is that +# electing the schema "changes no gate, no floor, no grant, and no clearance +# eligibility". +def check_project_schema_election(f: Findings, label: str, projects: list) -> None: + """`schema` / `reference` / `repository_roles` coherence, per project.""" + for p in projects: + if not isinstance(p, dict): + continue + pid = p.get("id") + members = {r for r in (p.get("repositories") or []) if isinstance(r, str)} + + if p.get("reference") is not None and p.get("schema") is None: + f.error("project-reference-without-schema", + f"{label}: project {pid!r} declares a `reference` and no " + f"`schema` — a reference records which document an election " + f"followed, and no election is declared") + + roles = p.get("repository_roles") + if roles is None: + continue + if not isinstance(roles, list): + f.error("project-roles-not-a-list", + f"{label}: project {pid!r} `repository_roles` is not a list " + f"({roles!r})") + continue + seen: dict[str, int] = {} + assemblies: list[str] = [] + for entry in roles: + if not isinstance(entry, dict): + f.error("project-role-malformed", + f"{label}: project {pid!r} has a non-mapping " + f"`repository_roles` entry ({entry!r})") + continue + repo = entry.get("repository") + role = entry.get("role") + if not isinstance(repo, str) or not repo: + f.error("project-role-malformed", + f"{label}: project {pid!r} has a `repository_roles` " + f"entry with no repository ({entry!r})") + continue + seen[repo] = seen.get(repo, 0) + 1 + if repo not in members: + f.error("project-role-unknown-repository", + f"{label}: project {pid!r} assigns role {role!r} to " + f"{repo!r}, which is not among its `repositories`; " + f"membership is declared once, in `repositories`") + if role == "assembly": + assemblies.append(repo) + for repo, n in seen.items(): + if n > 1: + f.error("project-duplicate-repository-role", + f"{label}: project {pid!r} assigns {repo!r} a role " + f"{n} times") + if len(assemblies) > 1: + f.error("project-multiple-assembly-roles", + f"{label}: project {pid!r} names {len(assemblies)} assembly " + f"roots {assemblies} — an electing project has exactly one " + f"per-project root repository") + + +# --------------------------- gate-action precondition --------------------------- + +def check_gate_precondition(f: Findings, label: str, doc: dict, ratified_changes: set[str] | None) -> None: + """D17: a kickoff record requires its target change to carry a recorded + ratification. Cross-instance — SKIPPED (not passed) when no context is + available.""" + if doc.get("action") != "kickoff": + return + change_id = (doc.get("target") or {}).get("change_id") + if ratified_changes is None: + f.note(f"{label}: kickoff precondition SKIPPED — no ratification context supplied " + f"(pass --context or run a directory sweep with sibling ratify records)") + return + if change_id not in ratified_changes: + f.error("kickoff-unratified", + f"{label}: kickoff targets change {change_id!r} which carries no recorded " + f"ratification in the supplied context (D17 refuses kickoff without ratification)") + + +def check_cleanup_record(f: Findings, label: str, doc: dict) -> None: + """Cross-field cleanup identity and evidence requirements.""" + if doc.get("action") != "cleanup-abandoned-branch": + return + target = doc.get("target") or {} + release = (doc.get("cleanup") or {}).get("retention_release") or {} + scope_fields = { + "staged-topic": "topic_id", "cluster": "cluster_id", + "possible": "possible_id", + } + scope_kind = release.get("scope_kind") + scope_field = scope_fields.get(scope_kind) + populated = [field for field in scope_fields.values() if target.get(field)] + if (scope_field is None or populated != [scope_field] + or target.get(scope_field) != release.get("scope_id")): + f.error( + "cleanup-scope-mismatch", + f"{label}: target tile scope must exactly match retention release") + if release.get("kind") == "explicit-human-release": + if doc.get("reason") != release.get("reason"): + f.error( + "cleanup-reason-mismatch", + f"{label}: explicit release reason must match the record reason") + else: + if not release.get("change_id"): + f.error("cleanup-machine-change", + f"{label}: machine evidence requires change_id") + if not release.get("references"): + f.error("cleanup-machine-references", + f"{label}: machine evidence requires nonempty references") + if not release.get("recorded_at"): + f.error("cleanup-machine-time", + f"{label}: machine evidence requires recorded_at") + + +def _safe_repo_reference(value: Any) -> bool: + if (not isinstance(value, str) or not value or "\\" in value + or Path(value).is_absolute()): + return False + return not ({".", ".."} & set(Path(value).parts)) + + +def check_demotion_receipt(f: Findings, label: str, doc: dict) -> None: + destination = doc.get("destination") or {} + if destination.get("path") != f"ideation/staging/{destination.get('id')}": + f.error("demotion-destination-mismatch", + f"{label}: destination.path must exactly match destination.id") + references = [doc.get("transition_manifest")] + references.extend(doc.get("returned_artifacts") or []) + for move in doc.get("returned_moves") or []: + if isinstance(move, dict): + references.extend((move.get("from"), move.get("to"))) + for reference in references: + if not _safe_repo_reference(reference): + f.error("demotion-unsafe-path", + f"{label}: unsafe repository path {reference!r}") + + +# --------------------------- committed-manifest guard --------------------------- + +def check_committed_manifests(f: Findings, repo: Path) -> None: + """A well-formed workbench manifest found in TRACKED repository content is an + error (schema comment / spec scenario "A workbench manifest is committed"). + Saved manifests belong under gitignored `ideation/workbench/`. The reference + `examples/` tree is excluded — those are static contract material, not live + session state — and the schema files themselves are excluded.""" + try: + out = subprocess.run( + ["git", "-C", str(repo), "ls-files", "*.yaml", "*.yml"], + capture_output=True, text=True, check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + f.note(f"committed-manifest guard skipped: git ls-files unavailable ({exc})") + return + tracked = [ln for ln in out.stdout.splitlines() if ln.strip()] + offenders = 0 + for rel in tracked: + if rel.startswith("examples/") or rel.startswith("contracts/schemas/"): + continue + try: + doc = load_yaml(repo / rel) + except (yaml.YAMLError, OSError): + continue + if isinstance(doc, dict) and doc.get("kind") == "ideation-workbench": + f.error("committed-workbench-manifest", + f"{rel}: a workbench manifest is committed/tracked — saved manifests are " + f"session state and belong under gitignored ideation/workbench/") + offenders += 1 + f.note(f"committed-manifest guard: {len(tracked)} tracked YAML file(s) scanned " + f"(examples/ excluded), {offenders} committed workbench manifest(s) found") + + + + +# --------------------- doxBench wire family (task 2.4 rules) --------------------- +# +# Layered on schema conformance, mirroring each schema's own comments: +# Catalog model_id uniqueness; a credential/endpoint SPELLING scan over +# every public string value (the schema already refuses extra +# fields structurally; this catches leakage THROUGH allowed ones); +# and since contract-v1.38 the ROUTING-RULE resolution rules — +# dangling target, chained rule, available-rule-to-unavailable- +# model, and the badge covering (see check_routing_rules). +# Request exactly one outline + one document buffer; segment-wise path +# confinement; EXACT content-hash parity (the validator recomputes +# SHA-256 over each buffer's content, so a mismatched identity is +# refused rather than trusted); with a catalog context (packaged +# examples, or --context) unknown-model and per-model input-budget +# checks — without one those two are SKIPPED, never silently +# passed. +# Success unique proposal targets; and on the widened record, since +# contract-v1.40, the CONTEXT POSTURE's pairing (a reduced packet +# states its reason, a full one carries none) plus the same +# credential/endpoint spelling scan over that reason (see +# check_context_packet). +# Failure the limit-pairing rule (`limit` appears IFF the error is the +# budget refusal) and the same spelling scan on the message. +# Sweep duplicate client_turn_id with DIFFERENT request content across a +# file set is refused (idempotency's conflict half, FR-019). + +import hashlib as _hashlib +import json as _json +import re as _re + +_CREDENTIAL_RE = _re.compile( + r"(?i)(bearer\s+\S|api[-_]?key|authorization\s*:|sk-[A-Za-z0-9]{6,}|" + r"BEGIN [A-Z ]*PRIVATE KEY|secret[-_]?name)") +_ENDPOINT_RE = _re.compile(r"(?i)\b(https?|wss?)://") + +# THE FAMILY'S NON-BLANK RULE, RESTATED (issue #263). Canonical statement and +# the measurement behind it live in +# `scripts/ideation_dashboard/doxbench_packet.states_something`; this file is a +# CONTRACT validator and is standalone by design — it validates artifacts and +# must not import the runtime package whose output it checks, or it could pass +# an instance simply because both sides share a bug. So the rule is restated, +# and a test asserts the two implementations agree on every recorded class. +# +# The rule: a reason STATES SOMETHING iff it has at least one character in +# `L* ∪ N* ∪ P* ∪ S*` — a letter, number, punctuation mark or symbol. +# +# STATED AS WHAT IT ADMITS, NOT WHAT IT EXCLUDES (issue #263 review, P2-1). The +# first version excluded the blank categories, which is a rule over a set that +# GROWS WITH THE UNICODE TABLE: a full sweep found 51 code points where this +# validator's Python (15.0.0) and the browser's ICU (16) disagreed, all +# unassigned in 15.0 and newly assigned combining marks in 16 — and the +# disagreement ran the dangerous way, with the server side ACCEPTING what the +# browser refused. `Cn` is never `L`/`N`/`P`/`S` in any table, so an admission +# rule is stable by construction. +# +# `.strip()` is not it either: it catches space/tab/NBSP/newline and misses +# ZWSP, BOM, bidi overrides, lone combining marks and controls, which are +# zero-visible-width rather than whitespace. +import unicodedata as _unicodedata + +_STATED_CATEGORIES = ("L", "N", "P", "S") + + +def _states_something(text: Any) -> bool: + if not isinstance(text, str): + return False + return any( + _unicodedata.category(ch)[0] in _STATED_CATEGORIES for ch in text) + +# The routing badge's SEGMENT GRAMMAR (contract-v1.38; adversarial review round +# 1 F1). RESTATED from `ideation_dashboard.doxbench_model` -- this validator is +# standalone and imports nothing from that package (the same convention the +# reserved-buffer-key note below records) -- and a companion test pins the two +# spellings and the two normalizers equal, so the file gate and the type gate +# cannot drift into two grammars. +ROUTING_BADGE_SEPARATOR = " / " +ROUTING_BADGE_TRAILING_PUNCTUATION = ".;," + + +def normalized_badge_segment(text: Any) -> str: + """One badge segment, in the form the covering rule compares: whitespace + collapsed, case folded, trailing `.;,` dropped. No normalization may + separate or merge two handling POSTURES — `on-tenant` and `non-tenant` must + stay different, which is the pair that broke the old substring predicate. + + Note that `casefold` DOES rewrite interior characters for the + multi-character folds (German sharp s becomes `ss`, the `fi` ligature + expands), so "interior characters are never rewritten" would be false. The + property actually relied on is narrower and stronger: every fold casefold + performs maps case-or-orthography variants of one word onto one form, and + none of them adds, removes or negates a word. That is the test a future + normalization step must pass. Kept verbatim in step with + `ideation_dashboard.doxbench_model.normalized_badge_segment`, whose + docstring carries the same correction.""" + collapsed = " ".join(str(text).split()).casefold() + return collapsed.rstrip(ROUTING_BADGE_TRAILING_PUNCTUATION).strip() + + +def badge_segments(data_handling: Any) -> tuple[str, ...]: + """A rule's declared badge, split into normalized non-empty segments.""" + return tuple( + segment + for segment in ( + normalized_badge_segment(part) + for part in str(data_handling).split(ROUTING_BADGE_SEPARATOR) + ) + if segment + ) + + +def _string_values(node): + if isinstance(node, str): + yield node + elif isinstance(node, dict): + for v in node.values(): + yield from _string_values(v) + elif isinstance(node, list): + for v in node: + yield from _string_values(v) + + +def catalog_model_ids_from(doc: Any) -> dict[str, int]: + """{model_id: input_limit_bytes} from one catalog instance (context for + the turn-request budget/unknown-model checks).""" + out: dict[str, int] = {} + if isinstance(doc, dict) and doc.get("kind") == "workbench-model-catalog": + for entry in doc.get("models") or []: + if isinstance(entry, dict) and entry.get("model_id"): + out[str(entry["model_id"])] = int(entry.get("input_limit_bytes") or 0) + return out + + +def _scan_public_strings(f: Findings, label: str, node: Any) -> None: + for value in _string_values(node): + if _CREDENTIAL_RE.search(value): + f.error("credential", + f"{label}: credential spelling in a public field: {value[:60]!r}") + if _ENDPOINT_RE.search(value): + f.error("endpoint", + f"{label}: raw endpoint in a public field: {value[:60]!r}") + + +def check_model_catalog(f: Findings, label: str, doc: dict) -> None: + """Catalog rules beyond the shape. `model_id` uniqueness and the routing + declaration's seven cross-entry rules; the credential/endpoint scan over + every public string. + + THE contract-v2.2 MODALITY RULES ARE NOT HERE, AND THAT IS THE POINT. All + three refusals the requirement states — a member outside the closed + vocabulary, an empty declared set, a declared set omitting `text` — are + EXPRESSIBLE IN THE SHAPE (`items.enum`, `minItems: 1`, + `contains: {const: text}`), so the released schema this validator already + applies to every instance refuses them, and delegating a fourth spelling + here would be a second gate to keep in step with no rule to enforce. The + three packaged negatives under `negative/` prove the refusal happens rather + than asserting that it would; nothing about a modality declaration needs a + SECOND entry or a comparison the shape has no operator for, which is the + test everything in this function meets.""" + entries = [e for e in doc.get("models") or [] if isinstance(e, dict)] + seen = set() + for entry in doc.get("models") or []: + mid = entry.get("model_id") if isinstance(entry, dict) else None + if mid in seen: + f.error("catalog", f"{label}: duplicate model_id {mid!r}") + seen.add(mid) + _scan_public_strings(f, label, doc.get("models")) + check_routing_rules(f, label, entries) + + +def check_routing_rules(f: Findings, label: str, entries: list[dict]) -> None: + """The contract-v1.38 routing declaration's rules that the released shape + cannot express (`$defs/model_entry` says so in its own comments, and + delegates them here). SEVEN of them, since adversarial review round 1. + + The shape enforces what is expressible per entry: the three fields travel + together, `routes_to` is unique and non-empty, and a `routing_rule: false` + entry may carry neither routing field. Everything below needs either the + WHOLE catalog or a comparison the shape has no operator for — and the same + seven are enforced at catalog construction in + `scripts/ideation_dashboard/doxbench_model.py` + (`ModelCatalog._validate_routing_targets` plus the per-entry + `_validate_routing_declaration`, which is where rules 6 and 7 live on that + side because one entry is enough to see them). Neither place substitutes + for the other: an in-process catalog never becomes a file, and a file is + never constructed through that type. The claim that the two agree is + ASSERTED by a test over the packaged negatives, not merely stated here — + review round 1 found this docstring claiming parity that did not hold. + + 1. no dangling target; + 2. no chained rule (`resolved_model_id` records the model that ANSWERED, so + it must name something that answers); + 3. an available rule resolves to an available model; + 4. THE BADGE COVERING — the ratified scenario's own THEN: a routing entry + MUST "carry the handling badge of every model it may route to", and the + entry's own `data_handling` is the one badge string the menu shows for + it, so each target's badge must be one SEGMENT of it (see + `normalized_badge_segment`; a target badge holding the separator is + ill-formed and refused); + 5. a rule promises no more headroom than THE MODEL THAT ANSWERS — the + effective turn limit is computed from the SELECTED entry, which for a + routed turn is the RULE, so an AVAILABLE rule's declared limits must not + exceed those of `resolved_model_id`'s entry. RULED BY BRETT 2026-08-21 + ("Swap to rule 5'"): this was first a MINIMUM over every member of + `routes_to`, which the adversarial review upheld only with reservation. + Under static resolution the promise that matters is the one the + ANSWERING model has to honour; the un-resolved destinations are not + load-bearing; and min-capping would bake in semantics that contradict + the sanctioned per-turn fit-aware router staged as + `ideation/staging/doxchat-auto-fit-routing/`. Unavailable rules are + exempt, as they are from rule 3; + 6. `resolved_model_id` must be a MEMBER of `routes_to`; + 7. a rule must not name ITSELF in `routes_to`. + + RULES 6 AND 7 WERE ADDED AT ADVERSARIAL REVIEW ROUND 1 (F2), and the + docstring they replace claimed the opposite — that they were "the + schema's/type's per-entry business" and that "a structurally invalid + instance never reaches this function". That was FALSE in the direction that + matters: the schema cannot express either rule, so this file gate was + strictly WEAKER than the type gate, and the reviewer walked a catalog past + it whose rule was badged safe while resolving to a model badged "retained + and used for vendor model training". Rule 7 is checked EXPLICITLY rather + than left to fall out of rule 2, so a self-reference is reported as what it + is instead of as "routes to something that is itself a routing rule".""" + by_id = {str(entry.get("model_id")): entry for entry in entries} + for entry in entries: + if entry.get("routing_rule") is not True: + continue + rule_id = str(entry.get("model_id")) + declared_segments = badge_segments(entry.get("data_handling") or "") + targets = entry.get("routes_to") + target_ids = [str(t) for t in targets] if isinstance(targets, list) else [] + for target_id in target_ids: + # ALSO A DIAGNOSTIC (F2b asked for it as one: "explicit check, not + # incidental via the chained-rule rule"). A rule that names itself + # names a routing rule, so the chained-rule arm below refuses the + # same catalog — reporting "routes to something that is itself a + # routing rule", which is true and useless. Guarded by a code test. + if target_id == rule_id: + f.error("routing-self-reference", + f"{label}: routing rule {rule_id!r} names ITSELF in " + f"routes_to — a rule resolves to a model that answers, " + f"never back to the rule") + continue + target = by_id.get(target_id) + if target is None: + f.error("routing-target", + f"{label}: routing rule {rule_id!r} routes to " + f"{target_id!r}, which is not in this catalog") + continue + if target.get("routing_rule") is True: + f.error("routing-target", + f"{label}: routing rule {rule_id!r} routes to " + f"{target_id!r}, which is itself a routing rule — a " + f"resolved model must be one that answers") + continue + target_badge = str(target.get("data_handling") or "") + # A DIAGNOSTIC, not an independent refusal — and revert-testing is + # what proved it. A badge holding the separator can never BE a + # segment, so the covering check below refuses the same catalog + # either way; it just refuses it with a message that sends the + # operator to add a badge which will still not match. This arm names + # the real cause. Its guard is therefore a test on the finding CODE, + # not on the mere fact of refusal. + # + # Against the NORMALIZED badge (review re-verify N3), and not merely + # for the message: a badge like `"read /\nwrite"` holds no raw + # " / " but collapses onto one, so it slipped this arm AND passed + # the covering check — a full ACCEPT of an ill-formed badge. + if ROUTING_BADGE_SEPARATOR in normalized_badge_segment(target_badge): + f.error("routing-badge", + f"{label}: the data_handling badge of {target_id!r} " + f"contains {ROUTING_BADGE_SEPARATOR!r}, the routing " + f"badge's own segment separator, so it cannot be " + f"carried as one") + elif normalized_badge_segment(target_badge) not in declared_segments: + f.error("routing-badge", + f"{label}: routing rule {rule_id!r} does not carry the " + f"data-handling badge of {target_id!r} as a SEGMENT of " + f"its own badge — a routing entry reports the posture " + f"of every model it may route to") + resolved_id = str(entry.get("resolved_model_id")) + resolved = by_id.get(resolved_id) + # RULE 5' (Brett's ruling 2026-08-21): the bound is the RESOLVED + # model's, not the minimum over `routes_to`, and it applies only while + # the rule is selectable — the same exemption rule 3 already carries. + if resolved is not None and entry.get("available") is True: + for field in ("input_limit_bytes", "output_limit_bytes"): + declared = entry.get(field) + answering = resolved.get(field) + if not (isinstance(declared, int) and isinstance(answering, int)): + continue + if declared > answering: + f.error("routing-limit", + f"{label}: routing rule {rule_id!r} declares " + f"{field} {declared}, above the {answering} of " + f"{resolved_id!r}, the model it resolves to") + if resolved_id not in target_ids: + # F2: the SHAPE cannot express this, so without it a rule could be + # badged safe and resolve to a model whose posture it never states — + # every covering check above runs over `routes_to`, which such a + # resolved id is not in. + f.error("routing-resolution", + f"{label}: routing rule {rule_id!r} resolves to " + f"{resolved_id!r}, which it does not declare it may route " + f"to (routes_to {sorted(target_ids)}) — so nothing checked " + f"that its badge is carried") + if resolved is None: + f.error("routing-target", + f"{label}: routing rule {rule_id!r} resolves to " + f"{resolved_id!r}, which is not in this catalog") + elif entry.get("available") is True and resolved.get("available") is not True: + f.error("routing-availability", + f"{label}: routing rule {rule_id!r} is available but " + f"resolves to {resolved_id!r}, which is not") + + +def _confined(f: Findings, label: str, where: str, path_value) -> None: + if path_value is None: + # A not-yet-created artifact has no path yet (the null-path -> create + # lifecycle); nullability is the schema's decision, confinement only + # judges paths that exist. + return + text = str(path_value) + if text.startswith("/") or ".." in text.split("/"): + f.error("path", f"{label}: {where}: path escapes the checkout: {text!r}") + + +def check_turn_request(f: Findings, label: str, doc: dict, + model_ctx: dict[str, int] | None) -> None: + buffers = [b for b in doc.get("buffers") or [] if isinstance(b, dict)] + check_reserved_document_paths(f, label, buffers, V1_RESERVED_DOCUMENT_PATHS) + kinds = sorted(str(b.get("kind")) for b in buffers) + if kinds != ["document", "outline"]: + f.error("buffers", f"{label}: exactly one outline and one document " + f"buffer required, got {kinds}") + _confined(f, label, "active_document_path", doc.get("active_document_path", "")) + total_bytes = 0 + for b in buffers: + if not isinstance(b, dict): + continue + _confined(f, label, f"buffers/{b.get('kind')}/path", b.get("path", "")) + content = str(b.get("content", "")) + total_bytes += len(content.encode("utf-8")) + declared = str(b.get("content_hash", "")) + actual = _hashlib.sha256(content.encode("utf-8")).hexdigest() + if declared != actual: + f.error("hash", f"{label}: buffers/{b.get('kind')}: content_hash " + f"mismatch (declared {declared[:12]}…, actual {actual[:12]}…)") + if model_ctx is None: + f.warnings.append(f"{label}: model context unavailable — unknown-model " + f"and budget checks SKIPPED (supply a catalog instance)") + return + mid = str(doc.get("model_id", "")) + if mid not in model_ctx: + f.error("unknown-model", + f"{label}: model_id {mid!r} is not in the approved catalog") + return + limit = model_ctx[mid] + if limit and total_bytes > limit: + f.error("budget", f"{label}: request buffers total {total_bytes} bytes " + f"over model {mid!r} input limit {limit}") + + +# THE RESERVED KEYS A DOCUMENT'S OWN PATH MAY NOT CLAIM, per lane (Codex review +# of PR #210, CODEX-3). Restated here rather than imported: this validator is +# PUBLISHED BY EXACT COMMIT and run from a pinned checkout against an arbitrary +# target repository, so it must not import the runtime package that happens to +# sit beside it in the publisher. The pairing with +# `ideation_dashboard.doxbench_turns.RESERVED_BUFFER_KEYS` / +# `V1_RESERVED_BUFFER_KEYS` is asserted by a companion test instead, which is the +# only way to make a restatement safe. +# +# The asymmetry is the runtime's own and is load-bearing. `outline` is refused on +# BOTH lanes: a document keyed there is filtered out of every downstream +# enumeration. `document` is refused on the WIDENED lane only, where the reserved +# unbacked slot can ride beside a path-backed document; the v1 envelope carries +# exactly one document whose key is `document` either way, and such a turn was +# served before this release. +V1_RESERVED_DOCUMENT_PATHS = frozenset({"outline"}) +V2_RESERVED_DOCUMENT_PATHS = frozenset({"outline", "document"}) + + +def check_reserved_document_paths(f: Findings, label: str, buffers: list, + refused: frozenset) -> None: + """Refuse a document buffer whose own PATH claims a reserved buffer key. + + Without this the family's declared owner certified an envelope the route + always refuses — conformance for a shape that cannot be processed, which is + worse than no verdict.""" + for buffer in buffers: + if str(buffer.get("kind")) != "document": + continue + if buffer.get("path") in refused: + f.error("reserved-key", + f"{label}: a document buffer's path claims the reserved " + f"buffer key {buffer.get('path')!r}; the route refuses this " + f"before any provider call") + + +def _buffer_key_of(buffer: dict) -> str: + """The KEY a buffer is held under (add-doxbench-editing-phase-b design D1), + derived exactly as the runtime derives it: the outline's key is reserved, a + document's key IS its own path, and a document with no path yet takes the one + reserved unbacked slot. Never invented, and never read off an adjacent field + that answers a different question.""" + if str(buffer.get("kind")) == "outline": + return "outline" + path = buffer.get("path") + return "document" if path is None else str(path) + + +def check_turn_request_v2(f: Findings, label: str, doc: dict, + model_ctx: dict[str, int] | None) -> None: + """The widened request's rules the shape cannot express: one outline plus one + or more DISTINCTLY KEYED documents, and a DECLARED binding that names one of + the buffers this same request supplied. The hash, confinement, unknown-model + and budget rules are the v1 ones, applied per buffer over a set instead of a + pair.""" + buffers = [b for b in doc.get("buffers") or [] if isinstance(b, dict)] + check_reserved_document_paths(f, label, buffers, V2_RESERVED_DOCUMENT_PATHS) + outlines = [b for b in buffers if str(b.get("kind")) == "outline"] + documents = [b for b in buffers if str(b.get("kind")) == "document"] + if len(outlines) != 1 or not documents: + f.error("buffers", f"{label}: exactly one outline buffer and at least " + f"one document buffer required, got " + f"{len(outlines)} outline(s) and " + f"{len(documents)} document(s)") + keys: list[str] = [_buffer_key_of(b) for b in buffers] + duplicates = sorted({key for key in keys if keys.count(key) > 1}) + if duplicates: + f.error("buffers", f"{label}: two buffers claim the same key " + f"{duplicates} — a document is loaded at most once, " + f"and 'which text did the model see' must have one " + f"answer") + bound = doc.get("bound_buffer") + if str(bound) not in keys: + f.error("bound-buffer", + f"{label}: bound_buffer {bound!r} names no supplied buffer " + f"(supplied {sorted(set(keys))})") + _confined(f, label, "bound_buffer", bound) + total_bytes = 0 + for b in buffers: + key = _buffer_key_of(b) + _confined(f, label, f"buffers/{key}/path", b.get("path", "")) + content = str(b.get("content", "")) + total_bytes += len(content.encode("utf-8")) + declared = str(b.get("content_hash", "")) + actual = _hashlib.sha256(content.encode("utf-8")).hexdigest() + if declared != actual: + f.error("hash", f"{label}: buffers/{key}: content_hash " + f"mismatch (declared {declared[:12]}…, actual {actual[:12]}…)") + if model_ctx is None: + f.warnings.append(f"{label}: model context unavailable — unknown-model " + f"and budget checks SKIPPED (supply a catalog instance)") + return + mid = str(doc.get("model_id", "")) + if mid not in model_ctx: + f.error("unknown-model", + f"{label}: model_id {mid!r} is not in the approved catalog") + return + limit = model_ctx[mid] + if limit and total_bytes > limit: + f.error("budget", f"{label}: request buffers total {total_bytes} bytes " + f"over model {mid!r} input limit {limit}") + + +def check_turn_success(f: Findings, label: str, doc: dict) -> None: + targets = [p.get("target") for p in doc.get("proposals") or [] + if isinstance(p, dict)] + if len(targets) != len(set(targets)): + f.error("proposal", f"{label}: proposal targets must be unique, got {targets}") + + +def check_turn_success_v2(f: Findings, label: str, doc: dict) -> None: + """The widened record's own consistency. It carries every buffer's observed + identity by KEY, so three rules the v1 record could not state become + checkable here: a proposal targets a buffer the turn actually held, the + proposal count is bounded by that buffer count rather than by a literal 2, + and the record's declared binding names one of those same buffers.""" + observed = doc.get("observed_hashes") + observed = observed if isinstance(observed, dict) else {} + proposals = [p for p in doc.get("proposals") or [] if isinstance(p, dict)] + targets = [p.get("target") for p in proposals] + if len(targets) != len(set(targets)): + f.error("proposal", f"{label}: proposal targets must be unique, got {targets}") + if len(proposals) > len(observed): + f.error("proposal", + f"{label}: {len(proposals)} proposal(s) against " + f"{len(observed)} supplied buffer(s) — a response may never " + f"rewrite more buffers than it was shown") + for proposal in proposals: + target = str(proposal.get("target")) + if target not in observed: + f.error("proposal-target", + f"{label}: proposal target {target!r} names no buffer this " + f"turn observed — unroutable, never guessed at") + continue + if str(proposal.get("base_hash")) != str(observed[target]): + f.error("proposal", + f"{label}: proposal {target!r} is based on an identity the " + f"turn did not observe for that buffer") + bound = str(doc.get("bound_buffer")) + if bound not in observed: + f.error("bound-buffer", + f"{label}: bound_buffer {bound!r} names no buffer this turn " + f"observed (observed {sorted(observed)})") + selected = doc.get("selected_model") + selected = selected if isinstance(selected, dict) else {} + if (selected.get("routing_rule") is False + and str(selected.get("requested_model_id")) != str(doc.get("model_id"))): + f.error("selected-model", + f"{label}: a non-routing catalog entry cannot resolve to a " + f"different model (requested " + f"{selected.get('requested_model_id')!r}, answered " + f"{doc.get('model_id')!r})") + check_context_packet(f, label, doc) + + +def check_context_packet(f: Findings, label: str, doc: dict) -> None: + """The contract-v1.40 posture statement's own rules (task 10.7). + + THREE of them now, and they are different in kind. The first the SHAPE also + expresses, and it is restated here ON PURPOSE: it is this release's whole + truth-claim — the reason is present IFF the posture is reduced — and the + v1.38 review's F2 finding was precisely a file gate that had grown weaker + than the type gate beside it while its own docstring claimed parity. Three + gates now assert this pairing (the released shape's two conditionals, + `ContextPacket.__post_init__`, and this), a test asserts they AGREE on the + packaged corpus, and each of the two halves has its own packaged negative. + BE HONEST ABOUT WHAT IT IS, THOUGH: revert-testing this release found that + disabling BOTH arms below leaves this validator's own packaged self-test + GREEN, because the shape refuses the same two instances anyway. So they are + DEFENCE IN DEPTH and the diagnostic a reader of this output actually gets — + not an independent refusal — and their only guard is the test that pins + their finding CODE. Same class as the `contract-v1.38` arms whose own + revert-tests said the same thing. + The second and third rules the shape CANNOT express. The second lives ONLY + here. The third — the NON-BLANK rule (issue #263) — is a DELEGATED rule + with four homes, and this docstring used to say the pairing was the only + restated one, which stopped being true when that rule landed: + + * `doxbench_packet.states_something` — the canonical statement, with the + measurement of which blank classes `.strip()` misses; + * `doxbench_packet.ContextPacket.__post_init__` and + `serve.doxbench_context_packet`, which IMPORT it; + * `_states_something` in this file, which RESTATES it because a contract + validator must not import the runtime package it validates artifacts + for — a shared bug would pass both; + * `NON_BLANK_REASON` in `web/views/doxbench-chat-model.js`, restated as a + Unicode-property regex. + + A test asserts the Python restatement and the JS regex agree with the + canonical predicate across the WHOLE code-point space, not on a sample. + + The four homes agree on the predicate and differ on the CONSEQUENCE: this + validator WARNS (contract-v1.40 accepted these records, and the versioning + policy makes a new validator warning additive and a new rejection breaking), + while the three runtime gates REFUSE, because a server may hold itself to + more than the wire requires and none of them is judging a third party. The released schema cannot + express any of this: `minLength: 1` counts CHARACTERS, and every blank class + is exactly one character. Tightening the shape itself is a `contract-v2.0` + question, not an additive one. + + 1. THE PAIRING. A `reduced` posture STATES its reason; a `full` posture + carries none. A reduction nobody can read is a silent degradation, and a + record declaring `full` beside a reduction states two contradictory facts + and lets the reader pick. + 2. THE REASON IS PUBLIC PROSE, and is LINTED for credential and endpoint + spellings exactly as a failure's `message` is. It is the one free-prose + field this release adds, it describes an ASSEMBLY rather than content, + and the same leak-through-an-allowed-field class the failure lane + already watches applies to it unchanged. + CALL IT A LINT, NOT A GUARD (adversarial review N1). `_CREDENTIAL_RE` and + `_ENDPOINT_RE` are spelling heuristics over free prose: they FALSE-POSITIVE + on innocent text that happens to say `api_key` (the reviewer's example, + "the apikey rotation lane", is refused) and they FALSE-NEGATIVE on real + secrets that do not look like their patterns (a bare + `github_pat_…`-shaped token passes). So this arm raises the cost of a + careless paste and catches the obvious shapes; it does NOT establish that + a reason is secret-free, and nothing downstream may treat a clean scan as + if it did. The structural protection is elsewhere and is real: the reason + this producer emits is a MODULE CONSTANT, not a formatted provider error, + so there is no value flowing into it for a scan to have to catch. + + 3. THE REASON STATES SOMETHING — it contains at least one character in + `L* ∪ N* ∪ P* ∪ S*` (a letter, number, punctuation mark or symbol). + "A reduction nobody can read" was implemented as NON-EMPTY, and nine + blank classes passed every gate and rendered as a disclosure with + nothing in it (issue #263). Not reachable from this repository's + producer — the two shipped reasons are module constants — but real for + the third-party producers this contract is published for. + + STATED AS WHAT IT ADMITS, and the difference is not cosmetic. The rule + was first written as an EXCLUSION ("outside White_Space ∪ Cc ∪ Cf ∪ Mn ∪ + Mc ∪ Me"), which is a rule over a set that grows with the Unicode table: + a full code-point sweep found 51 points where this file's Python + (unicodedata 15.0.0) and the browser's ICU (Unicode 16) disagreed, all + unassigned here and newly assigned combining marks there — with the + server side ACCEPTING what the browser refused, which is the original + bug. `Cn` is never L/N/P/S in any table, so admission is stable by + construction. + + THE ADMISSION RULE IS STRICTLY NARROWER, and the delta is named here + because a reader comparing the two spellings must not have to derive it + (PR #314, Codex P2). Beyond every blank class above it also refuses + `Co` (PRIVATE USE, e.g. U+E000), `Cn` (unassigned) and `Cs` + (surrogates). That is deliberate and rides the same fail-closed + rationale: a private-use code point has no meaning outside the font that + defines it, so a reason made only of them renders as tofu for every + reader who lacks that font — which is the disclosure-with-nothing-in-it + this rule exists to prevent, in a different disguise. + + THIS GATE WARNS; THE RUNTIME GATES REFUSE — see the arm itself for why. + The rule is one rule and the four homes agree on the VERDICT of the + predicate; they differ only in what each surface does with it, because + only this one is a conformance verdict on somebody else's record. + + A FOURTH RULE WAS CONSIDERED AND REJECTED: requiring the reason to say in as + many words that nothing unbounded was substituted and no rail was bypassed + (which the two reasons this capability ships both do). It is prose-matching + a contract — it would refuse a conformant producer whose honest reason is + worded differently, and it would pass a dishonest one that quoted the + sentence. What the wire can check is that a reduction is STATED; whether the + statement is TRUE is the assembler's rail, which is where it is enforced.""" + packet = doc.get("context_packet") + if packet is None: + # ABSENT IS LEGAL AND MEANS NOTHING ABOUT THE POSTURE: the key is + # optional, and a record from a producer older than contract-v1.40 + # simply does not carry one. Absence is never read as `full`. + return + if not isinstance(packet, dict): + f.error("context-packet", + f"{label}: context_packet is the released posture object") + return + posture = packet.get("posture") + reason = packet.get("reduced_reason") + # NON-BLANK, AS A WARNING (issue #263, and PR #314's Codex P1). `not reason` + # is the released shape's `minLength: 1` restated, and that bound counts + # CHARACTERS — so a reason of one space, one ZWSP, one BOM, one bidi + # override, one combining mark or one control satisfied it and rendered as + # a disclosure with nothing in it. + # + # WHY A WARNING AND NOT AN ERROR, which is where this rule started. THIS + # FILE IS THE PUBLISHED CONFORMANCE VALIDATOR: its verdict on a third + # party's record IS the contract surface, so turning a previously-accepted + # record into a rejected one changes what the PUBLISHED + # `contract-v1.40` means — and the bundle version, the changelog entry and + # the tag all still say v1.40. Reproduced at the tag: the released + # validator answers `{posture: "reduced", reduced_reason: " "}` with zero + # errors and zero warnings. + # + # `docs/contract-versioning-policy.md` decides this, in as many words. + # ADDITIVE (minor) is "new optional fields, new contracts, NEW VALIDATOR + # WARNINGS", under which "domain repos on the same major version remain + # conformant without changes". Rejecting a shape that was accepted is the + # BREAKING (major) class, which requires a migration note, a full minor + # release of deprecation warnings first, and a validator that "rejects the + # old one ONLY AT THE NEW MAJOR VERSION". + # + # The pairing rule two arms down IS an error, and that is not a licence for + # this one: `check_context_packet` and all three of its error arms were + # introduced BY the v1.40 cut itself (671a6908, the tagged release commit), + # so their strictness was published WITH the contract rather than added to + # it afterwards. A warning here is the same mechanism the v1 chat-turn + # family already rides — deprecated, warned on, still accepted, with a + # removal target recorded — and it becomes an ERROR at contract-v2.0, where + # the shape's own `minLength: 1` is due to be tightened. + # + # THE RUNTIME GATES STILL REFUSE. A server may hold ITSELF to more than the + # wire requires: `ContextPacket.__post_init__` guards this repo's own + # injected assembler seam, `doxbench_context_packet` guards what this server + # writes into its own durable record, and the browser adopter guards what + # this repo's own surface renders. None of those is a conformance verdict on + # somebody else's record, and this producer cannot emit a blank anyway — its + # two reasons are module constants, pinned under the released ceiling. + # TWO ARMS, SPLIT ON EXACTLY WHAT contract-v1.40 ALREADY REFUSED. Caught by + # the packaged-negative suite when the first version of this fix collapsed + # them: one predicate served BOTH the pairing rule and the blank rule, so + # downgrading it to a warning silently downgraded the PAIRING too — and that + # one shipped as an error WITH v1.40 and must stay one. + # + # v1.40's predicate was `not reason`, which is true for a MISSING key, an + # explicit `null`, and `""`. Those stay ERRORS: refusing them changes + # nothing about what the published version accepted. + if posture == "reduced" and not reason: + f.error("context-packet", + f"{label}: a reduced context_packet STATES its reason — a " + f"reduction nobody can read is a silent degradation") + # A reason that is PRESENT and non-empty but says nothing is the new rule, + # and it is the one contract-v1.40 accepted — so it warns and is accepted. + elif posture == "reduced" and not _states_something(reason): + f.warn("context-packet-blank", + f"{label}: a reduced context_packet STATES its reason — a " + f"reduction nobody can read is a silent degradation. Accepted " + f"at contract-v1.40 (the released shape's `minLength: 1` counts " + f"CHARACTERS, and every blank class is one character); this " + f"becomes an ERROR at contract-v2.0") + # KEY PRESENCE, which is what the shape's `not: {required: [...]}` means. + # `reason is not None` was closer than truthiness but still not it: a + # `reduced_reason: null` is a key that is PRESENT, and `.get()` cannot tell + # it from an absent one — so this gate accepted an instance the shape + # refuses. Found while fixing the same class at the type and the route + # (Copilot review of PR #256, finding 1); the reviewer named two gates and + # there were four. + if posture == "full" and "reduced_reason" in packet: + f.error("context-packet", + f"{label}: a full context_packet carries no reduced_reason — a " + f"record cannot state both postures and let a reader pick") + _scan_public_strings(f, label, reason) + + +def check_turn_failure(f: Findings, label: str, doc: dict) -> None: + has_limit = "limit" in doc + is_budget = doc.get("error") == "request_limit_exceeded" + if has_limit != is_budget: + f.error("failure-limit", + f"{label}: `limit` appears iff error is request_limit_exceeded " + f"(error={doc.get('error')!r}, " + f"limit={'present' if has_limit else 'absent'})") + _scan_public_strings(f, label, doc.get("message")) + + +def check_turn_id_uniqueness(f: Findings, paths) -> None: + """Sweep rule: the same client_turn_id with DIFFERENT request content is + the idempotency conflict FR-019 refuses before dispatch.""" + seen: dict[str, tuple[str, str]] = {} + for path in paths: + doc = load_yaml(path) + # One request kind since contract-v3.0; the v1 spelling left the tuple + # with its envelope (retire-doxbench-chat-turn-v1). A tuple rather than + # a bare comparison because the rule is about REQUEST kinds as a class, + # and the next co-resident family would join it here. + if not (isinstance(doc, dict) and doc.get("kind") in ( + "workbench-chat-turn-v2",)): + continue + tid = str(doc.get("client_turn_id")) + # Canonicalize buffer order before hashing: a retransmission that merely + # reorders the buffers is the SAME request, not an FR-019 conflict. + # Ordering is by buffer KEY rather than by kind, because the widened + # request holds N documents and every one of them declares `document`. + canonical = dict(doc) + canonical["buffers"] = sorted( + (b for b in doc.get("buffers") or [] if isinstance(b, dict)), + key=_buffer_key_of) + digest = _hashlib.sha256( + _json.dumps(canonical, sort_keys=True).encode()).hexdigest() + if tid in seen and seen[tid][0] != digest: + f.error("duplicate-turn", + f"{path.name}: duplicate-turn id {tid!r} with different " + f"content (first seen in {seen[tid][1]})") + seen.setdefault(tid, (digest, path.name)) + + +# --------------------- layer 1: packaged reference examples --------------------- + +def check_examples(f: Findings, registry: Registry, docs: dict[str, dict]) -> None: + """GATES self-test: every packaged example validates exactly as its filename + and header comment claim — valid pass, each negative fails for its intended + reason, each transition pair matches its declared expectation.""" + if not EXAMPLES_DIR.is_dir(): + f.error("examples-missing", f"{EXAMPLES_DIR} not found") + return + + # The snapshot example doubles as ratification context for the gate examples. + ratified_ctx: set[str] = set() + model_ctx: dict[str, int] = {} + valid_docs: list[tuple[str, Any, str | None]] = [] + for path in sorted(EXAMPLES_DIR.glob("*.example.yaml")): + doc = load_yaml(path) + ratified_ctx |= ratified_change_ids_from(doc) + model_ctx.update(catalog_model_ids_from(doc)) + for path in sorted(EXAMPLES_DIR.glob("*.example.yaml")): + sub = Findings() + doc = load_yaml(path) + tag = validate_instance(sub, path.name, doc, registry, docs, ratified_ctx, + model_ctx=model_ctx) + valid_docs.append((path.name, doc, tag)) + if sub.errors: + for e in sub.errors: + f.error("example-invalid", f"{path.name}: expected valid: {e}") + f.warnings.extend(sub.warnings) + valid_count = len(valid_docs) + + invalid_count = check_negative_examples(f, registry, docs, ratified_ctx, + model_ctx) + pairs = check_transition_examples(f, registry) + + f.note(f"examples: {valid_count} valid example(s) confirmed valid, " + f"{invalid_count} negative example(s) confirmed invalid, " + f"{pairs} transition pair(s) confirmed") + + +def check_negative_examples( + f: Findings, registry: Registry, docs: dict[str, dict], ratified_ctx: set[str], + model_ctx: dict[str, int] | None = None, +) -> int: + neg_dir = EXAMPLES_DIR / "negative" + if not neg_dir.is_dir(): + f.error("examples-missing", f"{neg_dir} not found") + return 0 + checked = 0 + for path in sorted(neg_dir.glob("*.yaml")): + sub = Findings() + doc = load_yaml(path) + # Negatives are validated WITH the example ratification context so the + # context-dependent kickoff-precondition negative can fail as intended. + validate_instance(sub, f"negative/{path.name}", doc, registry, docs, + ratified_ctx, model_ctx=model_ctx) + if not sub.errors: + f.error("example-should-fail", + f"negative/{path.name}: expected invalid, produced no error") + continue + checked += 1 + # The duplicate-turn PAIR negative: two files whose shared client_turn_id + # carries different content — refused by the sweep rule, not per-file. + pair_dir = neg_dir / "duplicate-turn-pair" + if pair_dir.is_dir(): + sub = Findings() + check_turn_id_uniqueness(sub, sorted(pair_dir.glob("*.yaml"))) + if sub.errors: + checked += 1 + else: + f.error("example-should-fail", + "negative/duplicate-turn-pair: expected the duplicate-turn " + "sweep to refuse, produced no error") + return checked + + +def check_transition_examples(f: Findings, registry: Registry) -> int: + """Transition pairs live under examples/ideation-dashboard/transitions/ as + `.before.yaml` / `.after.yaml`; a `valid-` prefix expects the + transition to pass, otherwise it must fail.""" + tdir = EXAMPLES_DIR / "transitions" + if not tdir.is_dir(): + f.error("examples-missing", f"{tdir} not found") + return 0 + pairs = 0 + for before in sorted(tdir.glob("*.before.yaml")): + case = before.name[: -len(".before.yaml")] + after = tdir / f"{case}.after.yaml" + if not after.is_file(): + f.error("transition-unpaired", f"transitions/{before.name}: no matching .after.yaml") + continue + old = (load_yaml(before) or {}).get(REGISTER_CONTAINER_KEY) or [] + new = (load_yaml(after) or {}).get(REGISTER_CONTAINER_KEY) or [] + sub = Findings() + check_register_transition(sub, f"{case}.before", old, f"{case}.after", new) + expect_pass = case.startswith("valid-") + if expect_pass and sub.errors: + for e in sub.errors: + f.error("transition-invalid", f"transitions/{case}: expected legal: {e}") + elif not expect_pass and not sub.errors: + f.error("transition-should-fail", + f"transitions/{case}: expected an illegal transition, validated cleanly") + pairs += 1 + return pairs + + +# --------------------- layer 2: real repository instances --------------------- + +def check_repo_tree( + f: Findings, registry: Registry, docs: dict[str, dict], repo: Path, context: set[str] | None, +) -> None: + """Validate any real instances of the five kinds committed under the repo + (excluding the reference examples tree, the schema files, and the test + trees — `tests/` carries deliberately-INVALID negative fixtures for the + dashboard runtime suite adopted by `adopt-neutral-tooling-home`, and a + test fixture is not a real instance), and run the + committed-workbench-manifest guard.""" + check_committed_manifests(f, repo) + + checked = 0 + for path in sorted(list(repo.rglob("*.yaml")) + list(repo.rglob("*.yml"))): + rel = path.relative_to(repo).as_posix() + if rel.startswith(("examples/", "contracts/schemas/", "tests/")) or "/__pycache__/" in rel: + continue + try: + doc = load_yaml(path) + except yaml.YAMLError: + continue + if detect(doc) is None: + continue + # A tracked ideation-workbench manifest is already reported by the guard. + if isinstance(doc, dict) and doc.get("kind") == "ideation-workbench": + continue + validate_instance(f, rel, doc, registry, docs, context) + checked += 1 + f.note(f"repo tree ({repo}): {checked} real ideation-dashboard-family instance(s) checked " + f"(none is normal pre-realization)") + + +# --------------------------- orchestration --------------------------- + +def run_default(repo: Path, strict: bool) -> int: + f = Findings() + if not SCHEMAS_DIR.is_dir(): + print(f"ERROR {SCHEMAS_DIR} not found ({CONTRACTS_SOURCE})", file=sys.stderr) + return 2 + registry, docs = build_registry() + for name, doc in docs.items(): + try: + Draft202012Validator.check_schema(doc) + except Exception as exc: # noqa: BLE001 + f.error("schema-meta-invalid", f"{name}: {exc}") + + check_examples(f, registry, docs) + # The packaged snapshot example supplies ratification context for any real + # gate-action records found in the tree. + ctx, ctx_notes = load_context(EXAMPLES_DIR / "ideation-dashboard-snapshot.example.yaml") + for n in ctx_notes: + f.note(n) + check_repo_tree(f, registry, docs, repo, ctx) + return report(f, strict) + + +def run_path(path: Path, context_path: Path | None, strict: bool) -> int: + f = Findings() + registry, docs = build_registry() + ctx, ctx_notes = load_context(context_path) + for n in ctx_notes: + f.note(n) + if path.is_dir(): + files = sorted(list(path.rglob("*.yaml")) + list(path.rglob("*.yml"))) + # Directory sweep: sibling records provide ratification context. + if ctx is None: + ctx = set() + for fp in files: + try: + ctx |= ratified_change_ids_from(load_yaml(fp)) + except yaml.YAMLError: + continue + checked = 0 + for fp in files: + try: + doc = load_yaml(fp) + except yaml.YAMLError as exc: + f.error("yaml", f"{fp}: {exc}") + continue + if detect(doc) is None: + continue + validate_instance(f, str(fp), doc, registry, docs, ctx) + checked += 1 + f.note(f"directory sweep {path}: {checked} recognized instance(s) checked") + elif path.is_file(): + try: + doc = load_yaml(path) + except yaml.YAMLError as exc: + print(f"ERROR {path}: {exc}", file=sys.stderr) + return 2 + validate_instance(f, str(path), doc, registry, docs, ctx) + else: + print(f"ERROR path not found: {path}", file=sys.stderr) + return 2 + return report(f, strict) + + +def run_transition(old_path: Path, new_path: Path, strict: bool) -> int: + f = Findings() + registry, docs = build_registry() + for label, p in (("OLD", old_path), ("NEW", new_path)): + if not p.is_file(): + print(f"ERROR {label} path not found: {p}", file=sys.stderr) + return 2 + old_doc, new_doc = load_yaml(old_path), load_yaml(new_path) + # Each side must itself be a well-formed register section. + sv = section_validator(registry) + for lbl, d in ((str(old_path), old_doc), (str(new_path), new_doc)): + section = d.get(REGISTER_CONTAINER_KEY) if isinstance(d, dict) else None + if not isinstance(section, list): + f.error("transition-shape", + f"{lbl}: not a register section (missing {REGISTER_CONTAINER_KEY!r} list)") + continue + for e in iter_errors(sv, section): + loc = "/".join(str(p) for p in e.absolute_path) or "" + f.error("schema", f"{lbl}: {REGISTER_CONTAINER_KEY}/{loc}: {e.message}") + if not f.errors: + check_register_transition( + f, str(old_path), old_doc.get(REGISTER_CONTAINER_KEY) or [], + str(new_path), new_doc.get(REGISTER_CONTAINER_KEY) or [], + ) + return report(f, strict) + + +def report(f: Findings, strict: bool) -> int: + for line in f.notes: + print(line) + for line in f.warnings: + print(line) + for line in f.errors: + print(line) + n_e, n_w = len(f.errors), len(f.warnings) + print(f"\nvalidate-ideation-dashboard-contracts: {n_e} error(s), {n_w} warning(s)") + if n_e or (strict and n_w): + return 1 + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("path", nargs="?", default=None, + help="file (kind auto-detected) or directory to validate; " + "omit to self-test packaged examples and scan this checkout") + ap.add_argument("--transition", nargs=2, metavar=("OLD", "NEW"), + help="validate register transition legality across two register sections") + ap.add_argument("--context", type=Path, default=None, + help="snapshot or directory supplying ratified-change context for kickoff preconditions") + ap.add_argument("--repo", type=Path, default=ROOT, + help="repo root to scan in default mode (default: this checkout)") + ap.add_argument("--strict", action="store_true", help="treat warnings as errors") + args = ap.parse_args() + try: + if args.transition: + return run_transition(Path(args.transition[0]).resolve(), + Path(args.transition[1]).resolve(), args.strict) + if args.path is not None: + return run_path(Path(args.path).resolve(), args.context, args.strict) + return run_default(Path(args.repo).resolve(), args.strict) + except Exception as exc: # noqa: BLE001 + print(f"ERROR harness failure: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_dependency_direction.py b/tests/test_dependency_direction.py index 49de0b4..fa30adb 100644 --- a/tests/test_dependency_direction.py +++ b/tests/test_dependency_direction.py @@ -160,10 +160,26 @@ def _modules(base: Path) -> list[Path]: return sorted(p for p in base.rglob("*.py") if ".git" not in p.parts) -def _src_census() -> list[tuple[str, int, str, bool]]: - """`(relpath, lineno, root_module, runs_at_import_time)` for all of `src/`.""" +#: The `.py` files under `src/` that are NOT modules of the package, each with +#: why it is there. An `import` statement cannot name a file whose stem is not an +#: identifier, so importing the package never runs one, and the import-time +#: census below leaves them out. They are held instead by +#: `test_a_script_shipped_under_src_reaches_only_declared_dependencies`, and a +#: new one fails `test_every_script_under_src_is_named` until it is named here. +SCRIPTS_UNDER_SRC = { + # plan 034 T061 (#1144 7.3): the consumer validator, shipped as package data + # so an install runs its own. Run as a script, never imported by name. + "src/openxdox/contracts/validate-ideation-dashboard-contracts.py", +} + + +def _is_module(path: Path) -> bool: + return path.stem.isidentifier() + + +def _census_rows(paths: list[Path]) -> list[tuple[str, int, str, bool]]: rows: list[tuple[str, int, str, bool]] = [] - for path in _modules(SRC): + for path in paths: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) rel = path.relative_to(ROOT).as_posix() for node, root, at_import_time in _census(tree): @@ -171,6 +187,17 @@ def _src_census() -> list[tuple[str, int, str, bool]]: return rows +def _src_census() -> list[tuple[str, int, str, bool]]: + """`(relpath, lineno, root_module, runs_at_import_time)` for every MODULE + under `src/`.""" + return _census_rows([p for p in _modules(SRC) if _is_module(p)]) + + +def _script_census() -> list[tuple[str, int, str, bool]]: + """The same rows for the `.py` files under `src/` that are not modules.""" + return _census_rows([p for p in _modules(SRC) if not _is_module(p)]) + + # -------------------------------------------------------------------------- # 1 — this leg's own tree. STRICT; these hold today. # -------------------------------------------------------------------------- @@ -212,6 +239,34 @@ def test_no_module_under_src_reaches_its_consumer_at_import_time() -> None: "declare it in pyproject.toml and add it above, or reach it late") +def test_every_script_under_src_is_named() -> None: + """A `.py` file the import-time census leaves out is one this file names, + so a module cannot leave the census by being given a hyphenated name.""" + scripts = {p.relative_to(ROOT).as_posix() for p in _modules(SRC) if not _is_module(p)} + assert scripts == SCRIPTS_UNDER_SRC, ( + f"non-module .py files under src/: {sorted(scripts)}; named: " + f"{sorted(SCRIPTS_UNDER_SRC)}. Name a new one in SCRIPTS_UNDER_SRC with " + "its reason, or give it an importable name so the census reads it") + + +def test_a_script_shipped_under_src_reaches_only_declared_dependencies() -> None: + """A script the package SHIPS runs wherever the package is installed, so + every name it imports, at any depth, is the standard library, this leg's + own, or a declared runtime dependency (`pyproject.toml`). Nothing is + allowed on `IMPORT_TIME_ALLOWED`'s strength alone: `doc_health` does not + resolve in an install.""" + rows = _script_census() + assert rows, "the census read no script, so this check would pass vacuously" + offenders = sorted({ + f"{rel}:{line} -> {root}" for rel, line, root, _ in rows + if root not in OWN and root not in DEFERRED_ALLOWED and root not in _STDLIB + }) + assert offenders == [], ( + f"a script shipped under src/ reaches undeclared name(s): {offenders}. " + "Declare each in pyproject.toml's `dependencies`, since an install " + "runs the script") + + def test_every_deferred_cross_package_reach_out_of_src_is_known() -> None: """The same list, one name wider: reaches from inside a function body, where the name need only be a DECLARED dependency — never hand-listed a diff --git a/tests/test_packaged_validator.py b/tests/test_packaged_validator.py new file mode 100644 index 0000000..9531750 --- /dev/null +++ b/tests/test_packaged_validator.py @@ -0,0 +1,216 @@ +"""THE PACKAGED VALIDATOR AND ITS THREE COPIES (plan 034 T061; #1144 7.3, RULED +R1Q14 (a), as T007's batch I amends it on R1Q27 (a), `opensoft/openxFactory#656` +comments 5850003126 and 5851950767). + +An install ships no `scripts/`, so it carries its validator, and the three +schemas that validator owns, as package data (`openxdox.contracts`). These +tests hold each shipped piece to what it claims to be: + +* THE VALIDATOR. The packaged copy is byte for byte the repository's + `scripts/validate-ideation-dashboard-contracts.py`, which a source checkout + runs and openxFactory's lanes read at that path. One file, two places; they + cannot drift apart unseen. +* THE COPIES. Each copy's sha256 is the one `copies.yaml` records, and the + record names the spec-leg commit the openXdox root pins (f088b097). The + record is refused, before any copy is read, for each way it can be wrong. +* THE PACKAGE-DATA LINE. `pyproject.toml`'s `"openxdox.contracts"` patterns + match every data file in the package and nothing else, so a wheel ships the + record, the three copies and the validator, and `__init__.py` alone is not + what an install gets. + +The fresh-venv, non-editable run of the same claims is #1144's F7.1, which +the pull request's evidence carries.""" +from __future__ import annotations + +import hashlib +import shutil +import tomllib +from pathlib import Path + +import pytest +import yaml + +from openxdox import contracts + +REPO_ROOT = Path(__file__).resolve().parents[1] +PACKAGE = REPO_ROOT / "src" / "openxdox" / "contracts" +SCRIPT = REPO_ROOT / "scripts" / contracts.VALIDATOR_NAME + +#: The spec-leg commit the openXdox root pins (opensoft/openXdox `main` +#: 57e2b8f2: its `spec` gitlink and `contracts/spec-pin.yaml`). +ROOT_SPEC_PIN = "f088b09732e236279898b53ab9fb0f5ebc89509a" + + +# --------------------------- the validator --------------------------- + +def test_the_packaged_validator_is_the_scripts_validator_byte_for_byte(): + packaged = PACKAGE / contracts.VALIDATOR_NAME + assert packaged.is_file(), f"{packaged} is not shipped" + assert packaged.read_bytes() == SCRIPT.read_bytes(), ( + f"{packaged} differs from {SCRIPT}: copy the script again, in the same commit") + + +def test_the_source_tree_answers_the_package_it_imports(): + """In this checkout `openxdox.contracts` is the source package itself, so + what the tests below read is what the wheel is built from.""" + assert contracts.package_dir().resolve() == PACKAGE.resolve() + assert contracts.validator_path() == PACKAGE / contracts.VALIDATOR_NAME + + +# ----------------------------- the copies ----------------------------- + +def test_every_copy_is_the_digest_its_record_gives_at_the_root_pin(): + record = contracts.record() + assert record.spec_leg == "opensoft/openXdox-spec" + assert record.commit == ROOT_SPEC_PIN + assert set(record.ids) == contracts.COPY_IDS + for copy in record.copies: + on_disk = PACKAGE / "schemas" / copy.filename + assert hashlib.sha256(on_disk.read_bytes()).hexdigest() == copy.sha256, copy.id + assert contracts.verified_bytes(copy.id) == on_disk.read_bytes() + assert contracts.verified_path(copy.id) == on_disk + + +def test_the_copies_are_the_validators_own_three_kinds(): + """The record's three are the validator's `OWN_KIND_SCHEMAS`, and each copy + declares the kind its id names, so no fourth kind rides in as a copy.""" + import importlib.util + + spec = importlib.util.spec_from_file_location("vidc_packaged_copies", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + record = contracts.record() + assert {copy.filename for copy in record.copies} == module.OWN_KIND_SCHEMAS + for copy in record.copies: + schema = yaml.safe_load(contracts.verified_bytes(copy.id)) + assert schema["properties"]["kind"]["const"] == copy.id + + +@pytest.fixture +def staged(tmp_path, monkeypatch): + """A copy of the package's data files, which `openxdox.contracts` reads in + place of its own, so a test can break one piece at a time.""" + staged = tmp_path / "contracts" + shutil.copytree(PACKAGE, staged, ignore=shutil.ignore_patterns("__pycache__", "*.py")) + monkeypatch.setattr(contracts, "package_dir", lambda: staged) + return staged + + +def _edit_record(staged: Path, change) -> None: + path = staged / contracts.RECORD_NAME + data = yaml.safe_load(path.read_text(encoding="utf-8")) + change(data) + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + +def test_the_staged_package_reads_as_the_real_one(staged): + assert contracts.record() == contracts.Record( + "opensoft/openXdox-spec", ROOT_SPEC_PIN, contracts.record().copies) + for copy_id in contracts.COPY_IDS: + assert contracts.verified_path(copy_id).parent == staged / "schemas" + + +def _drop(key): + return lambda data: data.pop(key) + + +def _set(key, value): + return lambda data: data.__setitem__(key, value) + + +def _copy_field(index, key, value): + return lambda data: data["copies"][index].__setitem__(key, value) + + +RECORD_REFUSALS = { + "a missing key": (_drop("commit"), "its keys are"), + "an unknown key": (_set("extra", 1), "its keys are"), + "a boolean schema_version": (_set("schema_version", True), "schema_version is True"), + "a string schema_version": (_set("schema_version", "1"), "schema_version is '1'"), + "another kind": (_set("kind", "contract-manifest"), "kind is 'contract-manifest'"), + "another spec leg": (_set("spec_leg", "opensoft/openDox-spec"), "spec_leg is"), + "a short commit": (_set("commit", ROOT_SPEC_PIN[:8]), "not a full 40-hex commit id"), + "no copies": (_set("copies", []), "copies is not a non-empty list"), + "a copy with an extra field": (_copy_field(0, "note", "x"), "copies[0] is not a mapping"), + "an uppercase id": (_copy_field(0, "id", "Gate-Action-Record"), "copies[0].id is"), + "a path that is not the id's": ( + _copy_field(0, "path", "contracts/schemas/other.schema.yaml"), "copies[0].path is"), + "an empty digest": (_copy_field(1, "sha256", ""), "copies[1].sha256 is ''"), + "a short digest": (_copy_field(1, "sha256", "ab" * 16), "not a 64-hex digest"), + "a repeated id": ( + lambda data: data["copies"].__setitem__(2, dict(data["copies"][1])), + "an id is given twice"), + "a fourth kind in place of one of the three": ( + lambda data: data["copies"].__setitem__(2, { + "id": "gate-intent", "path": "contracts/schemas/gate-intent.schema.yaml", + "sha256": "0" * 64}), + "not the consumer's three"), +} + + +@pytest.mark.parametrize("case", sorted(RECORD_REFUSALS)) +def test_a_record_that_cannot_be_trusted_is_refused_before_any_copy_is_read(staged, case): + change, needle = RECORD_REFUSALS[case] + _edit_record(staged, change) + with pytest.raises(contracts.CopyRefused) as refused: + contracts.record() + assert needle in str(refused.value), str(refused.value) + with pytest.raises(contracts.CopyRefused): + contracts.verified_bytes("ideation-dashboard-snapshot") + + +def test_a_record_that_is_not_a_mapping_is_refused(staged): + (staged / contracts.RECORD_NAME).write_text("- a list\n", encoding="utf-8") + with pytest.raises(contracts.CopyRefused, match="it is a list, not a mapping"): + contracts.record() + + +def test_an_absent_record_is_refused_by_name(staged): + (staged / contracts.RECORD_NAME).unlink() + with pytest.raises(contracts.CopyRefused, match="has no copies.yaml"): + contracts.record() + + +def test_a_copy_edited_in_place_is_refused_not_read(staged): + copy = staged / "schemas" / "ideation-dashboard-snapshot.schema.yaml" + copy.write_bytes(copy.read_bytes() + b"\n") + with pytest.raises(contracts.CopyRefused, match="is not the spec leg's file"): + contracts.verified_bytes("ideation-dashboard-snapshot") + with pytest.raises(contracts.CopyRefused, match="is not the spec leg's file"): + contracts.verified_path("ideation-dashboard-snapshot") + # the other two are untouched, and still read + assert contracts.verified_path("gate-action-record").is_file() + + +def test_an_absent_copy_is_refused_by_name(staged): + (staged / "schemas" / "gate-action-record.schema.yaml").unlink() + with pytest.raises(contracts.CopyRefused, match="has no schemas/gate-action-record"): + contracts.verified_bytes("gate-action-record") + + +def test_a_copy_the_record_does_not_name_is_refused(staged): + with pytest.raises(contracts.CopyRefused, match="is not one of the packaged copies"): + contracts.verified_path("gate-intent") + + +# ------------------------ the package-data line ------------------------ + +def test_the_package_data_line_ships_every_data_file_and_nothing_else(): + """The patterns are held to the files they ship: each one matches at least + one file, and together they match every file in the package but its module + (`__init__.py`) and bytecode, so a file added beside them is either declared + or refused here.""" + with (REPO_ROOT / "pyproject.toml").open("rb") as fh: + declared = tomllib.load(fh)["tool"]["setuptools"]["package-data"]["openxdox.contracts"] + assert declared == ["copies.yaml", "schemas/*.schema.yaml", contracts.VALIDATOR_NAME] + shipped = set() + for pattern in declared: + matched = {p.relative_to(PACKAGE).as_posix() for p in PACKAGE.glob(pattern) if p.is_file()} + assert matched, f"{pattern!r} ships nothing" + shipped |= matched + present = {p.relative_to(PACKAGE).as_posix() for p in PACKAGE.rglob("*") + if p.is_file() and "__pycache__" not in p.parts} + assert present - shipped == {"__init__.py"}, present - shipped + assert shipped == {contracts.RECORD_NAME, contracts.VALIDATOR_NAME} | { + f"schemas/{copy.filename}" for copy in contracts.record().copies} + diff --git a/tests/test_validate_ideation_dashboard_contracts.py b/tests/test_validate_ideation_dashboard_contracts.py index 36d0ae7..e90ca15 100644 --- a/tests/test_validate_ideation_dashboard_contracts.py +++ b/tests/test_validate_ideation_dashboard_contracts.py @@ -367,3 +367,40 @@ def test_the_validators_reserved_sets_agree_with_the_runtimes(vidc): from opendox import doxbench_turns as turns assert set(vidc.V2_RESERVED_DOCUMENT_PATHS) == set(turns.RESERVED_BUFFER_KEYS) assert set(vidc.V1_RESERVED_DOCUMENT_PATHS) == set(turns.V1_RESERVED_BUFFER_KEYS) + + +def test_every_schema_the_consumer_validates_is_on_disk(): + """#1144 7.3's second named test, as T007's batch I reads it (plan 034 T061; + RULED R1Q27 (a), `opensoft/openxFactory#656` comment `5851950767`): every + schema THIS INSTALL validates is on disk, its own three always, and another + kind's only where the running tree supplies it. + + THE VALIDATOR IS THE INSTALLED DISTRIBUTION'S, never this module's own + `SCRIPT`, whose `ROOT` is the carve's `parents[2]`, above the checkout + (V2-10): it is the one `openxdox.snapshot.find_validator` answers, and its + sources are the ones it reports (`schema_sources`). In a lone install its + tree carries no `contracts/`, so the three come from the distribution's + packaged copies, each held to `copies.yaml`'s digest. This module's + fixtures are not used: they read openxFactory's tree, which is why the file + is declared under `openxfactory-contracts`, and F7.1 runs this case by node + id.""" + from openxdox import contracts, snapshot + + validator = snapshot.find_validator() + assert validator is not None, "this install has no validator of its own" + spec = importlib.util.spec_from_file_location("_consumer_validator", validator) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + supplied = {name: path for name, (path, _channel) in module.schema_sources().items() + if path is not None} + + own = {contracts.record().copy(copy_id).filename for copy_id in contracts.COPY_IDS} + assert own == set(module.OWN_KIND_SCHEMAS) + assert own <= set(supplied), f"its own three are not all supplied: {sorted(own - set(supplied))}" + for kind, name in module.KIND_TO_SCHEMA.items(): + if name in supplied: + assert supplied[name].is_file(), (kind, supplied[name]) + if not (module.ROOT / "contracts" / "schemas").is_dir(): + for copy_id in contracts.COPY_IDS: + name = contracts.record().copy(copy_id).filename + assert supplied[name] == contracts.verified_path(copy_id), name diff --git a/tests/test_validator_schema_home.py b/tests/test_validator_schema_home.py index a6ae7eb..c86ec4b 100644 --- a/tests/test_validator_schema_home.py +++ b/tests/test_validator_schema_home.py @@ -22,16 +22,25 @@ split three ways: snapshot, snapshot-index and gate-action at openXdox-spec (this product's own spec leg); workbench, chat-turn and model-catalog at openDox-spec; possibles-register, project-register, gate-intent and -demotion-receipt at openxFactory. A kind whose schema the resolved directory -does not carry is refused BY NAME (exit 2), never passed. +demotion-receipt at openxFactory. A kind whose schema no channel supplies is +refused BY NAME (exit 2), never passed. + +THE SCHEMAS' NEW HOME, SINCE PLAN 034 T061 (#1144 7.3 as T007's batch I amends +it, RULED R1Q14 (a) and R1Q27 (a)). The validator's own three kinds, the +openXdox-spec three, come from its INSTALLED distribution: the packaged copies +in `openxdox/contracts/schemas/`, each held to `copies.yaml`'s digest, found +through the import system wherever the running tree carries none of its own. +`CONTRACTS_DIR` supplies only the family's other seven, and never the three. +So the declared channel is exercised here with a kind of the seven, a STAND-IN +`gate-intent` schema, and the snapshot is checked against the real packaged +schema, since the three now travel with the install. Each case copies the REAL validator script into a layout built in `tmp_path` and runs it the documented way, as a subprocess, with `CONTRACTS_DIR` removed -from the inherited environment unless the case sets it. The spec leg carries a -STAND-IN snapshot schema: the real one lives in openXdox-spec, which this leg's -hermetic suite does not reach. What is proven is the RESOLUTION and that the -validator really runs its snapshot rules (a dangling edge is a finding, exit 1), -not the content of the real schema. +from the inherited environment unless the case sets it. Where a case needs an +interpreter whose openxdox distribution carries no copies, it puts a DECOY +`openxdox/contracts/` package first on `PYTHONPATH`, which the import system +finds before the installed one. """ from __future__ import annotations @@ -58,6 +67,17 @@ REPO_ROOT = Path(__file__).resolve().parents[1] VALIDATOR = REPO_ROOT / "scripts" / "validate-ideation-dashboard-contracts.py" +STAND_IN_GATE_INTENT_SCHEMA = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gate-intent.schema.yaml", + "schema_version": 1, + "kind": "json-schema", + "type": "object", + "required": ["schema_version", "kind"], + "properties": {"kind": {"const": "gate-intent"}}, +} +GATE_INTENT = {"schema_version": 1, "kind": "gate-intent"} + STAND_IN_SNAPSHOT_SCHEMA = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "ideation-dashboard-snapshot.schema.yaml", @@ -103,39 +123,116 @@ def _spec_contracts(spec: Path, schema: dict = STAND_IN_SNAPSHOT_SCHEMA) -> Path return spec / "contracts" +def _declared(contracts: Path) -> Path: + """A `CONTRACTS_DIR` carrying the stand-in `gate-intent` schema, a kind of + the family's other seven; returns it.""" + _write(contracts / "schemas" / "gate-intent.schema.yaml", STAND_IN_GATE_INTENT_SCHEMA) + return contracts + + +def _decoy_distribution(root: Path, *, copies_from: Path | None = None) -> Path: + """A directory for `PYTHONPATH` whose `openxdox.contracts` the import system + finds first. Empty, it carries no copies; `copies_from` fills it with a copy + of a real `openxdox/contracts/` (record and schemas). Returns the directory.""" + package = root / "openxdox" / "contracts" + package.mkdir(parents=True, exist_ok=True) + (package / "__init__.py").write_text("", encoding="utf-8") + if copies_from is not None: + shutil.copy2(copies_from / "copies.yaml", package / "copies.yaml") + shutil.copytree(copies_from / "schemas", package / "schemas", dirs_exist_ok=True) + return root + + +PACKAGED = REPO_ROOT / "src" / "openxdox" / "contracts" + + def _run(script: Path, *args: Path, cwd: Path, - contracts_dir: Path | None = None) -> subprocess.CompletedProcess: - env = {k: v for k, v in os.environ.items() if k != "CONTRACTS_DIR"} + contracts_dir: Path | None = None, + pythonpath: Path | None = None) -> subprocess.CompletedProcess: + env = {k: v for k, v in os.environ.items() if k not in ("CONTRACTS_DIR", "PYTHONPATH")} if contracts_dir is not None: env["CONTRACTS_DIR"] = str(contracts_dir) + if pythonpath is not None: + env["PYTHONPATH"] = str(pythonpath) cwd.mkdir(parents=True, exist_ok=True) return subprocess.run([sys.executable, str(script), *map(str, args)], capture_output=True, text=True, cwd=cwd, env=env, timeout=120) -def test_with_contracts_dir_the_validator_runs_against_the_spec_legs_schemas(tmp_path): - """(i) THE REGISTERED DEFECT. A lone code leg, given the spec leg's - contracts through the declared channel, RUNS: a conforming snapshot +def test_with_contracts_dir_the_validator_runs_against_the_declared_schemas(tmp_path): + """(i) THE REGISTERED DEFECT. A lone code leg, given a contract it does not + own through the declared channel, RUNS: a conforming instance of that kind validates (exit 0), from a clean cwd.""" script = _script_in(tmp_path / "openXdox" / "code") - contracts = _spec_contracts(tmp_path / "openXdox" / "spec") - snap = _write(tmp_path / "out" / "s.yaml", _snapshot()) - proc = _run(script, snap, cwd=tmp_path / "elsewhere", contracts_dir=contracts) + contracts = _declared(tmp_path / "declared" / "contracts") + intent = _write(tmp_path / "out" / "gi.yaml", GATE_INTENT) + proc = _run(script, intent, cwd=tmp_path / "elsewhere", contracts_dir=contracts) assert proc.returncode == 0, proc.stdout + proc.stderr assert "0 error(s)" in proc.stdout +def test_its_own_three_come_from_the_installed_distribution_never_contracts_dir(tmp_path): + """PLAN 034 T061 (#1144 7.3; R1Q27 (a)). The validator's own kinds are read + from its installed distribution, and `CONTRACTS_DIR` never supplies them: + here the declared directory's snapshot schema would REJECT the instance, so + exit 0 proves the packaged copy was read. With no `CONTRACTS_DIR` at all the + snapshot validates the same way, since the three need no channel.""" + script = _script_in(tmp_path / "code") + stricter = dict(STAND_IN_SNAPSHOT_SCHEMA, required=["never_present"]) + declared = _spec_contracts(tmp_path / "spec", stricter) + snap = _write(tmp_path / "out" / "s.yaml", _snapshot()) + for contracts in (declared, None): + proc = _run(script, snap, cwd=tmp_path, contracts_dir=contracts) + assert proc.returncode == 0, (contracts, proc.stdout + proc.stderr) + assert "0 error(s)" in proc.stdout + + +def test_a_packaged_copy_that_differs_from_its_record_is_refused(tmp_path): + """PRESENCE IS NOT IDENTITY. A distribution whose snapshot copy is not the + spec leg's file (one byte appended) is refused by name before it is read, + as a harness error (exit 2), never used and never passed.""" + decoy = _decoy_distribution(tmp_path / "decoy", copies_from=PACKAGED) + copy = decoy / "openxdox" / "contracts" / "schemas" / "ideation-dashboard-snapshot.schema.yaml" + copy.write_bytes(copy.read_bytes() + b"\n") + script = _script_in(tmp_path / "code") + proc = _run(script, _write(tmp_path / "out" / "s.yaml", _snapshot()), cwd=tmp_path, + pythonpath=decoy) + assert proc.returncode == 2, proc.stdout + proc.stderr + assert "is not the spec leg's file" in proc.stderr + assert "0 error(s)" not in proc.stdout + + +def test_the_packaged_validator_reads_the_copies_beside_it(tmp_path): + """An INSTALL's validator is `openxdox/contracts/validate-...py`, and its own + tree is that package, so it reads the copies beside it, digest-checked, + and not whatever distribution the interpreter would find (a decoy with no + copies stands first on `PYTHONPATH` here). A copy edited in place beside it + is refused the same way.""" + site = tmp_path / "site" + shutil.copytree(PACKAGED, site / "openxdox" / "contracts", + ignore=shutil.ignore_patterns("__pycache__")) + script = site / "openxdox" / "contracts" / VALIDATOR.name + decoy = _decoy_distribution(tmp_path / "decoy") + snap = _write(tmp_path / "out" / "s.yaml", _snapshot()) + proc = _run(script, snap, cwd=tmp_path, pythonpath=decoy) + assert proc.returncode == 0, proc.stdout + proc.stderr + edited = site / "openxdox" / "contracts" / "schemas" / "gate-action-record.schema.yaml" + edited.write_bytes(edited.read_bytes().replace(b"gate-action-record", b"gate-action-recorD", 1)) + proc = _run(script, snap, cwd=tmp_path, pythonpath=decoy) + assert proc.returncode == 2, proc.stdout + proc.stderr + assert "is not the spec leg's file" in proc.stderr + + def test_a_referentially_broken_snapshot_is_a_verdict_not_a_harness_error(tmp_path): """(i) It is a real run, not an early exit: the validator's own snapshot rule fires on a dangling cluster edge — a FINDING (exit 1), which is what - `openxdox.snapshot` reads as not-conformant rather than unavailable.""" + `openxdox.snapshot` reads as not-conformant rather than unavailable. The + snapshot schema is the packaged one (T061), with no channel set.""" script = _script_in(tmp_path / "code") - contracts = _spec_contracts(tmp_path / "spec") broken = _snapshot(clusters=[{ "id": "cl-x", "name": "X", "topics": ["x"], "document_edges": [{"document": "doc-missing", "matched_topics": ["x"]}]}]) - proc = _run(script, _write(tmp_path / "out" / "bad.yaml", broken), cwd=tmp_path, - contracts_dir=contracts) + proc = _run(script, _write(tmp_path / "out" / "bad.yaml", broken), cwd=tmp_path) assert proc.returncode == 1, proc.stdout + proc.stderr assert "snapshot-dangling-edge" in proc.stdout @@ -152,8 +249,8 @@ def test_a_kind_the_declared_contracts_do_not_carry_is_refused_by_name(tmp_path) {"schema_version": 1, "kind": "ideation-workbench"}) proc = _run(script, workbench, cwd=tmp_path, contracts_dir=contracts) assert proc.returncode == 2, proc.stdout + proc.stderr - assert "ideation-workbench.schema.yaml is not carried under" in proc.stderr - assert str(contracts / "schemas") in proc.stderr + assert "ideation-workbench.schema.yaml is not supplied" in proc.stderr + assert f"not carried under {contracts / 'schemas'}" in proc.stderr assert f"CONTRACTS_DIR={contracts}" in proc.stderr @@ -181,17 +278,21 @@ def test_the_declared_directory_sets_the_schemas_and_the_packaged_examples(tmp_p def test_with_neither_the_refusal_names_the_channel(tmp_path): - """(i) No contracts of its own and no `CONTRACTS_DIR`: the run refuses as a - harness error (exit 2), in both the default and the single-file mode, and - the refusal says which variable would supply the contracts rather than only - that a directory is missing.""" + """(i) No contracts of its own and no `CONTRACTS_DIR`: the default mode, + whose packaged examples live in a contracts tree, and a single file of a + kind the validator does not own both refuse as a harness error (exit 2), + and the refusal says which variable would supply the contracts rather than + only that a directory is missing. A snapshot, one of its own three, + validates from the installed distribution (plan 034 T061).""" script = _script_in(tmp_path / "openXdox-code") default = _run(script, cwd=tmp_path) assert default.returncode == 2, default.stdout + default.stderr assert "CONTRACTS_DIR is not set" in default.stderr - single = _run(script, _write(tmp_path / "out" / "s.yaml", _snapshot()), cwd=tmp_path) + single = _run(script, _write(tmp_path / "out" / "gi.yaml", GATE_INTENT), cwd=tmp_path) assert single.returncode == 2, single.stdout + single.stderr assert "CONTRACTS_DIR is not set" in single.stderr + own = _run(script, _write(tmp_path / "out" / "s.yaml", _snapshot()), cwd=tmp_path) + assert own.returncode == 0, own.stdout + own.stderr def test_a_declared_directory_without_schemas_is_refused_by_name(tmp_path): @@ -200,7 +301,7 @@ def test_a_declared_directory_without_schemas_is_refused_by_name(tmp_path): script = _script_in(tmp_path / "code") empty = tmp_path / "empty-contracts" empty.mkdir() - proc = _run(script, _write(tmp_path / "out" / "s.yaml", _snapshot()), cwd=tmp_path, + proc = _run(script, _write(tmp_path / "out" / "gi.yaml", GATE_INTENT), cwd=tmp_path, contracts_dir=empty) assert proc.returncode == 2, proc.stdout + proc.stderr assert str(empty / "schemas") in proc.stderr @@ -221,8 +322,12 @@ def test_no_mode_passes_having_loaded_no_schema(tmp_path): Every mode is now a harness error, exit 2, naming the directory and the channel. It covers four modes: both sweeps, a register transition, and the default self-test. It covers two ways the ten can be missing: no contracts - resolved at all, and a `schemas/` of unrelated files.""" + resolved at all, and a `schemas/` of unrelated files. Since plan 034 T061 an + installed distribution supplies the validator's own three wherever it is + importable, so "nothing loaded" also needs an interpreter whose openxdox + carries no copies: a decoy stands first on `PYTHONPATH` here.""" script = _script_in(tmp_path / "code") + decoy = _decoy_distribution(tmp_path / "decoy") empty = tmp_path / "sweep-empty" empty.mkdir() unrecognized = _write(tmp_path / "sweep-unrecognized" / "notes.yaml", @@ -234,7 +339,8 @@ def test_no_mode_passes_having_loaded_no_schema(tmp_path): (None, tmp_path / "code" / "contracts" / "schemas", "CONTRACTS_DIR is not set"), (unrelated, unrelated.resolve() / "schemas", f"CONTRACTS_DIR={unrelated}")): for args in ((empty,), (unrecognized,), ("--transition", register, register), ()): - proc = _run(script, *args, cwd=tmp_path / "elsewhere", contracts_dir=contracts) + proc = _run(script, *args, cwd=tmp_path / "elsewhere", contracts_dir=contracts, + pythonpath=decoy) assert proc.returncode == 2, (contracts, args, proc.stdout + proc.stderr) assert str(schemas) in proc.stderr, (contracts, args, proc.stderr) assert channel in proc.stderr, (contracts, args, proc.stderr) @@ -253,11 +359,16 @@ def test_an_enclosing_assembly_root_is_never_read_by_position(tmp_path): "schema_version": 1, "kind": "pinned_contract_manifest", "leg_role": role, "source_repository": f"opensoft/{repo}", "submodule_path": leg}) script = _script_in(root / "code") - _spec_contracts(root / "spec") - proc = _run(script, _write(tmp_path / "out" / "s.yaml", _snapshot()), cwd=root) + _declared(root / "spec" / "contracts") + _spec_contracts(root / "spec", dict(STAND_IN_SNAPSHOT_SCHEMA, required=["never_present"])) + proc = _run(script, _write(tmp_path / "out" / "gi.yaml", GATE_INTENT), cwd=root) assert proc.returncode == 2, proc.stdout + proc.stderr assert str(root / "code" / "contracts" / "schemas") in proc.stderr assert str(root / "spec") not in proc.stderr + # and the spec leg's stricter snapshot schema is not read either: the + # packaged copy is, so the snapshot validates (plan 034 T061) + own = _run(script, _write(tmp_path / "out" / "s.yaml", _snapshot()), cwd=root) + assert own.returncode == 0, own.stdout + own.stderr @pytest.mark.skipif(not hasattr(os, "symlink"), reason="no symlinks on this platform") From 0dbc2fc80110119c13ac0214e8fa1638f7848a11 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:51:30 +0000 Subject: [PATCH 26/44] Locate the validator through the installed distribution, the start ignored (plan 034 T061, 7.3) #1144 7.3: "openXdox's validator and its three schemas ... are located through the INSTALLED openXdox distribution, and no parent walk remains." find_validator keeps its declared signature, because openDox's consumer_reach binds it by name, and ignores `start`. From this product's source tree it answers that tree's scripts/ validator, the path openxFactory's lanes read; from an install, the packaged openxdox/contracts copy. The answer never depends on the start, the cwd, a snapshot's directory or a corpus, so an enclosing tree's validator is never adopted, and a launch from outside the tree is validated rather than confined to None (split-opendox 8.9 residue (iii), openXdox-code#28, which this supersedes for the start). GovernedValidator.locate asks find_validator with no start, and says the product's own validator was not found when there is none. The installed layout's cases join tests/test_packaged_validator.py. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- src/openxdox/projection_contributions.py | 31 ++++------- src/openxdox/snapshot.py | 70 ++++++++++++++---------- tests/test_packaged_validator.py | 60 ++++++++++++++++++++ tests/test_projection_contributions.py | 22 +++++--- 4 files changed, 127 insertions(+), 56 deletions(-) diff --git a/src/openxdox/projection_contributions.py b/src/openxdox/projection_contributions.py index a3d5a79..561ca1f 100644 --- a/src/openxdox/projection_contributions.py +++ b/src/openxdox/projection_contributions.py @@ -229,22 +229,20 @@ class GovernedValidator: openXdox-spec's three kinds. openDox hands it the roots a search may start from, the written snapshot's - directory first and the served checkout second. `locate()` asks - `snapshot.find_validator` from each in turn, as openDox's - `cli._locate_validator` did before T055, and the first validator found - runs. `snapshot.validate_snapshot` reaches the verdict, with its three - outcomes, and its result carries every attribute openDox reads.""" + directory first and the served checkout second. Since plan 034 T061 there + is no search: `snapshot.find_validator` answers the installed + distribution's own validator whatever the start (#1144 7.3, RULED R1Q14 + (a)), so `locate()` asks it once and the roots are not read. + `snapshot.validate_snapshot` reaches the verdict, with its three outcomes, + and its result carries every attribute openDox reads.""" #: The remedy openDox prints when the validator is found but cannot run. dependency_remedy = snapshot_mod.DEPENDENCY_REMEDY def locate(self, search_from: tuple = ()) -> Path | None: - """The validator from the first root that reaches one, or None.""" - for start in tuple(search_from) or (None,): - found = snapshot_mod.find_validator(None if start is None else Path(start)) - if found is not None: - return found - return None + """The installed distribution's own validator, or None. `search_from` + keeps the seam's signature and is not read.""" + return snapshot_mod.find_validator() def validate(self, path: Path | str, *, strict: bool = False, search_from: tuple = ()) -> snapshot_mod.ValidationResult: @@ -252,18 +250,11 @@ def validate(self, path: Path | str, *, strict: bool = False, if validator is not None: return snapshot_mod.validate_snapshot(path, validator=validator, strict=strict) - starts = tuple(search_from) or (None,) - reasons = [] - for start in starts: - reason = snapshot_mod._validator_not_found_reason( - None if start is None else Path(start)) - if reason not in reasons: - reasons.append(reason) return snapshot_mod.ValidationResult( False, -1, "", "validator not found", None, snapshot_mod.VALIDATOR_UNAVAILABLE, - f"no {snapshot_mod.VALIDATOR_RELPATH} of this product's own was " - f"found from any root offered: " + "; ".join(reasons)) + f"this product's own validator ({snapshot_mod.VALIDATOR_RELPATH.name}) " + f"was not found: {snapshot_mod._validator_not_found_reason(None)}") VALIDATOR = GovernedValidator() diff --git a/src/openxdox/snapshot.py b/src/openxdox/snapshot.py index 5eb651a..ce537d2 100644 --- a/src/openxdox/snapshot.py +++ b/src/openxdox/snapshot.py @@ -13,8 +13,8 @@ (plan 034 T059: this module is openXdox's writer at openDox's writer seam). Validation is DELEGATED to this product's own validator, in this repository -(`scripts/validate-ideation-dashboard-contracts.py`) — the schema is never -restated here. `validate_or_raise` fails loudly on a non-conforming snapshot. +(`scripts/validate-ideation-dashboard-contracts.py`) and, in an install, in the +package (`openxdox/contracts/`) — the schema is never restated here. `validate_or_raise` fails loudly on a non-conforming snapshot. It reports THREE outcomes: validated, not conformant, and validator unavailable — see the commentary above `VALIDATED` for why the third one exists. """ @@ -147,8 +147,9 @@ def write_snapshot(snapshot: dict[str, Any], path: Path | str, boundary) -> Path def product_root() -> Path | None: """This product's own source tree, or None when the module is not running - from one (an installed wheel ships no `scripts/`, so it has no validator of - its own to offer, and it must not go looking for somebody else's). + from one (an installed wheel ships no `scripts/`: its validator is the + packaged one, `openxdox/contracts/`, and it never goes looking for somebody + else's). NO WALK. The root is fixed arithmetic on this module's own resolved path — `parents[2]` of `src/openxdox/snapshot.py` — confirmed by the explicit @@ -171,25 +172,36 @@ def _inside(path: Path, root: Path) -> bool: return Path(path).resolve().is_relative_to(root.resolve()) +def _packaged_validator() -> Path | None: + """The installed distribution's packaged validator, beside this module in + `openxdox/contracts/`, or None where this install carries none.""" + candidate = Path(__file__).resolve().parent / "contracts" / VALIDATOR_RELPATH.name + return candidate if candidate.is_file() else None + + def find_validator(start: Path | None = None) -> Path | None: - """THIS PRODUCT'S OWN validator, `product_root() / VALIDATOR_RELPATH`, or - None. It is looked for in exactly one place, and never above the product's - root: the parent walk this replaces ADOPTED whatever enclosing checkout - still carried a pre-shed copy (split-opendox-two-layer-product § 8.9 - residue (iii) — a reader adopting its enclosing tree). - - `start` keeps its declared signature (openDox's `consumer_reach` binds this - function by name) and now CONFINES instead of widening: a `start` outside - this product's own tree answers None, because nothing outside that tree is - ever consulted, and `start=None` asks from the module itself. A caller that - means a validator from anywhere else passes it explicitly — - `validate_snapshot(..., validator=...)`; none is ever inferred from where a - snapshot, a corpus or the cwd happens to sit.""" + """THE INSTALLED DISTRIBUTION'S OWN validator, or None (plan 034 T061; + #1144 7.3, RULED R1Q14 (a)). There is no parent walk, and no search at all: + + * where this module runs from this product's source tree (`product_root()`), + it is that tree's `VALIDATOR_RELPATH`, the file openxFactory's lanes read + at that path; + * where it runs from an install, it is the packaged validator, + `openxdox/contracts/validate-ideation-dashboard-contracts.py`, which finds + this validator's own three schemas beside it. + + `start` KEEPS ITS DECLARED SIGNATURE, and is IGNORED. openDox's + `consumer_reach` binds this function by name, and callers pass the + directories they validate from. Under split-opendox § 8.9 residue (iii) + (openXdox-code#28, `e28930bf`) a `start` outside the product's tree + CONFINED the answer to None, so a launch from a corpus or a run directory + went unvalidated. The answer now never depends on it, nor on the cwd, a + snapshot's directory or a corpus, so an enclosing tree's validator is never + adopted, whatever it carries. A caller that means another validator passes + it explicitly: `validate_snapshot(..., validator=...)`.""" root = product_root() if root is None: - return None - if start is not None and not _inside(Path(start), root): - return None + return _packaged_validator() candidate = root / VALIDATOR_RELPATH # `is_file()` follows a symlink, so containment is checked on the RESOLVED # path as well: a `scripts/` link pointing out of the tree is refused. @@ -199,14 +211,12 @@ def find_validator(start: Path | None = None) -> Path | None: def _validator_not_found_reason(search_from: Path | None) -> str: - """Why `find_validator` answered None, in the three ways it can.""" + """Why `find_validator` answered None, in the two ways it can. The start is + not one of them: it is ignored (plan 034 T061).""" root = product_root() if root is None: - where = ("this openxdox is not running from a source checkout, so it " - f"carries no {VALIDATOR_RELPATH} of its own") - elif search_from is not None and not _inside(Path(search_from), root): - where = (f"the search was confined to {search_from}, which lies outside " - f"this product's own tree ({root})") + where = ("this openxdox is installed without its packaged validator " + f"(openxdox/contracts/{VALIDATOR_RELPATH.name})") else: where = f"{root / VALIDATOR_RELPATH} does not exist" return (f"{where}; a validator in an enclosing checkout is never adopted — " @@ -303,9 +313,11 @@ def validate_snapshot( `result.outcome` is one of `VALIDATED`, `NOT_CONFORMANT`, or `VALIDATOR_UNAVAILABLE`; `result.ok` stays True only for `VALIDATED`.""" path = Path(path).resolve() - # The snapshot's own directory is NOT a search root any more: a snapshot - # written inside some other checkout must not choose that checkout's - # validator (§ 8.9 residue (iii)). `search_from`, when given, only confines. + # The snapshot's own directory is NOT a search root: a snapshot written + # inside some other checkout must not choose that checkout's validator + # (§ 8.9 residue (iii)). `search_from` keeps its place in the signature + # (openDox's validator seam passes the roots it validates from), and is + # ignored with `find_validator`'s start (plan 034 T061). validator = validator or find_validator(search_from) if validator is None: return ValidationResult( diff --git a/tests/test_packaged_validator.py b/tests/test_packaged_validator.py index 9531750..bac4397 100644 --- a/tests/test_packaged_validator.py +++ b/tests/test_packaged_validator.py @@ -17,6 +17,10 @@ match every data file in the package and nothing else, so a wheel ships the record, the three copies and the validator, and `__init__.py` alone is not what an install gets. +* THE INSTALLED LAYOUT. Where `openxdox` is not running from this product's + source tree, `find_validator` answers the packaged copy, and no start it is + given changes that answer; an install built without it answers None, and + says so. The fresh-venv, non-editable run of the same claims is #1144's F7.1, which the pull request's evidence carries.""" @@ -24,6 +28,8 @@ import hashlib import shutil +import subprocess +import sys import tomllib from pathlib import Path @@ -214,3 +220,57 @@ def test_the_package_data_line_ships_every_data_file_and_nothing_else(): assert shipped == {contracts.RECORD_NAME, contracts.VALIDATOR_NAME} | { f"schemas/{copy.filename}" for copy in contracts.record().copies} + +# ------------------------ the installed layout ------------------------ + +def _installed_copy(site: Path) -> Path: + """This checkout's `openxdox` package, copied into `site` as an install + lays it out: no `src/`, no `pyproject.toml`, no `scripts/` beside it.""" + shutil.copytree(REPO_ROOT / "src" / "openxdox", site / "openxdox", + ignore=shutil.ignore_patterns("__pycache__")) + return site / "openxdox" + + +PROBE = """ +import sys +from pathlib import Path +from openxdox import snapshot +assert snapshot.product_root() is None, snapshot.product_root() +starts = [None] + [Path(arg) for arg in sys.argv[1:]] +answers = {str(snapshot.find_validator(start)) for start in starts} +print(answers.pop() if len(answers) == 1 else f"DIFFERING {sorted(answers)}") +print(snapshot._validator_not_found_reason(None)) +""" + + +def _probe(site: Path, cwd: Path, *starts: Path) -> list[str]: + proc = subprocess.run( + [sys.executable, "-I", "-c", f"import sys; sys.path.insert(0, {str(site)!r})\n{PROBE}", + *map(str, starts)], + cwd=cwd, capture_output=True, text=True, check=False) + assert proc.returncode == 0, proc.stdout + proc.stderr + return proc.stdout.splitlines() + + +def test_an_install_answers_its_packaged_validator_whatever_the_start(tmp_path): + """The planted tree is the one the old parent walk adopted: an + `openxFactory/scripts/validate-...py` above the start and the cwd, which + exits 0 whatever it is given.""" + installed = _installed_copy(tmp_path / "site") + planted = tmp_path / "preshed" + decoy = planted / "openxFactory" / "scripts" / contracts.VALIDATOR_NAME + decoy.parent.mkdir(parents=True) + decoy.write_text("raise SystemExit(0)\n", encoding="utf-8") + (planted / "work").mkdir() + answer, _ = _probe(tmp_path / "site", planted / "work", + planted / "work", planted, tmp_path / "nowhere", REPO_ROOT) + assert answer == str((installed / "contracts" / contracts.VALIDATOR_NAME).resolve()) + + +def test_an_install_without_its_packaged_validator_answers_none_and_says_so(tmp_path): + installed = _installed_copy(tmp_path / "site") + (installed / "contracts" / contracts.VALIDATOR_NAME).unlink() + answer, reason = _probe(tmp_path / "site", tmp_path, tmp_path, REPO_ROOT) + assert answer == "None" + assert "installed without its packaged validator" in reason + assert "never adopted" in reason diff --git a/tests/test_projection_contributions.py b/tests/test_projection_contributions.py index 6e7b5dd..ad02d50 100644 --- a/tests/test_projection_contributions.py +++ b/tests/test_projection_contributions.py @@ -413,13 +413,16 @@ def declared_origin_state(path): # the validator # -------------------------------------------------------------------------- -def test_the_validator_is_located_from_each_root_in_turn(monkeypatch, tmp_path) -> None: +def test_the_validator_is_the_installed_distributions_own_whatever_the_roots(monkeypatch, + tmp_path) -> None: + """Plan 034 T061 (#1144 7.3): the lookup ignores its start, so the roots + openDox offers are not read, and the one validator runs.""" found = tmp_path / "validator.py" asked = [] - def find_validator(start): + def find_validator(start=None): asked.append(start) - return found if start == tmp_path / "second" else None + return found ran = [] monkeypatch.setattr(snapshot_mod, "find_validator", find_validator) @@ -428,16 +431,21 @@ def find_validator(start): result = pc.VALIDATOR.validate(tmp_path / "s.json", strict=True, search_from=(tmp_path / "first", tmp_path / "second")) assert result == "result" - assert asked == [tmp_path / "first", tmp_path / "second"] + assert asked == [None] assert ran == [(tmp_path / "s.json", {"validator": found, "strict": True})] -def test_no_validator_from_any_root_is_unavailable_naming_the_script(monkeypatch, tmp_path) -> None: - monkeypatch.setattr(snapshot_mod, "find_validator", lambda start: None) +def test_the_locate_answer_is_snapshots_own(tmp_path) -> None: + assert pc.VALIDATOR.locate((tmp_path / "anywhere",)) == snapshot_mod.find_validator() + + +def test_no_validator_of_its_own_is_unavailable_naming_the_script(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(snapshot_mod, "find_validator", lambda start=None: None) result = pc.VALIDATOR.validate(tmp_path / "s.json", search_from=(tmp_path / "a", tmp_path / "b")) assert result.outcome == projection_seams.VALIDATOR_UNAVAILABLE assert not result.available assert result.validator is None - assert str(snapshot_mod.VALIDATOR_RELPATH) in result.unavailable_reason + assert snapshot_mod.VALIDATOR_RELPATH.name in result.unavailable_reason + assert "never adopted" in result.unavailable_reason assert pc.VALIDATOR.dependency_remedy == snapshot_mod.DEPENDENCY_REMEDY From 3dc4a4cce4993ecaecb54cecb8e66fa340e8cd0c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:51:30 +0000 Subject: [PATCH 27/44] Add F7.1's first named test to tests/test_snapshot.py (plan 034 T061; batch F) A PROTECTED SUITE EDIT, in its own commit. T007's batch F (R1Q14 (a), opensoft/openxFactory#656 comment 5850003126) admits it through batch C's allow-list, with its reason, and not as a respelling: test_the_validator_is_the_installed_consumers_own is added after test_referentially_broken_snapshot_is_rejected, and nothing else in the suite changes. It plants the pre-shed tree F7.1 names above the start and asserts that find_validator answers the consumer's own validator for every start, never one under the planted tree, and that the validator it answers runs: a good snapshot validates, a dangling edge is not conformant. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_snapshot.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index 312224f..ff7d5b1 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -98,6 +98,50 @@ def test_referentially_broken_snapshot_is_rejected(tmp_path): snapshot.validate_or_raise(p, validator=validator) +def test_the_validator_is_the_installed_consumers_own(tmp_path, monkeypatch): + """#1144 7.3 (plan 034 T061, RULED R1Q14 (a)): the lookup answers the INSTALLED + distribution's own validator, whatever it is asked from, and never an + enclosing tree's. The planted tree is F7.1's: a pre-shed + `openxFactory/scripts/validate-ideation-dashboard-contracts.py` above the + working directory, which exits 0 whatever it is given. And the validator + found checks a snapshot against the consumer's own schema, which it reads + from the distribution (R1Q27 (a)), with no `contracts/` of its own and no + `CONTRACTS_DIR`: a dangling edge is a verdict, and a conforming snapshot + validates. This test is added by T007's batch F, entered in + `tests/protected_suite_respellings.yaml`.""" + import pathlib + + planted = tmp_path / "preshed" + decoy = planted / "openxFactory" / "scripts" / "validate-ideation-dashboard-contracts.py" + decoy.parent.mkdir(parents=True) + decoy.write_text("raise SystemExit(0)\n", encoding="utf-8") + (planted / "work").mkdir() + monkeypatch.chdir(planted / "work") + monkeypatch.delenv("CONTRACTS_DIR", raising=False) + + own = snapshot.find_validator() + assert own is not None, "the consumer found no validator of its own" + for start in (planted / "work", planted, planted / "openxFactory", tmp_path): + assert snapshot.find_validator(start) == own, start + assert planted.resolve() not in own.resolve().parents + root = snapshot.product_root() + home = root if root is not None else pathlib.Path(snapshot.__file__).resolve().parent + assert own.resolve().is_relative_to(home.resolve()) + + b = OutputBoundary(tmp_path, ["out/"]) + good = snapshot.write_snapshot(_minimal_snapshot(), tmp_path / "out" / "s.json", b) + result = snapshot.validate_snapshot(good, search_from=planted / "work") + assert result.outcome == snapshot.VALIDATED, result.summary() + result.stdout + result.stderr + assert result.validator == own + bad = snapshot.write_snapshot(_minimal_snapshot(clusters=[{ + "id": "cl-x", "name": "X", "topics": ["x"], + "document_edges": [{"document": "doc-missing", "matched_topics": ["x"]}]}]), + tmp_path / "out" / "bad.json", b) + refused = snapshot.validate_snapshot(bad, search_from=planted) + assert refused.outcome == snapshot.NOT_CONFORMANT, refused.stdout + refused.stderr + assert "dangling" in refused.stdout + + # ---- three outcomes: validated / not conformant / validator unavailable ---- # # `ok = (returncode == 0)` answered "is this snapshot good?" with "did the check From 08b03b9d44d761fc64fcb5139ebe884811e105c6 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:51:30 +0000 Subject: [PATCH 28/44] Revise C3's start-outside-the-product test for 7.3 (plan 034 T061; batch F) A PROTECTED SUITE EDIT, in its own commit. T007's batch F (R1Q14 (a), opensoft/openxFactory#656 comment 5850003126) admits it through batch C's allow-list, with its reason: the expected answer of tests/test_snapshot_validator_home.py::test_a_start_outside_the_product_is_refused_not_walked is now 7.3's. A start outside the product no longer confines the answer to None; every start answers the product's own validator, and the planted tree above it is still never walked. Nothing else in the suite changes. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_snapshot_validator_home.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test_snapshot_validator_home.py b/tests/test_snapshot_validator_home.py index 789a87b..70a687a 100644 --- a/tests/test_snapshot_validator_home.py +++ b/tests/test_snapshot_validator_home.py @@ -158,15 +158,20 @@ def test_an_enclosing_pre_shed_validator_is_never_adopted(tmp_path, load_copy): def test_a_start_outside_the_product_is_refused_not_walked(tmp_path, load_copy): - """(iii) `start` CONFINES. Every directory above or beside the product — - the enclosing checkout's root, its `openxFactory/`, a run directory — answers - None; none of them is walked to the enclosing validator sitting right there.""" + """(iii) `start` IS IGNORED (#1144 7.3, plan 034 T061, RULED R1Q14 (a); this + case's expected answer revised under T007's batch F, entered in + `tests/protected_suite_respellings.yaml`). The lookup answers the installed + distribution's own validator whatever it is asked from. Every directory + above or beside the product — the enclosing checkout's root, its + `openxFactory/`, a run directory — answers the product's own; none of them + is walked to the enclosing validator sitting right there. Until 7.3 a start + outside the product CONFINED the answer to None.""" agg, enclosing, product, module = _enclosed_product(tmp_path, load_copy) + own = product / "scripts" / "validate-ideation-dashboard-contracts.py" assert enclosing.is_file() for start in (agg, agg / "openxFactory", agg / "work", agg / "work" / "out"): - assert module.find_validator(start) is None, start - assert module.find_validator(product / "src") == \ - product / "scripts" / "validate-ideation-dashboard-contracts.py" + assert module.find_validator(start) == own, start + assert module.find_validator(product / "src") == own def test_with_no_validator_of_its_own_the_product_refuses_rather_than_adopting( From 038470d2cf5c919e3c276f578fcc320e35ccf9db Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:05:06 +0000 Subject: [PATCH 29/44] Revise the skip-message case for 7.3, and name F7.1 as rfc3339's check (plan 034 T061) tests/test_repo_root_guard.py::test_the_validation_skip_names_the_directory_it_searched_and_the_reason staged a skip by giving the lookup two roots that reach no validator. Since 7.3 the roots never decide where the validator is, so under composition the snapshot validated and the skip line never printed. The case now stages the one skip left, an install built without its packaged validator, and asserts the line still leads with the consequence, names both offered roots, names the validator and says why it is absent. The suite is not a protected one; it is declared-excluded (doc_health), so it runs in composition only. pyproject.toml: the dependency paragraph says what holds rfc3339-validator on the list. The dependency-direction check reads imports, and the validator does not import it; F7.1's fresh install does, and goes red without it. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 4 +++- tests/test_repo_root_guard.py | 39 +++++++++++++++++++++-------------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 033a93b..b2bfcff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,7 +115,9 @@ requires-python = ">=3.12" # will not validate with `date-time` unenforced. Left out, a plain install # would carry a validator it cannot run, and every snapshot it generated would # read validator-unavailable. `tests/test_dependency_direction.py` holds the -# packaged validator's imports to this list. +# packaged validator's imports to this list, and #1144's F7.1, whose fresh +# environment installs only this list and the `test` extra, goes red when +# `rfc3339-validator` leaves it (measured on this change). # LOWER BOUNDS: `referencing>=0.28.4` is the floor `jsonschema>=4.18` itself # requires, so declaring it adds nothing an install of jsonschema would not # already bring. `rfc3339-validator>=0.1.4` is the floor the `test` extra diff --git a/tests/test_repo_root_guard.py b/tests/test_repo_root_guard.py index 545c6b1..7d4e7e2 100644 --- a/tests/test_repo_root_guard.py +++ b/tests/test_repo_root_guard.py @@ -22,10 +22,11 @@ * a root that IS a corpus but projects ZERO documents WARNS loudly and still succeeds: an empty corpus is legal, and failing on it would make an honestly empty repository unusable. - * "validation SKIPPED" says WHY — it names BOTH roots the search walked up - from (the OUTPUT path first, then `--repo-root`; see - `test_snapshot_validation_launch.py`, which pins the fallback), so a skip - blames neither on its own — instead of reading as routine. + * "validation SKIPPED" says WHY — it names BOTH roots openDox offered to + search from (the OUTPUT path first, then `--repo-root`), so a skip blames + neither on its own — instead of reading as routine. Since plan 034 T061 + (#1144 7.3) neither root decides where the validator is: a skip means this + product carries no validator of its own, and the line says that too. * `serve.py --checkout-root` (the same value under a second spelling) refuses a path that cannot be a checkout, and `_checkout_real` means what its name says. @@ -260,15 +261,21 @@ def test_a_corpus_with_documents_warns_about_nothing(tmp_path, capsys): # -------------------------------------------------------------------------- def test_the_validation_skip_names_the_directory_it_searched_and_the_reason( - tmp_path, capsys): + tmp_path, capsys, monkeypatch): """The old line ("no reachable openxFactory checkout") read as routine and left the human believing the snapshot had been checked, while naming a checkout that was present and fine. The replacement leads with the consequence — NOT checked against the pinned schema — and names BOTH roots - that were walked up from, because since `_locate_validator` neither one on - its own is the reason (defect 8: the OUTPUT path is searched first, then - `--repo-root`). Here neither reaches a validator, which is what a skip now - means.""" + openDox offered (defect 8: the OUTPUT path first, then `--repo-root`), so + neither is blamed on its own. + + Since plan 034 T061 (#1144 7.3) the validator is this product's own, + whatever the roots, so a skip has one cause left: an install built without + its packaged validator. That is the case staged here (no source tree, no + packaged copy), and the line names the validator and says why it is + absent.""" + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: None) corpus_root = _corpus(tmp_path / "repo", document=DOC) out_dir = tmp_path / "out" @@ -277,13 +284,13 @@ def test_the_validation_skip_names_the_directory_it_searched_and_the_reason( assert rc == 0 # a skip is not a failure assert "NOT checked against the pinned schema" in err - assert str(snapshot_mod.VALIDATOR_RELPATH) in err - assert str(out_dir) in err # WHERE it searched, first… - assert str(corpus_root) in err # …and where it fell back to - assert snapshot_mod.find_validator(out_dir) is None, ( - "this test's premise: no validator is reachable from the output dir") - assert snapshot_mod.find_validator(corpus_root) is None, ( - "…nor from --repo-root, which is why the skip is legitimate here") + assert snapshot_mod.VALIDATOR_RELPATH.name in err + assert "installed without its packaged validator" in err + assert str(out_dir) in err # WHERE it was offered, first… + assert str(corpus_root) in err # …and then + for start in (out_dir, corpus_root, None): + assert snapshot_mod.find_validator(start) is None, ( + "this test's premise: the product carries no validator, whatever the start") # -------------------------------------------------------------------------- From bad7fe1f631db2a46c6dc373567db08011ad3426 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:15:37 +0000 Subject: [PATCH 30/44] Let a chain of entries admit one landing's several edits to a suite (plan 034 T061; batch K) Brett's ruling at opensoft/openxFactory#656 comment 5916000030 admits each of the ten cases 7.3's lookup rewords as its own R1Q7 (a) allow-list entry (T007's batch K records it). T061 is ONE landing, and it edits tests/test_snapshot.py twice and tests/test_snapshot_validation_launch.py nine times. The check T059 wired admitted a landing's edit to a suite by one entry only: one old-to-new replacement, inside one named test. So several entries for one suite may now admit one landing together. They are applied in the order listed, each to the text the one before it leaves. The first one's before_blob is the suite before the landing, and the last one's after_blob is the suite at it. Each one in between records the git blob id of the text its edit leaves, which no commit need hold. Each holds on its own texts by every existing condition, and they all name one landing. The landing's diff for the suite is therefore exactly their texts, and every entry of the chain is spent by it. Six cases cover it: a two-step chain admitted and then spent whole; a wrong blob in between, a step outside its named test, an edit no step records, and a chain naming two landings, each refused. A chain of one is the check as it was. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/protected_suites.py | 127 +++++++++++++++++++++---- tests/protected_suite_respellings.yaml | 14 ++- tests/test_protected_suite_check.py | 70 +++++++++++++- 3 files changed, 189 insertions(+), 22 deletions(-) diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py index 4942881..486271c 100644 --- a/scripts/protected_suites.py +++ b/scripts/protected_suites.py @@ -53,7 +53,30 @@ added after, and may lie in a neighbouring test, which it leaves as it was. A landing that touches a protected suite is admitted for that suite only if one -entry holds at it. Every other protected path it touches is refused. +entry holds at it, or a CHAIN of entries does. Every other protected path it +touches is refused. + +SEVERAL EDITS IN ONE LANDING (plan 034 T061; T007's batch K, on Brett's ruling +at openxFactory#656 comment 5916000030). A landing may edit one suite in more +than one test: T061 edits `tests/test_snapshot.py` twice and +`tests/test_snapshot_validation_launch.py` nine times. Each edit is its own +entry, and the entries for that suite are applied in the order they are listed, +each to the text the one before it leaves. They admit the landing together when: + +* the first one's `before_blob` is the suite before the landing, and the last + one's `after_blob` is the suite at it; +* each one in between has, as its `after_blob`, the git blob id of the text its + own edit leaves (`git hash-object` of that text, which no commit need hold), + and the next one's `before_blob` is that id, as the chain rule below already + requires of any two entries for one suite; +* each one holds on its own texts, by every condition above: its `old` occurs + once, starting a line; its replacement gives its `after_blob` text; and the + edit lies inside its one named test, or adds only it; +* they all name the same landing. + +So the landing's diff for that suite is exactly those entries' recorded texts, +each inside its own test, and nothing else. Every entry of the chain is spent by +that landing. AN ENTRY ADMITS ONE LANDING (Copilot on openXdox-code#35). The landings are taken oldest first, and an entry that has admitted one is spent: a later @@ -293,7 +316,7 @@ def _only_the_added_test(after_text: str, at: int, old: str, new: str, test: str def entry_holds(repo: Path, landing: str, entry: dict) -> str | None: - """None when `entry` holds at `landing`, else why it does not.""" + """None when `entry` alone holds at `landing`, else why it does not.""" suite = entry["suite"] before = _blob(repo, f"{landing}^1", suite) after = _blob(repo, landing, suite) @@ -301,7 +324,57 @@ def entry_holds(repo: Path, landing: str, entry: dict) -> str | None: return f"{suite} before the landing is {before}, not the entry's {entry['before_blob']}" if after != entry["after_blob"]: return f"{suite} at the landing is {after}, not the entry's {entry['after_blob']}" - before_text, after_text = _blob_text(repo, before), _blob_text(repo, after) + return _edit_holds(_blob_text(repo, before), _blob_text(repo, after), entry) + + +def _hash_text(repo: Path, text: str) -> str: + """The git blob id `text` would have. Nothing is written to the object store.""" + return subprocess.run(("git", "-C", str(repo), "hash-object", "--stdin"), + input=text.encode("utf-8"), check=True, + capture_output=True).stdout.decode("ascii").strip() + + +def chain_holds(repo: Path, landing: str, chain: list[dict]) -> str | None: + """None when the entries of `chain`, applied in order, admit `landing`'s + edits to their one suite together, else why they do not. A chain of one is + `entry_holds`.""" + if len(chain) == 1: + return entry_holds(repo, landing, chain[0]) + suite = chain[0]["suite"] + if any(entry["suite"] != suite for entry in chain): + return "a chain's entries name more than one suite" + if any(entry["landing"] != chain[0]["landing"] for entry in chain): + return "a chain's entries name more than one landing" + before = _blob(repo, f"{landing}^1", suite) + after = _blob(repo, landing, suite) + if before != chain[0]["before_blob"]: + return f"{suite} before the landing is {before}, not the chain's {chain[0]['before_blob']}" + if after != chain[-1]["after_blob"]: + return f"{suite} at the landing is {after}, not the chain's {chain[-1]['after_blob']}" + text, last = _blob_text(repo, before), _blob_text(repo, after) + for step, entry in enumerate(chain, 1): + if step > 1 and entry["before_blob"] != chain[step - 2]["after_blob"]: + return f"step {step}: its before_blob is not the step before it's after_blob" + if text.count(entry["old"]) != 1: + return (f"step {step}: its old text occurs {text.count(entry['old'])} times in " + "the text the steps before it leave, not once") + following = text.replace(entry["old"], entry["new"], 1) + if step == len(chain): + if following != last: + return f"step {step}: the chain's texts do not give the suite at the landing" + elif _hash_text(repo, following) != entry["after_blob"]: + return (f"step {step}: its after_blob is not the blob of the text its edit " + "leaves, so the chain does not record the text in between") + why = _edit_holds(text, following, entry) + if why is not None: + return f"step {step}: {why}" + text = following + return None + + +def _edit_holds(before_text: str, after_text: str, entry: dict) -> str | None: + """None when `entry`'s one edit turns `before_text` into `after_text`, + inside its named test (or adding only it), else why not.""" old, new = entry["old"], entry["new"] if before_text.count(old) != 1: return f"the entry's old text occurs {before_text.count(old)} times before the landing, not once" @@ -328,26 +401,37 @@ class Finding: path: str admitted_by: int | None why: str + #: Every entry (1-based) that admitted it, in order: one, or a chain. + chain: tuple[int, ...] = () def _admitting(repo: Path, landing: str, path: str, entries: list[dict], - spent: dict[int, str]) -> tuple[int | None, list[str]]: - """The entry (1-based) for `path` that holds at `landing`, or None and - every entry's reason for not holding. An entry in `spent` has admitted - another landing already and admits no second one.""" + spent: dict[int, str]) -> tuple[tuple[int, ...], list[str]]: + """The entries (1-based) for `path` that admit `landing`, one or a chain, + or () and every candidate's reason for not holding. A candidate starts at + an unspent entry for `path` and runs on through the entries for `path` + that follow it in the list, until one records the suite at the landing. + An entry in `spent` has admitted another landing already and admits no + second one.""" reasons = [] - for n, entry in enumerate(entries, 1): - if entry["suite"] != path: - continue + ours = [n for n, entry in enumerate(entries, 1) if entry["suite"] == path] + after = _blob(repo, landing, path) + for i, n in enumerate(ours): if n in spent: reasons.append(f"entry {n}: it admitted {spent[n][:12]} already, " "and an entry admits one landing") continue - why = entry_holds(repo, landing, entry) + run = [n] + for m in ours[i + 1:]: + if entries[run[-1] - 1]["after_blob"] == after or m in spent: + break + run.append(m) + why = chain_holds(repo, landing, [entries[k - 1] for k in run]) if why is None: - return n, [] - reasons.append(f"entry {n}: {why}") - return None, reasons + return tuple(run), [] + label = f"entry {n}" if len(run) == 1 else f"entries {run[0]}-{run[-1]}" + reasons.append(f"{label}: {why}") + return (), reasons def check(repo: Path, landings: list[str], protected: set[str], @@ -368,12 +452,13 @@ def check(repo: Path, landings: list[str], protected: set[str], f"{landing}^1", landing).splitlines() if line.strip()} for path in sorted(touched & protected): - admitted, reasons = _admitting(repo, landing, path, entries, spent) - if admitted is not None: - spent[admitted] = landing + chain, reasons = _admitting(repo, landing, path, entries, spent) + for n in chain: + spent[n] = landing findings.append(Finding( - landing, path, admitted, - "" if admitted else ("; ".join(reasons) or "no entry names this suite"))) + landing, path, chain[0] if chain else None, + "" if chain else ("; ".join(reasons) or "no entry names this suite"), + chain)) return findings @@ -427,7 +512,9 @@ def main(argv: list[str] | None = None) -> int: refused = [] for f in findings: if f.admitted_by is not None: - print(f"admitted: {f.landing[:12]} {f.path}, by entry {f.admitted_by} of {ALLOW_LIST}") + by = (f"entry {f.admitted_by}" if len(f.chain) <= 1 else + f"entries {', '.join(map(str, f.chain))}, in that order,") + print(f"admitted: {f.landing[:12]} {f.path}, by {by} of {ALLOW_LIST}") else: print(f"refused: {f.landing[:12]} {f.path}: {f.why}") refused.append(f.path) diff --git a/tests/protected_suite_respellings.yaml b/tests/protected_suite_respellings.yaml index 1bd6412..e06ec4d 100644 --- a/tests/protected_suite_respellings.yaml +++ b/tests/protected_suite_respellings.yaml @@ -24,7 +24,11 @@ # `tests/test_snapshot.py` and `tests/test_snapshot_validator_home.py`. # * The two edits 5.3a's `values` block requires (R1Q26 (a), `5851950767`, # batch I), in `tests/test_gate_loop_views.py`. -# The last two kinds are each entered with a reason, and neither is entered as +# * The ten cases 7.3's lookup rewords (Brett's ruling at `5916000030`, batch +# K), nine in `tests/test_snapshot_validation_launch.py` and one in +# `tests/test_snapshot.py`: each one's walk premise becomes 7.3's answer, +# and its assertions stay. +# The last three kinds are each entered with a reason, and none is entered as # a respelling. # # WHO WRITES IT. The task that makes the first admitted edit creates it, in its @@ -75,6 +79,14 @@ # * Entries for one suite CHAIN: each one's `before_blob` is the previous # one's `after_blob`, because each edit is made to the suite the last one # left. +# * SEVERAL EDITS IN ONE LANDING (plan 034 T061; batch K). A landing that +# edits one suite in several tests enters each edit as its own entry, in the +# order they apply, all naming that landing. The first one's `before_blob` +# is the suite before the landing and the last one's `after_blob` the suite +# at it. Each one between records, as its `after_blob`, the blob id of the +# text its edit leaves (`git hash-object`; no commit need hold it). Each +# holds on its own texts by the conditions above, so the landing's diff for +# the suite is exactly their texts, each inside its own test. # * No entry weakens an assertion. The pull request that adds an entry is # reviewed on exactly that basis (batch C). diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index 203b335..bc7e0cb 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -16,7 +16,11 @@ * entries for one suite chain, and a file that breaks its own rules refuses the whole check rather than subtracting less; * an ADDED test (plan 034 T061) is admitted only where the edit adds it and - nothing else. + nothing else; +* SEVERAL EDITS IN ONE LANDING (plan 034 T061; T007's batch K) are admitted by + a chain of entries, one per edit, applied in order: only where every step + holds on its own texts, the steps in between record the blob of the text + they leave, and the chain's texts are the landing's diff and nothing else. The last cases hold this repository's own allow-list to those rules. Which landing each entry holds at is the falsifier's to show, at the head it runs @@ -318,6 +322,70 @@ def test_two_landings_admitted_by_two_chained_entries(repo) -> None: assert sorted(f.admitted_by for f in findings) == [1, 2] +# One landing, two edits: test_second's (OLD to NEW) and then test_first's. +BOTH = AFTER.replace(" assert 1 == 1\n", " assert 2 == 2\n") + + +def _two_step_chain(repo: Repo, **second_over) -> list[dict]: + first = _entry(repo, before=BEFORE, after=AFTER) + second = _entry(repo, before=AFTER, after=BOTH, test="test_first", + old=" assert 1 == 1\n", new=" assert 2 == 2\n") + second.update(second_over) + return [first, second] + + +def test_one_landing_with_two_edits_is_admitted_by_a_chain_of_two_entries(repo) -> None: + repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") + [finding] = _check(repo, _two_step_chain(repo)) + assert finding.admitted_by == 1 + assert finding.chain == (1, 2) + + +def test_a_chain_is_spent_whole_by_its_landing(repo) -> None: + landing = repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") + repo.commit({SUITE: BEFORE}, "a revert, no landing") + replay = repo.commit({SUITE: BOTH}, f"the same two edits again\n\n{ARC}") + findings = {f.landing: f for f in _check(repo, _two_step_chain(repo))} + assert findings[landing].chain == (1, 2) + assert findings[replay].admitted_by is None + assert "admitted" in findings[replay].why and "already" in findings[replay].why + + +def test_a_chain_whose_middle_blob_is_not_the_text_between_is_refused(repo) -> None: + repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") + chain = _two_step_chain(repo) + wrong = repo.blob(AFTER + "\n") + chain[0]["after_blob"] = wrong + chain[1]["before_blob"] = wrong + [finding] = _check(repo, chain) + assert finding.admitted_by is None + assert "does not record the text in between" in finding.why + + +def test_a_chain_step_outside_its_named_test_is_refused(repo) -> None: + repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") + [finding] = _check(repo, _two_step_chain(repo, test="test_second")) + assert finding.admitted_by is None + assert "step 2: the entry's old text is not inside test_second" in finding.why + + +def test_a_landing_with_an_edit_no_step_records_is_refused(repo) -> None: + extra = BOTH + "\n\nHELPER = 1\n" + repo.commit({SUITE: extra}, f"two edits and a third\n\n{ARC}") + chain = _two_step_chain(repo) + chain[1]["after_blob"] = repo.blob(extra) + [finding] = _check(repo, chain) + assert finding.admitted_by is None + assert "do not give the suite at the landing" in finding.why + + +def test_a_chain_naming_two_landings_is_refused(repo) -> None: + repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") + [finding] = _check(repo, _two_step_chain(repo, landing="opensoft/openXdox-code#2")) + assert finding.admitted_by is None + assert "more than one landing" in finding.why + + def _command(repo: Repo, *, landings: str | None = None, suites: str = SUITE + "\n") -> list[str]: """The falsifier's call: each option carries its list, one item per line.""" if landings is None: From 5c2ef23b456faa8dcedf19b40a1c66fb3619434c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:17:08 +0000 Subject: [PATCH 31/44] Plant the launch suite's stubs as the distribution's own validator (plan 034 T061; batch K) A PROTECTED SUITE EDIT, in its own commit: nine entries of batch C's allow-list, admitted by Brett's ruling at opensoft/openxFactory#656 comment 5916000030 and recorded in #1144 by T007's batch K. Each edit lies inside its own test of tests/test_snapshot_validation_launch.py. The helpers above them are unchanged. Under 7.3 no walk finds a validator, so each test that planted a stub above --repo-root now plants it as the installed distribution's own validator (product_root None, _packaged_validator the stub, both with monkeypatch). The real find_validator answers it whatever the start. The two tests that staged "no validator" now stage a distribution carrying none of its own. Every assertion stays: SKIPPED on stderr naming both roots, the pip remedy, the validator's own diagnosis, --strict fatal, and a non-conformant snapshot blocked and blamed. One expected answer inverts, as C3's did under batch F: test_a_run_dir_beside_a_checkout_still_uses_that_one_first. The validator planted beside the run dir is never adopted, and the distribution's own runs. Its name is kept, because an entry admits an edit inside one named test. Composed, the suite goes from 5 failed, 4 passed to 9 passed. Lone, all nine fail for doc_health alone, as its declared-exclusion entry says. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_snapshot_validation_launch.py | 98 ++++++++++++++++++------ 1 file changed, 75 insertions(+), 23 deletions(-) diff --git a/tests/test_snapshot_validation_launch.py b/tests/test_snapshot_validation_launch.py index 1fb53b5..c91f7bf 100644 --- a/tests/test_snapshot_validation_launch.py +++ b/tests/test_snapshot_validation_launch.py @@ -110,17 +110,26 @@ def _served(captured) -> bool: return " serving http://" in captured.out -def test_the_default_shaped_launch_validates_from_the_repo_root(tmp_path, capsys): +def test_the_default_shaped_launch_validates_from_the_repo_root(tmp_path, capsys, + monkeypatch): """THE DEFECT: the run dir is OUTSIDE any aggregation checkout — the shape `tempfile.mkdtemp()` always produces — and the snapshot is validated anyway, because `--repo-root` is a checkout and the validator lives in it. A real temp dir is not used, because a test that wrote to /tmp/ would be asserting the same thing with less control; what matters is that the run - dir has NO aggregation ancestor, which `tmp_path/run` also has not.""" + dir has NO aggregation ancestor, which `tmp_path/run` also has not. + + SINCE PLAN 034 T061 (#1144 7.3; admitted by T007's batch K, on Brett's + ruling at openxFactory#656 comment 5916000030) the validator is the + installed distribution's own, and no walk finds it. So the stub is planted + as the distribution's own validator, and the run dir's start answers it.""" repo_root = _corpus_with_a_reachable_validator(tmp_path) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) run_dir = tmp_path / "outside" / "run" # no validator above it - assert snapshot_mod.find_validator(run_dir.parent) is None + assert snapshot_mod.find_validator(run_dir.parent) == stub # the start is ignored rc = _launch(repo_root, run_dir) captured = capsys.readouterr() @@ -132,12 +141,22 @@ def test_the_default_shaped_launch_validates_from_the_repo_root(tmp_path, capsys assert (run_dir / "snapshot.json").is_file() -def test_a_run_dir_beside_a_checkout_still_uses_that_one_first(tmp_path, capsys): - """The output path keeps PRIORITY: a run dir deliberately placed inside an - aggregation checkout is validated by THAT checkout's validator, as before. - `--repo-root` is the fallback that makes the ordinary launch validate, not a - replacement for the search that already worked.""" +def test_a_run_dir_beside_a_checkout_still_uses_that_one_first(tmp_path, capsys, + monkeypatch): + """Before plan 034 T061 the output path kept PRIORITY: a run dir placed + inside another aggregation checkout was validated by THAT checkout's + validator, found by walking up from it. + + 7.3 ENDS THAT (#1144 7.3, "no parent walk remains"; admitted by T007's + batch K, on Brett's ruling at openxFactory#656 comment 5916000030). An + enclosing tree's validator is never adopted, so the one planted beside the + run dir is not run, and the distribution's own validator (the stub) is. The + name is kept, since an entry admits an edit inside one named test; this is + the one case whose expected answer inverts, as C3's did under batch F.""" repo_root = _corpus_with_a_reachable_validator(tmp_path) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) beside = tmp_path / "other-aggregation" marker = beside / snapshot_mod.VALIDATOR_RELPATH marker.parent.mkdir(parents=True, exist_ok=True) @@ -149,11 +168,12 @@ def test_a_run_dir_beside_a_checkout_still_uses_that_one_first(tmp_path, capsys) out = capsys.readouterr().out assert rc == 0 - assert "beside-validator" in out, out + assert "beside-validator" not in out, out + assert "validation: stub-validator: 0 error(s), 0 warning(s)" in out, out def test_when_neither_root_reaches_a_validator_the_message_names_both( - tmp_path, capsys): + tmp_path, capsys, monkeypatch): """A skip is still legal — a checkout with no validator in it is a real state — but the line must not blame a checkout that is present. It names the two directories that were searched, so the human can see which one to fix. @@ -161,7 +181,14 @@ def test_when_neither_root_reaches_a_validator_the_message_names_both( On stderr, and leading with the consequence rather than the cause (the wording PR #50 landed for the same line): a diagnostic that says "this snapshot was NOT checked" must not be mistakable for the routine stdout - progress the surrounding `wrote …` lines are.""" + progress the surrounding `wrote …` lines are. + + Since plan 034 T061 (#1144 7.3; batch K) the roots never decide where the + validator is, so a skip has one cause left: the distribution carries no + validator of its own (an install built without its packaged copy). That is + the state staged here, and the line still names both roots it was offered.""" + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: None) corpus = tmp_path / "corpus" import shutil shutil.copytree(BASE_REPO, corpus) @@ -182,14 +209,18 @@ def test_when_neither_root_reaches_a_validator_the_message_names_both( # --------------------------------------------------------------------------- def test_missing_validator_dependencies_warn_and_the_server_still_starts( - tmp_path, capsys): + tmp_path, capsys, monkeypatch): """THE REGRESSION, in the shape Brett hit it: the validator is reachable, it runs, and it exits non-zero because the interpreter running it has no `jsonschema`. Nothing is known about the snapshot — which is not the same as knowing it is bad, and only the second of those justifies withholding the - dashboard from the human who asked for it.""" + dashboard from the human who asked for it. (The stub is planted as the + distribution's own validator: plan 034 T061, #1144 7.3; batch K.)""" repo_root = _corpus_with_a_reachable_validator(tmp_path, STUB_MISSING_DEPENDENCIES) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) rc = _launch(repo_root, tmp_path / "run") captured = capsys.readouterr() @@ -199,14 +230,18 @@ def test_missing_validator_dependencies_warn_and_the_server_still_starts( def test_the_dependency_warning_carries_the_pip_remedy_and_clears_the_corpus( - tmp_path, capsys): + tmp_path, capsys, monkeypatch): """A warning that does not say what to type is a warning that gets ignored. It must also relay the validator's OWN diagnosis (it is the thing that knows which library it wanted) and state plainly that the corpus is not the accused — the wrong half of that sentence is what a stopped human reads - first.""" + first. (The stub is planted as the distribution's own validator: plan 034 + T061, #1144 7.3; batch K.)""" repo_root = _corpus_with_a_reachable_validator(tmp_path, STUB_MISSING_DEPENDENCIES) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) _launch(repo_root, tmp_path / "run") err = capsys.readouterr().err @@ -217,14 +252,18 @@ def test_the_dependency_warning_carries_the_pip_remedy_and_clears_the_corpus( def test_a_non_conformant_snapshot_still_blocks_and_blames_the_snapshot( - tmp_path, capsys): + tmp_path, capsys, monkeypatch): """The other half of the distinction, and the one the fix must not spend: a validator that RAN and rejected the data still stops the serve. The message is about the snapshot, and it must not offer the dependency remedy — sending someone to `pip install` over a dangling document edge is the same defect - pointing the other way.""" + pointing the other way. (The stub is planted as the distribution's own + validator: plan 034 T061, #1144 7.3; batch K.)""" repo_root = _corpus_with_a_reachable_validator(tmp_path, STUB_NON_CONFORMANT) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) rc = _launch(repo_root, tmp_path / "run") captured = capsys.readouterr() @@ -235,12 +274,16 @@ def test_a_non_conformant_snapshot_still_blocks_and_blames_the_snapshot( assert "jsonschema" not in captured.err, captured.err -def test_strict_makes_an_unrunnable_validator_fatal(tmp_path, capsys): +def test_strict_makes_an_unrunnable_validator_fatal(tmp_path, capsys, monkeypatch): """`--strict` is a demand for certainty, so "we could not check" is a failure under it — otherwise the flag would quietly mean less than it - says.""" + says. (The stub is planted as the distribution's own validator: plan 034 + T061, #1144 7.3; batch K.)""" repo_root = _corpus_with_a_reachable_validator(tmp_path, STUB_MISSING_DEPENDENCIES) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) rc = _launch(repo_root, tmp_path / "run", "--strict") captured = capsys.readouterr() @@ -251,11 +294,15 @@ def test_strict_makes_an_unrunnable_validator_fatal(tmp_path, capsys): assert "SERVES this unchecked snapshot" not in captured.err, captured.err -def test_strict_is_fatal_when_no_validator_is_reachable_either(tmp_path, capsys): +def test_strict_is_fatal_when_no_validator_is_reachable_either(tmp_path, capsys, + monkeypatch): """The same rule for the other unavailable sub-case. A `--strict` run that found no validator at all learned exactly as much as one whose validator - would not start.""" + would not start. Since plan 034 T061 (#1144 7.3; batch K) "no validator at + all" is a distribution that carries none of its own, which is staged here.""" import shutil + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: None) corpus = tmp_path / "corpus" shutil.copytree(BASE_REPO, corpus) @@ -267,12 +314,17 @@ def test_strict_is_fatal_when_no_validator_is_reachable_either(tmp_path, capsys) def test_the_classifier_reads_the_exit_code_not_the_dependency_sentence( - tmp_path, capsys): + tmp_path, capsys, monkeypatch): """The guard against fixing this brittlely. A different environmental failure, with different words and no mention of jsonschema, is classified the same way — because the signal is the validator's documented exit code - (2 = harness error), not a phrase that a future edit could reword.""" + (2 = harness error), not a phrase that a future edit could reword. (The + stub is planted as the distribution's own validator: plan 034 T061, #1144 + 7.3; batch K.)""" repo_root = _corpus_with_a_reachable_validator(tmp_path, STUB_MISSING_YAML) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) rc = _launch(repo_root, tmp_path / "run") captured = capsys.readouterr() From 8a89bc755c62d46205bffc227557ad7ebb988a3a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:17:08 +0000 Subject: [PATCH 32/44] Make test_snapshot's missing validator where 7.3 moved the lookup (plan 034 T061; batch K) A PROTECTED SUITE EDIT, in its own commit: one entry of batch C's allow-list, admitted by Brett's ruling at opensoft/openxFactory#656 comment 5916000030 and recorded in #1144 by T007's batch K. tests/test_snapshot.py::test_a_missing_validator_is_unavailable_not_a_verdict made "missing" with a start outside the product, which C3 confined to None. Under 7.3 the start never confines, so the case stages a distribution that carries no validator of its own. Its call and search_from are unchanged, and UNAVAILABLE and `validator is None` stay. tests/test_snapshot.py now passes alone, 18 of 18. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_snapshot.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index ff7d5b1..bcd2c0b 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -185,7 +185,13 @@ def test_the_harness_exit_code_means_no_verdict_was_reached(tmp_path): assert "harness" in result.unavailable_reason -def test_a_missing_validator_is_unavailable_not_a_verdict(tmp_path): +def test_a_missing_validator_is_unavailable_not_a_verdict(tmp_path, monkeypatch): + # Since plan 034 T061 (#1144 7.3; T007's batch K, on Brett's ruling at + # openxFactory#656 comment 5916000030) the start never confines the lookup, + # so "missing" is made where the arc moved it: a distribution that carries no + # validator of its own, from no source tree and with no packaged copy. + monkeypatch.setattr(snapshot, "product_root", lambda: None) + monkeypatch.setattr(snapshot, "_packaged_validator", lambda: None) result = snapshot.validate_snapshot(_written(tmp_path), search_from=tmp_path / "out") assert result.outcome == snapshot.VALIDATOR_UNAVAILABLE From faa20dc7e6e8bdc0761c54e4f2c1f0deafcc09f7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:18:22 +0000 Subject: [PATCH 33/44] tests/test_snapshot.py leaves the declared exclusion, 66 to 65 (plan 034 T061) The entry's reason, consumer-schemas (R1Q25 (b)), held "until 7.3 finds them through the installed distribution". 7.3 now does: the consumer validator reads its own three schemas from openxdox.contracts. And the suite's one other red, the missing-validator case, is reworded under batch K. So tests/test_snapshot.py passes alone (18 of 18), and its entry leaves in the pull request that clears its reason, as the declaration's rules require. The reason leaves with its last entry. The root conftest's table of admitted reasons keeps R1Q25 (b)'s row, and its one ruled file, as the check's own tests expect: a reason leaves the declaration, not the record of what the rulings admitted. tests/test_declared_exclusion.py, 206 passed. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 4 ++-- tests/declared_exclusion.yaml | 14 +++++--------- tests/test_declared_exclusion.py | 9 +++++---- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 34efbaa..4b150b3 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -94,8 +94,8 @@ jobs: # would drift. The declaration is the list, and the run prints it. # Requirement 9 is met for this leg only once the declaration is empty. # Its `doc_health`, rail and contracts entries wait for the doc_health - # direction arc (plan 034 T008), and its consumer-schemas entry waits - # for 7.3 (T061, in phase 2). So the extraction stays open until then, + # direction arc (plan 034 T008). Its consumer-schemas entry left when 7.3 + # landed (T061, in phase 2). So the extraction stays open until T008, # and T097 ticks 9.2 with that note. # # ONE TEST IS LEFT OUT BESIDE THEM, WITH ITS STATED REASON, UNTIL T008: diff --git a/tests/declared_exclusion.yaml b/tests/declared_exclusion.yaml index fa631c6..400195f 100644 --- a/tests/declared_exclusion.yaml +++ b/tests/declared_exclusion.yaml @@ -43,8 +43,10 @@ # like each note, are one line. Every declared reason is named by at least # one entry, and `only` lists exactly the files that name its reason, each # once. A reason its ruling admits for named files alone carries exactly -# those in `only`: `consumer-schemas` is `tests/test_snapshot.py`'s alone -# (R1Q25 (b)). +# those in `only`: `consumer-schemas` was `tests/test_snapshot.py`'s alone +# (R1Q25 (b)), and left with that entry when 7.3 landed (plan 034 T061), +# which reads the consumer validator's own three schemas from the +# installed distribution. # * An entry leaves in the pull request that clears its reason, and the count # moves with it. A reason leaves with its last entry. # @@ -57,7 +59,7 @@ schema_version: 1 kind: declared-test-exclusion -count: 66 +count: 65 reasons: - id: doc_health @@ -72,11 +74,6 @@ reasons: reason: "reads openxFactory's contracts (the contract family's validator, schemas and examples, or its contracts/manifest.yaml) where openxFactory's tree keeps them, which is outside this checkout" ruled: "R1Q24 (a), openxFactory#656 comment 5850003126" open_until: "the doc_health direction arc (plan 034 T008)" - - id: consumer-schemas - reason: "needs the consumer validator's schemas, which neither a contracts/ in this tree nor CONTRACTS_DIR supplies until 7.3 finds them through the installed distribution" - ruled: "R1Q25 (b), openxFactory#656 comment 5850003126" - open_until: "7.3, the consumer's validator lookup (plan 034 T061, in phase 2)" - only: [tests/test_snapshot.py] entries: - {path: tests/test_authoring_agent.py, reasons: [doc_health]} @@ -134,7 +131,6 @@ entries: - {path: tests/test_session_snapshot.py, reasons: [doc_health]} - {path: tests/test_session_transaction.py, reasons: [doc_health]} - {path: tests/test_session_verbs.py, reasons: [doc_health]} - - {path: tests/test_snapshot.py, reasons: [consumer-schemas], note: "a 5.4a protected suite; T061's landing (7.3) clears this entry"} - {path: tests/test_snapshot_determinism.py, reasons: [doc_health]} - {path: tests/test_snapshot_registry.py, reasons: [doc_health]} - {path: tests/test_snapshot_validation_launch.py, reasons: [doc_health], note: "a 5.4a protected suite"} diff --git a/tests/test_declared_exclusion.py b/tests/test_declared_exclusion.py index 23d0163..8865760 100644 --- a/tests/test_declared_exclusion.py +++ b/tests/test_declared_exclusion.py @@ -10,9 +10,10 @@ * T007 batch B's three assertions on the file: its count equals its entries, every entry carries its reason, and a run that loads the root conftest prints it; - * the reasons are the four the rulings admit (R1Q6 (d); R1Q24 (a) twice; - R1Q25 (b)), and the consumer's schemas are `tests/test_snapshot.py`'s - alone; + * the reasons are drawn from the four the rulings admit (R1Q6 (d); R1Q24 + (a) twice; R1Q25 (b)), and the consumer's schemas were + `tests/test_snapshot.py`'s alone, until T061 (7.3) cleared that entry and + the reason left with it; * the root conftest derives `collect_ignore` from the file, refuses a file that breaks a rule, and the whole suite collects less exactly these files, while a listed file named on the command line is collected and printed as @@ -23,7 +24,7 @@ The last is the one that stops the exclusion from hiding anything. A listed file that also failed for some other cause would turn its case red, and so would a file that stopped failing for its reason. That file then leaves the -list, in the pull request that clears the reason (T061's, for +list, in the pull request that clears the reason (as T061's did for `tests/test_snapshot.py`). An entry may name two reasons, and each is then checked (`tests/test_doxbench_packet.py` and `tests/test_doxbench_blank_reason.py`). From c49e906a9470b89b380ee74451233be4e42824fb Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:21:37 +0000 Subject: [PATCH 34/44] Confine chains to F5.2's call, --chains (plan 034 T061; batch K) The holder's decision on the chain rule: Brett's ruling admits T061's ten under F5.2, whose protected set they are in. Neither tests/test_snapshot.py nor tests/test_snapshot_validation_launch.py is among 12.5's sixteen governed suites (`git grep -l -e open-pr -e open_pr -e FakePullRequests`, at main and at this head). So a chain is admitted only when the call passes the new opt-in flag, --chains, which F5.2's call does and 12.5's does not. Without the flag the rule stays one entry per suite per landing, as T059 wired it. 12.5's call keeps its text, and a new case shows it refusing a chain that F5.2's call admits. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/protected_suites.py | 47 ++++++++++++++++---------- tests/protected_suite_respellings.yaml | 18 +++++----- tests/test_protected_suite_check.py | 43 +++++++++++++++++------ 3 files changed, 72 insertions(+), 36 deletions(-) diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py index 486271c..7c7c78c 100644 --- a/scripts/protected_suites.py +++ b/scripts/protected_suites.py @@ -19,11 +19,13 @@ the falsifier's own block, as #1144 writes them. The last step, the inline Python that intersected them, becomes this call, from the checkout's root: - python3 scripts/protected_suites.py --landings="$(cat "$W/x-arc.txt")" \ + python3 scripts/protected_suites.py --chains --landings="$(cat "$W/x-arc.txt")" \ --suites="$(cat "$W/gen-suites.txt")" # F5.2 python3 scripts/protected_suites.py --landings="$(cat "$W/x-arc.txt")" \ --suites="$(cat "$W/governed.txt")" # 12.5 +(`--chains` is F5.2's alone, since T061; see SEVERAL EDITS IN ONE LANDING.) + Each option carries its LIST, one item per line, and never a path to one: the landings, each a full commit id as `git log --format=%H` prints it, and the protected suites, each `tests/test_.py`. (The `=` keeps a value that @@ -53,8 +55,8 @@ added after, and may lie in a neighbouring test, which it leaves as it was. A landing that touches a protected suite is admitted for that suite only if one -entry holds at it, or a CHAIN of entries does. Every other protected path it -touches is refused. +entry holds at it, or, in F5.2's call, a CHAIN of entries does. Every other +protected path it touches is refused. SEVERAL EDITS IN ONE LANDING (plan 034 T061; T007's batch K, on Brett's ruling at openxFactory#656 comment 5916000030). A landing may edit one suite in more @@ -78,6 +80,12 @@ each inside its own test, and nothing else. Every entry of the chain is spent by that landing. +F5.2'S CALL ALONE. Brett's ruling admits T061's ten under F5.2, whose protected +set they are in, and 12.5's governed set holds neither of their suites. So a +chain is admitted only when the call passes `--chains`, which F5.2's does and +12.5's does not. Without it, a landing's edits to one suite are admitted by one +entry or not at all, as T059 wired the check, and a chain is refused. + AN ENTRY ADMITS ONE LANDING (Copilot on openXdox-code#35). The landings are taken oldest first, and an entry that has admitted one is spent: a later landing that repeats the same edit, after the suite came back to the entry's @@ -406,13 +414,14 @@ class Finding: def _admitting(repo: Path, landing: str, path: str, entries: list[dict], - spent: dict[int, str]) -> tuple[tuple[int, ...], list[str]]: - """The entries (1-based) for `path` that admit `landing`, one or a chain, - or () and every candidate's reason for not holding. A candidate starts at - an unspent entry for `path` and runs on through the entries for `path` - that follow it in the list, until one records the suite at the landing. - An entry in `spent` has admitted another landing already and admits no - second one.""" + spent: dict[int, str], chains: bool) -> tuple[tuple[int, ...], list[str]]: + """The entries (1-based) for `path` that admit `landing`, one or (with + `chains`) a chain, or () and every candidate's reason for not holding. A + candidate starts at an unspent entry for `path` and, with `chains`, runs on + through the entries for `path` that follow it in the list, until one + records the suite at the landing. Without `chains` a candidate is its one + entry. An entry in `spent` has admitted another landing already and admits + no second one.""" reasons = [] ours = [n for n, entry in enumerate(entries, 1) if entry["suite"] == path] after = _blob(repo, landing, path) @@ -422,7 +431,7 @@ def _admitting(repo: Path, landing: str, path: str, entries: list[dict], "and an entry admits one landing") continue run = [n] - for m in ours[i + 1:]: + for m in (ours[i + 1:] if chains else ()): if entries[run[-1] - 1]["after_blob"] == after or m in spent: break run.append(m) @@ -435,11 +444,12 @@ def _admitting(repo: Path, landing: str, path: str, entries: list[dict], def check(repo: Path, landings: list[str], protected: set[str], - entries: list[dict]) -> list[Finding]: + entries: list[dict], *, chains: bool = False) -> list[Finding]: """One finding per protected path each landing touched: admitted by the - entry (1-based) that holds there, or refused with every entry's reason. - The landings are taken oldest first, whatever order they come in, and - each entry admits one of them at most.""" + entry (1-based) that holds there, or, with `chains` (F5.2's call), by the + chain of entries that does, or refused with every candidate's reason. The + landings are taken oldest first, whatever order they come in, and each + entry admits one of them at most.""" findings: list[Finding] = [] spent: dict[int, str] = {} ancestors = {landing: int(_git(repo, "rev-list", "--count", landing).strip()) @@ -452,7 +462,7 @@ def check(repo: Path, landings: list[str], protected: set[str], f"{landing}^1", landing).splitlines() if line.strip()} for path in sorted(touched & protected): - chain, reasons = _admitting(repo, landing, path, entries, spent) + chain, reasons = _admitting(repo, landing, path, entries, spent, chains) for n in chain: spent[n] = landing findings.append(Finding( @@ -482,6 +492,9 @@ def main(argv: list[str] | None = None) -> int: help="the arc's landings, one full commit id per line") parser.add_argument("--suites", required=True, help="the protected suites, one tests/test_.py per line") + parser.add_argument("--chains", action="store_true", + help="admit a chain of entries for one landing's several edits " + "to a suite (F5.2's call alone; plan 034 T061, batch K)") try: args = parser.parse_args(sys.argv[1:] if argv is None else argv) except SystemExit as exc: @@ -500,7 +513,7 @@ def main(argv: list[str] | None = None) -> int: file=sys.stderr) return 2 try: - findings = check(repo, landings, suites, entries) + findings = check(repo, landings, suites, entries, chains=args.chains) except subprocess.CalledProcessError as exc: # A landing this checkout does not hold, or one with no parent: the # history the check needs is not here, which is not a refusal. diff --git a/tests/protected_suite_respellings.yaml b/tests/protected_suite_respellings.yaml index e06ec4d..0b59e4f 100644 --- a/tests/protected_suite_respellings.yaml +++ b/tests/protected_suite_respellings.yaml @@ -79,14 +79,16 @@ # * Entries for one suite CHAIN: each one's `before_blob` is the previous # one's `after_blob`, because each edit is made to the suite the last one # left. -# * SEVERAL EDITS IN ONE LANDING (plan 034 T061; batch K). A landing that -# edits one suite in several tests enters each edit as its own entry, in the -# order they apply, all naming that landing. The first one's `before_blob` -# is the suite before the landing and the last one's `after_blob` the suite -# at it. Each one between records, as its `after_blob`, the blob id of the -# text its edit leaves (`git hash-object`; no commit need hold it). Each -# holds on its own texts by the conditions above, so the landing's diff for -# the suite is exactly their texts, each inside its own test. +# * SEVERAL EDITS IN ONE LANDING (plan 034 T061; batch K), in F5.2's call +# alone (`--chains`; 12.5's keeps one entry per suite per landing). A +# landing that edits one suite in several tests enters each edit as its +# own entry, in the order they apply, all naming that landing. The first +# one's `before_blob` is the suite before the landing and the last one's +# `after_blob` the suite at it. Each one between records, as its +# `after_blob`, the blob id of the text its edit leaves (`git +# hash-object`; no commit need hold it). Each holds on its own texts by the +# conditions above, so the landing's diff for the suite is exactly their +# texts, each inside its own test. # * No entry weakens an assertion. The pull request that adds an entry is # reviewed on exactly that basis (batch C). diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index bc7e0cb..de4c1a5 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -18,9 +18,10 @@ * an ADDED test (plan 034 T061) is admitted only where the edit adds it and nothing else; * SEVERAL EDITS IN ONE LANDING (plan 034 T061; T007's batch K) are admitted by - a chain of entries, one per edit, applied in order: only where every step - holds on its own texts, the steps in between record the blob of the text - they leave, and the chain's texts are the landing's diff and nothing else. + a chain of entries, one per edit, applied in order: only in F5.2's call + (`--chains`), and only where every step holds on its own texts, the steps + in between record the blob of the text they leave, and the chain's texts are + the landing's diff and nothing else. 12.5's call refuses a chain. The last cases hold this repository's own allow-list to those rules. Which landing each entry holds at is the falsifier's to show, at the head it runs @@ -111,10 +112,10 @@ def _entry(repo: Repo, *, before: str = BEFORE, after: str = AFTER, old: str = O return entry -def _check(repo: Repo, entries: list[dict]) -> list[ps.Finding]: +def _check(repo: Repo, entries: list[dict], *, chains: bool = False) -> list[ps.Finding]: landings = repo.git("log", "--first-parent", "--format=%H", f"--grep=^{ARC}$", "HEAD").splitlines() - return ps.check(repo.root, landings, {SUITE}, entries) + return ps.check(repo.root, landings, {SUITE}, entries, chains=chains) # -------------------------------------------------------------------------- @@ -336,16 +337,35 @@ def _two_step_chain(repo: Repo, **second_over) -> list[dict]: def test_one_landing_with_two_edits_is_admitted_by_a_chain_of_two_entries(repo) -> None: repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") - [finding] = _check(repo, _two_step_chain(repo)) + [finding] = _check(repo, _two_step_chain(repo), chains=True) assert finding.admitted_by == 1 assert finding.chain == (1, 2) +def test_a_chain_is_refused_in_12_5s_call(repo, monkeypatch) -> None: + """Chains are F5.2's alone (Brett's ruling admits T061's ten under F5.2; + batch K). 12.5's call passes no `--chains`, so its rule stays one entry per + suite per landing: each entry of the chain is tried alone, and neither + turns the suite before the landing into the suite at it.""" + repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") + chain = _two_step_chain(repo) + [finding] = _check(repo, chain) + assert finding.admitted_by is None and finding.chain == () + assert "entry 1:" in finding.why and "entries 1-2" not in finding.why + allow = repo.root / ps.ALLOW_LIST + monkeypatch.chdir(repo.root) + allow.parent.mkdir(parents=True, exist_ok=True) + allow.write_text(yaml.safe_dump({"schema_version": 1, "kind": ps.KIND, "entries": chain}), + encoding="utf-8") + assert ps.main(_command(repo)) == 1 # 12.5's call + assert ps.main(["--chains", *_command(repo)]) == 0 # F5.2's call + + def test_a_chain_is_spent_whole_by_its_landing(repo) -> None: landing = repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") repo.commit({SUITE: BEFORE}, "a revert, no landing") replay = repo.commit({SUITE: BOTH}, f"the same two edits again\n\n{ARC}") - findings = {f.landing: f for f in _check(repo, _two_step_chain(repo))} + findings = {f.landing: f for f in _check(repo, _two_step_chain(repo), chains=True)} assert findings[landing].chain == (1, 2) assert findings[replay].admitted_by is None assert "admitted" in findings[replay].why and "already" in findings[replay].why @@ -357,14 +377,14 @@ def test_a_chain_whose_middle_blob_is_not_the_text_between_is_refused(repo) -> N wrong = repo.blob(AFTER + "\n") chain[0]["after_blob"] = wrong chain[1]["before_blob"] = wrong - [finding] = _check(repo, chain) + [finding] = _check(repo, chain, chains=True) assert finding.admitted_by is None assert "does not record the text in between" in finding.why def test_a_chain_step_outside_its_named_test_is_refused(repo) -> None: repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") - [finding] = _check(repo, _two_step_chain(repo, test="test_second")) + [finding] = _check(repo, _two_step_chain(repo, test="test_second"), chains=True) assert finding.admitted_by is None assert "step 2: the entry's old text is not inside test_second" in finding.why @@ -374,14 +394,15 @@ def test_a_landing_with_an_edit_no_step_records_is_refused(repo) -> None: repo.commit({SUITE: extra}, f"two edits and a third\n\n{ARC}") chain = _two_step_chain(repo) chain[1]["after_blob"] = repo.blob(extra) - [finding] = _check(repo, chain) + [finding] = _check(repo, chain, chains=True) assert finding.admitted_by is None assert "do not give the suite at the landing" in finding.why def test_a_chain_naming_two_landings_is_refused(repo) -> None: repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") - [finding] = _check(repo, _two_step_chain(repo, landing="opensoft/openXdox-code#2")) + [finding] = _check(repo, _two_step_chain(repo, landing="opensoft/openXdox-code#2"), + chains=True) assert finding.admitted_by is None assert "more than one landing" in finding.why From 8015a5657f871157f3061efd346a3e99359a9d23 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:47:32 +0000 Subject: [PATCH 35/44] Enter T061's twelve protected edits in the allow-list (plan 034 T061) Each edit this pull request makes to a protected suite is entered with its reason and landing (opensoft/openXdox-code#36): * batch F's two (R1Q14 (a)): the added test_the_validator_is_the_installed_consumers_own, and the revised test_a_start_outside_the_product_is_refused_not_walked; * batch K's ten (Brett's ruling at opensoft/openxFactory#656 comment 5916000030): nine in tests/test_snapshot_validation_launch.py and one in tests/test_snapshot.py. tests/test_snapshot.py's two entries, and the launch suite's nine, are each a chain under F5.2's --chains. Each chain's first before_blob is main's blob, its last after_blob is this head's, and each blob in between is the text its step leaves. Each hunk is the minimal whole-line replacement inside its test. The loader accepts all fifteen entries, and applying each chain to main's text gives this head's text. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/protected_suite_respellings.yaml | 595 +++++++++++++++++++++++++ 1 file changed, 595 insertions(+) diff --git a/tests/protected_suite_respellings.yaml b/tests/protected_suite_respellings.yaml index 0b59e4f..5933153 100644 --- a/tests/protected_suite_respellings.yaml +++ b/tests/protected_suite_respellings.yaml @@ -297,3 +297,598 @@ entries: source = inspect.getsource(serve_mod.DashboardHandler._serve_source) assert "resolve_source_path(Path(root), rest)" in source + - suite: tests/test_snapshot.py + test: test_the_validator_is_the_installed_consumers_own + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + F7.1's first named test, ADDED after + test_referentially_broken_snapshot_is_rejected (plan 034 T061, #1144 + 7.3). It plants the pre-shed tree above the start and asserts that + find_validator answers the consumer's own validator for every start, + never one under the planted tree, and that it runs: a good snapshot + validates, a dangling edge is not conformant. The first of the two edits + batch F admits. + ruled: >- + R1Q14 (a), opensoft/openxFactory#656 comment 5850003126, recorded in + #1144's F5.2 by T007's batch F. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, which adds the named test and nothing beside it. No + assertion is weakened: the edit adds a test and changes no line of any + other. + before_blob: 312224fb34e148606be1e09e5674680ca050703a + after_blob: ff7d5b1f756eb15419536afc6c204e507d7efd6c + old: |2 + snapshot.validate_or_raise(p, validator=validator) + new: |2 + snapshot.validate_or_raise(p, validator=validator) + + + def test_the_validator_is_the_installed_consumers_own(tmp_path, monkeypatch): + """#1144 7.3 (plan 034 T061, RULED R1Q14 (a)): the lookup answers the INSTALLED + distribution's own validator, whatever it is asked from, and never an + enclosing tree's. The planted tree is F7.1's: a pre-shed + `openxFactory/scripts/validate-ideation-dashboard-contracts.py` above the + working directory, which exits 0 whatever it is given. And the validator + found checks a snapshot against the consumer's own schema, which it reads + from the distribution (R1Q27 (a)), with no `contracts/` of its own and no + `CONTRACTS_DIR`: a dangling edge is a verdict, and a conforming snapshot + validates. This test is added by T007's batch F, entered in + `tests/protected_suite_respellings.yaml`.""" + import pathlib + + planted = tmp_path / "preshed" + decoy = planted / "openxFactory" / "scripts" / "validate-ideation-dashboard-contracts.py" + decoy.parent.mkdir(parents=True) + decoy.write_text("raise SystemExit(0)\n", encoding="utf-8") + (planted / "work").mkdir() + monkeypatch.chdir(planted / "work") + monkeypatch.delenv("CONTRACTS_DIR", raising=False) + + own = snapshot.find_validator() + assert own is not None, "the consumer found no validator of its own" + for start in (planted / "work", planted, planted / "openxFactory", tmp_path): + assert snapshot.find_validator(start) == own, start + assert planted.resolve() not in own.resolve().parents + root = snapshot.product_root() + home = root if root is not None else pathlib.Path(snapshot.__file__).resolve().parent + assert own.resolve().is_relative_to(home.resolve()) + + b = OutputBoundary(tmp_path, ["out/"]) + good = snapshot.write_snapshot(_minimal_snapshot(), tmp_path / "out" / "s.json", b) + result = snapshot.validate_snapshot(good, search_from=planted / "work") + assert result.outcome == snapshot.VALIDATED, result.summary() + result.stdout + result.stderr + assert result.validator == own + bad = snapshot.write_snapshot(_minimal_snapshot(clusters=[{ + "id": "cl-x", "name": "X", "topics": ["x"], + "document_edges": [{"document": "doc-missing", "matched_topics": ["x"]}]}]), + tmp_path / "out" / "bad.json", b) + refused = snapshot.validate_snapshot(bad, search_from=planted) + assert refused.outcome == snapshot.NOT_CONFORMANT, refused.stdout + refused.stderr + assert "dangling" in refused.stdout + - suite: tests/test_snapshot.py + test: test_a_missing_validator_is_unavailable_not_a_verdict + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + Case 10 of batch K's ten (plan 034 T061, #1144 7.3). A start outside the + product no longer confines the lookup, so "missing" is made where the + arc moved it: the distribution carries no validator of its own + (`product_root` and `_packaged_validator` both None), the one way under + 7.3 to have none. The call and its search_from are unchanged. + ruled: >- + Brett's ruling at opensoft/openxFactory#656 comment 5916000030 ("Reword + as reviewed entries"), on R1Q7 (a), comment 5817152735. It is recorded + in #1144's F5.2 by T007's batch K, and T061 lands only after batch K + does. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, inside the test named. No assertion is weakened: + UNAVAILABLE and `validator is None` stay. + before_blob: ff7d5b1f756eb15419536afc6c204e507d7efd6c + after_blob: bcd2c0b6308cd9f8ea170567f52505ded9468d39 + old: |2 + def test_a_missing_validator_is_unavailable_not_a_verdict(tmp_path): + new: |2 + def test_a_missing_validator_is_unavailable_not_a_verdict(tmp_path, monkeypatch): + # Since plan 034 T061 (#1144 7.3; T007's batch K, on Brett's ruling at + # openxFactory#656 comment 5916000030) the start never confines the lookup, + # so "missing" is made where the arc moved it: a distribution that carries no + # validator of its own, from no source tree and with no packaged copy. + monkeypatch.setattr(snapshot, "product_root", lambda: None) + monkeypatch.setattr(snapshot, "_packaged_validator", lambda: None) + - suite: tests/test_snapshot_validator_home.py + test: test_a_start_outside_the_product_is_refused_not_walked + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + The second of the two edits batch F admits (plan 034 T061, #1144 7.3): + the expected answer is now 7.3's. A start outside the product no longer + confines the answer to None; every start answers the product's own + validator, and the planted tree above it is still never walked. + ruled: >- + R1Q14 (a), opensoft/openxFactory#656 comment 5850003126, recorded in + #1144's F5.2 by T007's batch F. + review: >- + Reviewed in the landing pull request on batch C's basis. Its diff for + this suite is exactly `old` to `new`, inside the one test named, and + nothing else. No assertion is weakened: the planted tree is still + asserted never to be adopted. + before_blob: 789a87b26313d630f0cce249e9350a4d1b60e2c5 + after_blob: 70a687a78311d2f1a6ed307f8102c8ccf90b953b + old: |2 + """(iii) `start` CONFINES. Every directory above or beside the product — + the enclosing checkout's root, its `openxFactory/`, a run directory — answers + None; none of them is walked to the enclosing validator sitting right there.""" + agg, enclosing, product, module = _enclosed_product(tmp_path, load_copy) + assert enclosing.is_file() + for start in (agg, agg / "openxFactory", agg / "work", agg / "work" / "out"): + assert module.find_validator(start) is None, start + assert module.find_validator(product / "src") == \ + product / "scripts" / "validate-ideation-dashboard-contracts.py" + new: |2 + """(iii) `start` IS IGNORED (#1144 7.3, plan 034 T061, RULED R1Q14 (a); this + case's expected answer revised under T007's batch F, entered in + `tests/protected_suite_respellings.yaml`). The lookup answers the installed + distribution's own validator whatever it is asked from. Every directory + above or beside the product — the enclosing checkout's root, its + `openxFactory/`, a run directory — answers the product's own; none of them + is walked to the enclosing validator sitting right there. Until 7.3 a start + outside the product CONFINED the answer to None.""" + agg, enclosing, product, module = _enclosed_product(tmp_path, load_copy) + own = product / "scripts" / "validate-ideation-dashboard-contracts.py" + assert enclosing.is_file() + for start in (agg, agg / "openxFactory", agg / "work", agg / "work" / "out"): + assert module.find_validator(start) == own, start + assert module.find_validator(product / "src") == own + - suite: tests/test_snapshot_validation_launch.py + test: test_the_default_shaped_launch_validates_from_the_repo_root + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + One of batch K's ten (plan 034 T061, #1144 7.3). Under 7.3 no walk finds + a validator, so the stub is planted as the installed distribution's own + validator (`product_root` None, `_packaged_validator` the stub), which + the real `find_validator` answers whatever the start; the run dir's + start answers it rather than None. + ruled: >- + Brett's ruling at opensoft/openxFactory#656 comment 5916000030 ("Reword + as reviewed entries"), on R1Q7 (a), comment 5817152735. It is recorded + in #1144's F5.2 by T007's batch K, and T061 lands only after batch K + does. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, inside the test named. No assertion is weakened: rc + 0, not SKIPPED on either stream, the stub's verdict line, and the + snapshot written all stay. + before_blob: 1fb53b56ac219b4c7d8020c3263b300a141e6e27 + after_blob: 6c3f5e9e261473c61ccd84b366c8ad06e1a08630 + old: |2 + def test_the_default_shaped_launch_validates_from_the_repo_root(tmp_path, capsys): + """THE DEFECT: the run dir is OUTSIDE any aggregation checkout — the shape + `tempfile.mkdtemp()` always produces — and the snapshot is validated anyway, + because `--repo-root` is a checkout and the validator lives in it. + + A real temp dir is not used, because a test that wrote to /tmp/ would + be asserting the same thing with less control; what matters is that the run + dir has NO aggregation ancestor, which `tmp_path/run` also has not.""" + repo_root = _corpus_with_a_reachable_validator(tmp_path) + run_dir = tmp_path / "outside" / "run" # no validator above it + assert snapshot_mod.find_validator(run_dir.parent) is None + new: |2 + def test_the_default_shaped_launch_validates_from_the_repo_root(tmp_path, capsys, + monkeypatch): + """THE DEFECT: the run dir is OUTSIDE any aggregation checkout — the shape + `tempfile.mkdtemp()` always produces — and the snapshot is validated anyway, + because `--repo-root` is a checkout and the validator lives in it. + + A real temp dir is not used, because a test that wrote to /tmp/ would + be asserting the same thing with less control; what matters is that the run + dir has NO aggregation ancestor, which `tmp_path/run` also has not. + + SINCE PLAN 034 T061 (#1144 7.3; admitted by T007's batch K, on Brett's + ruling at openxFactory#656 comment 5916000030) the validator is the + installed distribution's own, and no walk finds it. So the stub is planted + as the distribution's own validator, and the run dir's start answers it.""" + repo_root = _corpus_with_a_reachable_validator(tmp_path) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) + run_dir = tmp_path / "outside" / "run" # no validator above it + assert snapshot_mod.find_validator(run_dir.parent) == stub # the start is ignored + - suite: tests/test_snapshot_validation_launch.py + test: test_a_run_dir_beside_a_checkout_still_uses_that_one_first + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + One of batch K's ten (plan 034 T061, #1144 7.3). 7.3 ends output-path + priority: an enclosing tree's validator is never adopted, so the one + beside the run dir is not run, and the stub is planted as the installed + distribution's own validator (`product_root` None, `_packaged_validator` + the stub), which the real `find_validator` answers whatever the start. + The one case whose expected answer inverts, as C3's did under batch F; + its name is kept because an entry admits an edit inside one named test. + ruled: >- + Brett's ruling at opensoft/openxFactory#656 comment 5916000030 ("Reword + as reviewed entries"), on R1Q7 (a), comment 5817152735. It is recorded + in #1144's F5.2 by T007's batch K, and T061 lands only after batch K + does. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, inside the test named. No assertion is weakened: rc 0 + stays, and the case now asserts both that the beside validator did not + run and that the distribution's own did. + before_blob: 6c3f5e9e261473c61ccd84b366c8ad06e1a08630 + after_blob: 94b95b33fb298b0c8de24a84f827e0872fd74ec4 + old: |2 + def test_a_run_dir_beside_a_checkout_still_uses_that_one_first(tmp_path, capsys): + """The output path keeps PRIORITY: a run dir deliberately placed inside an + aggregation checkout is validated by THAT checkout's validator, as before. + `--repo-root` is the fallback that makes the ordinary launch validate, not a + replacement for the search that already worked.""" + repo_root = _corpus_with_a_reachable_validator(tmp_path) + beside = tmp_path / "other-aggregation" + marker = beside / snapshot_mod.VALIDATOR_RELPATH + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('print("beside-validator: 0 error(s), 0 warning(s)")\n', + encoding="utf-8") + run_dir = beside / "run" + + rc = _launch(repo_root, run_dir) + out = capsys.readouterr().out + + assert rc == 0 + assert "beside-validator" in out, out + new: |2 + def test_a_run_dir_beside_a_checkout_still_uses_that_one_first(tmp_path, capsys, + monkeypatch): + """Before plan 034 T061 the output path kept PRIORITY: a run dir placed + inside another aggregation checkout was validated by THAT checkout's + validator, found by walking up from it. + + 7.3 ENDS THAT (#1144 7.3, "no parent walk remains"; admitted by T007's + batch K, on Brett's ruling at openxFactory#656 comment 5916000030). An + enclosing tree's validator is never adopted, so the one planted beside the + run dir is not run, and the distribution's own validator (the stub) is. The + name is kept, since an entry admits an edit inside one named test; this is + the one case whose expected answer inverts, as C3's did under batch F.""" + repo_root = _corpus_with_a_reachable_validator(tmp_path) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) + beside = tmp_path / "other-aggregation" + marker = beside / snapshot_mod.VALIDATOR_RELPATH + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('print("beside-validator: 0 error(s), 0 warning(s)")\n', + encoding="utf-8") + run_dir = beside / "run" + + rc = _launch(repo_root, run_dir) + out = capsys.readouterr().out + + assert rc == 0 + assert "beside-validator" not in out, out + assert "validation: stub-validator: 0 error(s), 0 warning(s)" in out, out + - suite: tests/test_snapshot_validation_launch.py + test: test_when_neither_root_reaches_a_validator_the_message_names_both + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + One of batch K's ten (plan 034 T061, #1144 7.3). Under 7.3 a skip has + one cause left, so the distribution carries no validator of its own + (`product_root` and `_packaged_validator` both None), the one way under + 7.3 to have none. + ruled: >- + Brett's ruling at opensoft/openxFactory#656 comment 5916000030 ("Reword + as reviewed entries"), on R1Q7 (a), comment 5817152735. It is recorded + in #1144's F5.2 by T007's batch K, and T061 lands only after batch K + does. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, inside the test named. No assertion is weakened: rc + 0, SKIPPED on stderr only, both offered roots named, and "NOT checked + against the pinned schema" all stay. + before_blob: 94b95b33fb298b0c8de24a84f827e0872fd74ec4 + after_blob: a00b60881d240215ad04166982309245b503fc22 + old: |2 + tmp_path, capsys): + """A skip is still legal — a checkout with no validator in it is a real + state — but the line must not blame a checkout that is present. It names the + two directories that were searched, so the human can see which one to fix. + + On stderr, and leading with the consequence rather than the cause (the + wording PR #50 landed for the same line): a diagnostic that says "this + snapshot was NOT checked" must not be mistakable for the routine stdout + progress the surrounding `wrote …` lines are.""" + new: |2 + tmp_path, capsys, monkeypatch): + """A skip is still legal — a checkout with no validator in it is a real + state — but the line must not blame a checkout that is present. It names the + two directories that were searched, so the human can see which one to fix. + + On stderr, and leading with the consequence rather than the cause (the + wording PR #50 landed for the same line): a diagnostic that says "this + snapshot was NOT checked" must not be mistakable for the routine stdout + progress the surrounding `wrote …` lines are. + + Since plan 034 T061 (#1144 7.3; batch K) the roots never decide where the + validator is, so a skip has one cause left: the distribution carries no + validator of its own (an install built without its packaged copy). That is + the state staged here, and the line still names both roots it was offered.""" + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: None) + - suite: tests/test_snapshot_validation_launch.py + test: test_missing_validator_dependencies_warn_and_the_server_still_starts + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + One of batch K's ten (plan 034 T061, #1144 7.3). 7.3 moved where the + validator is found, not what a harness exit means, so the stub is + planted as the installed distribution's own validator (`product_root` + None, `_packaged_validator` the stub), which the real `find_validator` + answers whatever the start. + ruled: >- + Brett's ruling at opensoft/openxFactory#656 comment 5916000030 ("Reword + as reviewed entries"), on R1Q7 (a), comment 5817152735. It is recorded + in #1144's F5.2 by T007's batch K, and T061 lands only after batch K + does. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, inside the test named. No assertion is weakened: rc + 0, the server started, and "NOT checked against the pinned schema" stay. + before_blob: a00b60881d240215ad04166982309245b503fc22 + after_blob: 8a1e67670c5ad064d71345102b1d54ea0341ca18 + old: |2 + tmp_path, capsys): + """THE REGRESSION, in the shape Brett hit it: the validator is reachable, it + runs, and it exits non-zero because the interpreter running it has no + `jsonschema`. Nothing is known about the snapshot — which is not the same as + knowing it is bad, and only the second of those justifies withholding the + dashboard from the human who asked for it.""" + repo_root = _corpus_with_a_reachable_validator(tmp_path, + STUB_MISSING_DEPENDENCIES) + new: |2 + tmp_path, capsys, monkeypatch): + """THE REGRESSION, in the shape Brett hit it: the validator is reachable, it + runs, and it exits non-zero because the interpreter running it has no + `jsonschema`. Nothing is known about the snapshot — which is not the same as + knowing it is bad, and only the second of those justifies withholding the + dashboard from the human who asked for it. (The stub is planted as the + distribution's own validator: plan 034 T061, #1144 7.3; batch K.)""" + repo_root = _corpus_with_a_reachable_validator(tmp_path, + STUB_MISSING_DEPENDENCIES) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) + - suite: tests/test_snapshot_validation_launch.py + test: test_the_dependency_warning_carries_the_pip_remedy_and_clears_the_corpus + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + One of batch K's ten (plan 034 T061, #1144 7.3). As the case above: the + stub is planted as the installed distribution's own validator + (`product_root` None, `_packaged_validator` the stub), which the real + `find_validator` answers whatever the start. + ruled: >- + Brett's ruling at opensoft/openxFactory#656 comment 5916000030 ("Reword + as reviewed entries"), on R1Q7 (a), comment 5817152735. It is recorded + in #1144's F5.2 by T007's batch K, and T061 lands only after batch K + does. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, inside the test named. No assertion is weakened: the + pip remedy, the validator's own diagnosis, "the ENVIRONMENT, not the + snapshot" and "SERVES this unchecked snapshot" all stay. + before_blob: 8a1e67670c5ad064d71345102b1d54ea0341ca18 + after_blob: e69afab186efc62531db003d52a303d7a34f389c + old: |2 + tmp_path, capsys): + """A warning that does not say what to type is a warning that gets ignored. + It must also relay the validator's OWN diagnosis (it is the thing that knows + which library it wanted) and state plainly that the corpus is not the + accused — the wrong half of that sentence is what a stopped human reads + first.""" + repo_root = _corpus_with_a_reachable_validator(tmp_path, + STUB_MISSING_DEPENDENCIES) + new: |2 + tmp_path, capsys, monkeypatch): + """A warning that does not say what to type is a warning that gets ignored. + It must also relay the validator's OWN diagnosis (it is the thing that knows + which library it wanted) and state plainly that the corpus is not the + accused — the wrong half of that sentence is what a stopped human reads + first. (The stub is planted as the distribution's own validator: plan 034 + T061, #1144 7.3; batch K.)""" + repo_root = _corpus_with_a_reachable_validator(tmp_path, + STUB_MISSING_DEPENDENCIES) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) + - suite: tests/test_snapshot_validation_launch.py + test: test_a_non_conformant_snapshot_still_blocks_and_blames_the_snapshot + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + One of batch K's ten (plan 034 T061, #1144 7.3). A verdict on the data + still blocks the serve; the stub is planted as the installed + distribution's own validator (`product_root` None, `_packaged_validator` + the stub), which the real `find_validator` answers whatever the start. + ruled: >- + Brett's ruling at opensoft/openxFactory#656 comment 5916000030 ("Reword + as reviewed entries"), on R1Q7 (a), comment 5817152735. It is recorded + in #1144's F5.2 by T007's batch K, and T061 lands only after batch K + does. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, inside the test named. No assertion is weakened: rc + 1, not served, REJECTED with the dangling edge, and no pip remedy stay. + before_blob: e69afab186efc62531db003d52a303d7a34f389c + after_blob: 068ee1632dd7fe3e52d90ab9ed054fc90fca8943 + old: |2 + tmp_path, capsys): + """The other half of the distinction, and the one the fix must not spend: a + validator that RAN and rejected the data still stops the serve. The message + is about the snapshot, and it must not offer the dependency remedy — sending + someone to `pip install` over a dangling document edge is the same defect + pointing the other way.""" + repo_root = _corpus_with_a_reachable_validator(tmp_path, + STUB_NON_CONFORMANT) + new: |2 + tmp_path, capsys, monkeypatch): + """The other half of the distinction, and the one the fix must not spend: a + validator that RAN and rejected the data still stops the serve. The message + is about the snapshot, and it must not offer the dependency remedy — sending + someone to `pip install` over a dangling document edge is the same defect + pointing the other way. (The stub is planted as the distribution's own + validator: plan 034 T061, #1144 7.3; batch K.)""" + repo_root = _corpus_with_a_reachable_validator(tmp_path, + STUB_NON_CONFORMANT) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) + - suite: tests/test_snapshot_validation_launch.py + test: test_strict_makes_an_unrunnable_validator_fatal + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + One of batch K's ten (plan 034 T061, #1144 7.3). --strict still makes + "could not check" fatal; the stub is planted as the installed + distribution's own validator (`product_root` None, `_packaged_validator` + the stub), which the real `find_validator` answers whatever the start. + ruled: >- + Brett's ruling at opensoft/openxFactory#656 comment 5916000030 ("Reword + as reviewed entries"), on R1Q7 (a), comment 5817152735. It is recorded + in #1144's F5.2 by T007's batch K, and T061 lands only after batch K + does. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, inside the test named. No assertion is weakened: rc + 1, not served, "--strict was given", and no "SERVES" claim stay. + before_blob: 068ee1632dd7fe3e52d90ab9ed054fc90fca8943 + after_blob: 92c43770604369e19c904e4347580882020f4c6f + old: |2 + def test_strict_makes_an_unrunnable_validator_fatal(tmp_path, capsys): + """`--strict` is a demand for certainty, so "we could not check" is a + failure under it — otherwise the flag would quietly mean less than it + says.""" + repo_root = _corpus_with_a_reachable_validator(tmp_path, + STUB_MISSING_DEPENDENCIES) + new: |2 + def test_strict_makes_an_unrunnable_validator_fatal(tmp_path, capsys, monkeypatch): + """`--strict` is a demand for certainty, so "we could not check" is a + failure under it — otherwise the flag would quietly mean less than it + says. (The stub is planted as the distribution's own validator: plan 034 + T061, #1144 7.3; batch K.)""" + repo_root = _corpus_with_a_reachable_validator(tmp_path, + STUB_MISSING_DEPENDENCIES) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) + - suite: tests/test_snapshot_validation_launch.py + test: test_strict_is_fatal_when_no_validator_is_reachable_either + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + One of batch K's ten (plan 034 T061, #1144 7.3). The other unavailable + sub-case under 7.3: the distribution carries no validator of its own + (`product_root` and `_packaged_validator` both None), the one way under + 7.3 to have none. + ruled: >- + Brett's ruling at opensoft/openxFactory#656 comment 5916000030 ("Reword + as reviewed entries"), on R1Q7 (a), comment 5817152735. It is recorded + in #1144's F5.2 by T007's batch K, and T061 lands only after batch K + does. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, inside the test named. No assertion is weakened: it + warns and serves without --strict, and is fatal with it, as before. + before_blob: 92c43770604369e19c904e4347580882020f4c6f + after_blob: cb20a9d2dc7e4a5e3297901d9f57d209ee58fc2a + old: |2 + def test_strict_is_fatal_when_no_validator_is_reachable_either(tmp_path, capsys): + """The same rule for the other unavailable sub-case. A `--strict` run that + found no validator at all learned exactly as much as one whose validator + would not start.""" + import shutil + new: |2 + def test_strict_is_fatal_when_no_validator_is_reachable_either(tmp_path, capsys, + monkeypatch): + """The same rule for the other unavailable sub-case. A `--strict` run that + found no validator at all learned exactly as much as one whose validator + would not start. Since plan 034 T061 (#1144 7.3; batch K) "no validator at + all" is a distribution that carries none of its own, which is staged here.""" + import shutil + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: None) + - suite: tests/test_snapshot_validation_launch.py + test: test_the_classifier_reads_the_exit_code_not_the_dependency_sentence + landing: opensoft/openXdox-code#36 + edit: admitted + reason: >- + One of batch K's ten (plan 034 T061, #1144 7.3). The exit code, not the + sentence, still classifies; the stub is planted as the installed + distribution's own validator (`product_root` None, `_packaged_validator` + the stub), which the real `find_validator` answers whatever the start. + ruled: >- + Brett's ruling at opensoft/openxFactory#656 comment 5916000030 ("Reword + as reviewed entries"), on R1Q7 (a), comment 5817152735. It is recorded + in #1144's F5.2 by T007's batch K, and T061 lands only after batch K + does. + review: >- + Reviewed in the landing pull request on batch C's basis. It is one step + of the chain this landing's entries for the suite make, in order, under + F5.2's `--chains`: the landing's diff for this suite is exactly the + chain's recorded texts, each inside its own named test, and this step's + is `old` to `new`, inside the test named. No assertion is weakened: rc + 0, served, the stub's own diagnosis, and "NOT checked" stay. + before_blob: cb20a9d2dc7e4a5e3297901d9f57d209ee58fc2a + after_blob: c91f7bf7aa5b0dc08aa68f6d7846d4e3e5af3013 + old: |2 + tmp_path, capsys): + """The guard against fixing this brittlely. A different environmental + failure, with different words and no mention of jsonschema, is classified + the same way — because the signal is the validator's documented exit code + (2 = harness error), not a phrase that a future edit could reword.""" + repo_root = _corpus_with_a_reachable_validator(tmp_path, STUB_MISSING_YAML) + new: |2 + tmp_path, capsys, monkeypatch): + """The guard against fixing this brittlely. A different environmental + failure, with different words and no mention of jsonschema, is classified + the same way — because the signal is the validator's documented exit code + (2 = harness error), not a phrase that a future edit could reword. (The + stub is planted as the distribution's own validator: plan 034 T061, #1144 + 7.3; batch K.)""" + repo_root = _corpus_with_a_reachable_validator(tmp_path, STUB_MISSING_YAML) + stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote + monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) + monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: stub) From 20c032a94d55f0c48f0f4cff06114c5c5ab59706 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:58:43 +0000 Subject: [PATCH 36/44] Answer SonarCloud's analysis of #36 (plan 034 T061) The quality gate failed on two conditions. Both came from the packaged validator, src/openxdox/contracts/validate-ideation-dashboard-contracts.py, which Sonar read as 2,142 new lines: 63.1% duplication on new code, and a C security rating from its copy of the validator's existing path-from-argv finding (S8707), which scripts/ carries already. The copy is package data and a byte copy of scripts/validate-ideation-dashboard-contracts.py, and tests/test_packaged_validator.py holds the two byte-equal. So .sonarcloud.properties (new; the project has no other analysis settings) excludes the copy, and the validator is analysed once, at scripts/. The smells in this pull request's own new code are taken: * S1192: the three schema file names are named once (SNAPSHOT_SCHEMA, SNAPSHOT_INDEX_SCHEMA, GATE_ACTION_RECORD_SCHEMA) and used in OWN_KIND_SCHEMAS, SCHEMA_FILENAMES and KIND_TO_SCHEMA; * S8519: next(iter(...)) for the distribution's search location; * S3776: chain_holds splits into _chain_ends_why and _step_why, and main's report into _report, with messages unchanged; * S9073: the three composite assertions are split; * S1172: find_validator deletes its ignored start explicitly. The packaged copy is re-copied and is byte-equal again. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .sonarcloud.properties | 11 ++++ scripts/protected_suites.py | 62 +++++++++++++------ .../validate-ideation-dashboard-contracts.py | 23 ++++--- .../validate-ideation-dashboard-contracts.py | 23 ++++--- src/openxdox/snapshot.py | 1 + tests/test_protected_suite_check.py | 9 ++- 6 files changed, 83 insertions(+), 46 deletions(-) create mode 100644 .sonarcloud.properties diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..a7c45c2 --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,11 @@ +# SonarCloud automatic analysis settings for openXdox-code (plan 034 T061). +# +# THE PACKAGED VALIDATOR IS ANALYSED ONCE, AT scripts/. Since T061 the package +# ships src/openxdox/contracts/validate-ideation-dashboard-contracts.py as +# package data, so that an install runs its own validator (#1144 7.3). It is a +# byte copy of scripts/validate-ideation-dashboard-contracts.py, which a source +# checkout runs and which is analysed here as every other file is. +# tests/test_packaged_validator.py holds the two byte-equal, so no line can +# differ unseen. Analysing the copy as well would count each of the validator's +# lines, and each finding, twice. +sonar.exclusions=src/openxdox/contracts/validate-ideation-dashboard-contracts.py diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py index 7c7c78c..becfe52 100644 --- a/scripts/protected_suites.py +++ b/scripts/protected_suites.py @@ -348,38 +348,57 @@ def chain_holds(repo: Path, landing: str, chain: list[dict]) -> str | None: `entry_holds`.""" if len(chain) == 1: return entry_holds(repo, landing, chain[0]) + why = _chain_ends_why(repo, landing, chain) + if why is not None: + return why + text = _blob_text(repo, chain[0]["before_blob"]) + last = _blob_text(repo, chain[-1]["after_blob"]) + for step, entry in enumerate(chain, 1): + following = text.replace(entry["old"], entry["new"], 1) + why = _step_why(repo, chain, step, text, following, last) + if why is not None: + return f"step {step}: {why}" + text = following + return None + + +def _chain_ends_why(repo: Path, landing: str, chain: list[dict]) -> str | None: + """Why `chain` cannot admit `landing` whatever its steps say, or None: its + entries name more than one suite or landing, or its ends are not the + suite before the landing and at it.""" suite = chain[0]["suite"] if any(entry["suite"] != suite for entry in chain): return "a chain's entries name more than one suite" if any(entry["landing"] != chain[0]["landing"] for entry in chain): return "a chain's entries name more than one landing" before = _blob(repo, f"{landing}^1", suite) - after = _blob(repo, landing, suite) if before != chain[0]["before_blob"]: return f"{suite} before the landing is {before}, not the chain's {chain[0]['before_blob']}" + after = _blob(repo, landing, suite) if after != chain[-1]["after_blob"]: return f"{suite} at the landing is {after}, not the chain's {chain[-1]['after_blob']}" - text, last = _blob_text(repo, before), _blob_text(repo, after) - for step, entry in enumerate(chain, 1): - if step > 1 and entry["before_blob"] != chain[step - 2]["after_blob"]: - return f"step {step}: its before_blob is not the step before it's after_blob" - if text.count(entry["old"]) != 1: - return (f"step {step}: its old text occurs {text.count(entry['old'])} times in " - "the text the steps before it leave, not once") - following = text.replace(entry["old"], entry["new"], 1) - if step == len(chain): - if following != last: - return f"step {step}: the chain's texts do not give the suite at the landing" - elif _hash_text(repo, following) != entry["after_blob"]: - return (f"step {step}: its after_blob is not the blob of the text its edit " - "leaves, so the chain does not record the text in between") - why = _edit_holds(text, following, entry) - if why is not None: - return f"step {step}: {why}" - text = following return None +def _step_why(repo: Path, chain: list[dict], step: int, text: str, following: str, + last: str) -> str | None: + """Why step `step` (1-based) of `chain` does not hold on `text`, the text + the steps before it leave, or None. `following` is what its edit leaves, + and `last` the suite at the landing.""" + entry = chain[step - 1] + if step > 1 and entry["before_blob"] != chain[step - 2]["after_blob"]: + return "its before_blob is not the step before it's after_blob" + if text.count(entry["old"]) != 1: + return (f"its old text occurs {text.count(entry['old'])} times in the text " + "the steps before it leave, not once") + if step == len(chain) and following != last: + return "the chain's texts do not give the suite at the landing" + if step < len(chain) and _hash_text(repo, following) != entry["after_blob"]: + return ("its after_blob is not the blob of the text its edit leaves, so the " + "chain does not record the text in between") + return _edit_holds(text, following, entry) + + def _edit_holds(before_text: str, after_text: str, entry: dict) -> str | None: """None when `entry`'s one edit turns `before_text` into `after_text`, inside its named test (or adding only it), else why not.""" @@ -522,6 +541,11 @@ def main(argv: list[str] | None = None) -> int: f"({' '.join(map(str, exc.cmd[3:]))}: {detail[0] if detail else exc.returncode}), " "so nothing is checked", file=sys.stderr) return 2 + return _report(findings) + + +def _report(findings: list[Finding]) -> int: + """Print each finding, and answer the exit code: 1 when any is refused.""" refused = [] for f in findings: if f.admitted_by is not None: diff --git a/scripts/validate-ideation-dashboard-contracts.py b/scripts/validate-ideation-dashboard-contracts.py index fe10c33..0079820 100755 --- a/scripts/validate-ideation-dashboard-contracts.py +++ b/scripts/validate-ideation-dashboard-contracts.py @@ -187,11 +187,10 @@ # records beside it, wherever it is found (`openxdox.contracts` applies the # same rule), so a copy edited in place is refused, never read. A schema no # place supplies is refused BY NAME where an instance needs it (harness exit 2). -OWN_KIND_SCHEMAS = frozenset({ - "ideation-dashboard-snapshot.schema.yaml", - "ideation-dashboard-snapshot-index.schema.yaml", - "gate-action-record.schema.yaml", -}) +SNAPSHOT_SCHEMA = "ideation-dashboard-snapshot.schema.yaml" +SNAPSHOT_INDEX_SCHEMA = "ideation-dashboard-snapshot-index.schema.yaml" +GATE_ACTION_RECORD_SCHEMA = "gate-action-record.schema.yaml" +OWN_KIND_SCHEMAS = frozenset({SNAPSHOT_SCHEMA, SNAPSHOT_INDEX_SCHEMA, GATE_ACTION_RECORD_SCHEMA}) COPIES_RECORD = "copies.yaml" @@ -209,7 +208,7 @@ def distribution_contracts() -> Path | None: return None if spec is None or not spec.submodule_search_locations: return None - return Path(list(spec.submodule_search_locations)[0]).resolve() + return Path(next(iter(spec.submodule_search_locations))).resolve() def verified_copy(contracts: Path, name: str) -> Path: @@ -278,25 +277,25 @@ def schema_sources() -> dict[str, tuple[Path | None, str]]: return {name: schema_source(name) for name in SCHEMA_FILENAMES} SCHEMA_FILENAMES = [ - "ideation-dashboard-snapshot.schema.yaml", - "ideation-dashboard-snapshot-index.schema.yaml", + SNAPSHOT_SCHEMA, + SNAPSHOT_INDEX_SCHEMA, "ideation-workbench.schema.yaml", "ideation-possibles-register.schema.yaml", "xfactory-workbench-model-catalog.schema.yaml", "xfactory-workbench-chat-turn.schema.yaml", "project-register.schema.yaml", - "gate-action-record.schema.yaml", + GATE_ACTION_RECORD_SCHEMA, "demotion-execution-receipt.schema.yaml", "gate-intent.schema.yaml", ] # Whole-document schemas keyed by the hyphenated `kind` literal each declares. KIND_TO_SCHEMA = { - "ideation-dashboard-snapshot": "ideation-dashboard-snapshot.schema.yaml", - "ideation-dashboard-snapshot-index": "ideation-dashboard-snapshot-index.schema.yaml", + "ideation-dashboard-snapshot": SNAPSHOT_SCHEMA, + "ideation-dashboard-snapshot-index": SNAPSHOT_INDEX_SCHEMA, "ideation-workbench": "ideation-workbench.schema.yaml", "project-register": "project-register.schema.yaml", - "gate-action-record": "gate-action-record.schema.yaml", + "gate-action-record": GATE_ACTION_RECORD_SCHEMA, "demotion-execution-receipt": "demotion-execution-receipt.schema.yaml", "gate-intent": "gate-intent.schema.yaml", # doxBench wire family (add-workbench-integrated-editor-chat task 2.1): diff --git a/src/openxdox/contracts/validate-ideation-dashboard-contracts.py b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py index fe10c33..0079820 100755 --- a/src/openxdox/contracts/validate-ideation-dashboard-contracts.py +++ b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py @@ -187,11 +187,10 @@ # records beside it, wherever it is found (`openxdox.contracts` applies the # same rule), so a copy edited in place is refused, never read. A schema no # place supplies is refused BY NAME where an instance needs it (harness exit 2). -OWN_KIND_SCHEMAS = frozenset({ - "ideation-dashboard-snapshot.schema.yaml", - "ideation-dashboard-snapshot-index.schema.yaml", - "gate-action-record.schema.yaml", -}) +SNAPSHOT_SCHEMA = "ideation-dashboard-snapshot.schema.yaml" +SNAPSHOT_INDEX_SCHEMA = "ideation-dashboard-snapshot-index.schema.yaml" +GATE_ACTION_RECORD_SCHEMA = "gate-action-record.schema.yaml" +OWN_KIND_SCHEMAS = frozenset({SNAPSHOT_SCHEMA, SNAPSHOT_INDEX_SCHEMA, GATE_ACTION_RECORD_SCHEMA}) COPIES_RECORD = "copies.yaml" @@ -209,7 +208,7 @@ def distribution_contracts() -> Path | None: return None if spec is None or not spec.submodule_search_locations: return None - return Path(list(spec.submodule_search_locations)[0]).resolve() + return Path(next(iter(spec.submodule_search_locations))).resolve() def verified_copy(contracts: Path, name: str) -> Path: @@ -278,25 +277,25 @@ def schema_sources() -> dict[str, tuple[Path | None, str]]: return {name: schema_source(name) for name in SCHEMA_FILENAMES} SCHEMA_FILENAMES = [ - "ideation-dashboard-snapshot.schema.yaml", - "ideation-dashboard-snapshot-index.schema.yaml", + SNAPSHOT_SCHEMA, + SNAPSHOT_INDEX_SCHEMA, "ideation-workbench.schema.yaml", "ideation-possibles-register.schema.yaml", "xfactory-workbench-model-catalog.schema.yaml", "xfactory-workbench-chat-turn.schema.yaml", "project-register.schema.yaml", - "gate-action-record.schema.yaml", + GATE_ACTION_RECORD_SCHEMA, "demotion-execution-receipt.schema.yaml", "gate-intent.schema.yaml", ] # Whole-document schemas keyed by the hyphenated `kind` literal each declares. KIND_TO_SCHEMA = { - "ideation-dashboard-snapshot": "ideation-dashboard-snapshot.schema.yaml", - "ideation-dashboard-snapshot-index": "ideation-dashboard-snapshot-index.schema.yaml", + "ideation-dashboard-snapshot": SNAPSHOT_SCHEMA, + "ideation-dashboard-snapshot-index": SNAPSHOT_INDEX_SCHEMA, "ideation-workbench": "ideation-workbench.schema.yaml", "project-register": "project-register.schema.yaml", - "gate-action-record": "gate-action-record.schema.yaml", + "gate-action-record": GATE_ACTION_RECORD_SCHEMA, "demotion-execution-receipt": "demotion-execution-receipt.schema.yaml", "gate-intent": "gate-intent.schema.yaml", # doxBench wire family (add-workbench-integrated-editor-chat task 2.1): diff --git a/src/openxdox/snapshot.py b/src/openxdox/snapshot.py index ce537d2..2d01f17 100644 --- a/src/openxdox/snapshot.py +++ b/src/openxdox/snapshot.py @@ -199,6 +199,7 @@ def find_validator(start: Path | None = None) -> Path | None: snapshot's directory or a corpus, so an enclosing tree's validator is never adopted, whatever it carries. A caller that means another validator passes it explicitly: `validate_snapshot(..., validator=...)`.""" + del start # the declared signature is kept, and the start is ignored (7.3) root = product_root() if root is None: return _packaged_validator() diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index de4c1a5..7190098 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -350,8 +350,10 @@ def test_a_chain_is_refused_in_12_5s_call(repo, monkeypatch) -> None: repo.commit({SUITE: BOTH}, f"two edits\n\n{ARC}") chain = _two_step_chain(repo) [finding] = _check(repo, chain) - assert finding.admitted_by is None and finding.chain == () - assert "entry 1:" in finding.why and "entries 1-2" not in finding.why + assert finding.admitted_by is None + assert finding.chain == () + assert "entry 1:" in finding.why + assert "entries 1-2" not in finding.why allow = repo.root / ps.ALLOW_LIST monkeypatch.chdir(repo.root) allow.parent.mkdir(parents=True, exist_ok=True) @@ -368,7 +370,8 @@ def test_a_chain_is_spent_whole_by_its_landing(repo) -> None: findings = {f.landing: f for f in _check(repo, _two_step_chain(repo), chains=True)} assert findings[landing].chain == (1, 2) assert findings[replay].admitted_by is None - assert "admitted" in findings[replay].why and "already" in findings[replay].why + assert "admitted" in findings[replay].why + assert "already" in findings[replay].why def test_a_chain_whose_middle_blob_is_not_the_text_between_is_refused(repo) -> None: From bbe171650a70bf949267a9c5d56b49e2541092a8 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:00:24 +0000 Subject: [PATCH 37/44] Refuse a packaged validator whose record is missing (plan 034 T061) Copilot on #36 (both reviews, r4147428566 and r4147670123): when this script ran as the packaged copy and copies.yaml was absent, schema_source read the three packaged schemas as a tree's own contracts/, with no digest check. That contradicts the package's own rule that an absent record is refused. The packaged layout is now told by where the script sits: inside the contracts directory it reads, openxdox/contracts/, beside its copies. Whether the record is there no longer decides it. There, each of the three is always read through verified_copy, so a missing record or a missing copy is refused by name (harness exit 2), and CONTRACTS_DIR never stands in. A source checkout's scripts/ copy and openxFactory's composed validator are not that layout, and read their own contracts/ as before. The branch that told the packaged layout by its record is gone, since the place now tells it. A new case covers both kinds of missing file. The packaged copy is re-copied and is byte-equal again. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .../validate-ideation-dashboard-contracts.py | 19 +++++++++++----- .../validate-ideation-dashboard-contracts.py | 19 +++++++++++----- tests/test_validator_schema_home.py | 22 +++++++++++++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/scripts/validate-ideation-dashboard-contracts.py b/scripts/validate-ideation-dashboard-contracts.py index 0079820..55a6efe 100755 --- a/scripts/validate-ideation-dashboard-contracts.py +++ b/scripts/validate-ideation-dashboard-contracts.py @@ -136,6 +136,12 @@ _DECLARED_CONTRACTS = os.environ.get("CONTRACTS_DIR", "") _OWN_CONTRACTS = ROOT / "contracts" _OWN_SCHEMAS = _OWN_CONTRACTS / "schemas" +# THE PACKAGED LAYOUT (plan 034 T061): this script sits INSIDE the contracts +# directory it reads, as `openxdox/contracts/validate-ideation-dashboard- +# contracts.py` does in an install, beside its packaged copies and their record. +# It is told by where the script is, never by whether the record is there, so a +# package whose `copies.yaml` is missing is refused rather than read around. +_PACKAGED_LAYOUT = Path(__file__).resolve().parent == _OWN_CONTRACTS # A `contracts/` or `contracts/schemas/` that is a LINK OUT OF THIS TREE is # somebody else's contracts under this tree's own name: the escaping-link case # `openxdox.snapshot.find_validator` refuses for `scripts/`. It is never read, @@ -174,9 +180,10 @@ # schemas. So each family schema is found in ONE of three places, in this order: # 1. this tree's own `contracts/schemas/`, read first, as before. openxFactory's # farm (`doxbench_contracts._composed_validator`) supplies the whole family -# that way, and the packaged copy of this script -# (`openxdox/contracts/validate-ideation-dashboard-contracts.py`) finds the -# three packaged copies beside it that way; +# that way. The packaged copy of this script +# (`openxdox/contracts/validate-ideation-dashboard-contracts.py`) reads its +# three packaged copies beside it that way too, and there each is held to +# the record, which must be present (`_PACKAGED_LAYOUT`); # 2. for the three, the INSTALLED openxdox distribution's packaged copies # (`openxdox/contracts/schemas/`), found through the import system and never # by position. That is where a source checkout's `scripts/` copy, which has @@ -247,9 +254,11 @@ def schema_source(name: str) -> tuple[Path | None, str]: """Where this run reads the family schema `name`, and through which channel, or (None, why no channel supplies it).""" own = _OWN_SCHEMAS / name + if name in OWN_KIND_SCHEMAS and _PACKAGED_LAYOUT: + # Always held to the record beside it: a missing record, or a missing + # copy, is refused by `verified_copy` (harness exit 2), never skipped. + return verified_copy(_OWN_CONTRACTS, name), "this validator's own packaged copies" if own.is_file(): - if name in OWN_KIND_SCHEMAS and (_OWN_CONTRACTS / COPIES_RECORD).is_file(): - return verified_copy(_OWN_CONTRACTS, name), "this tree's own packaged copies" return own, "this tree's own contracts/" if name in OWN_KIND_SCHEMAS: distribution = distribution_contracts() diff --git a/src/openxdox/contracts/validate-ideation-dashboard-contracts.py b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py index 0079820..55a6efe 100755 --- a/src/openxdox/contracts/validate-ideation-dashboard-contracts.py +++ b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py @@ -136,6 +136,12 @@ _DECLARED_CONTRACTS = os.environ.get("CONTRACTS_DIR", "") _OWN_CONTRACTS = ROOT / "contracts" _OWN_SCHEMAS = _OWN_CONTRACTS / "schemas" +# THE PACKAGED LAYOUT (plan 034 T061): this script sits INSIDE the contracts +# directory it reads, as `openxdox/contracts/validate-ideation-dashboard- +# contracts.py` does in an install, beside its packaged copies and their record. +# It is told by where the script is, never by whether the record is there, so a +# package whose `copies.yaml` is missing is refused rather than read around. +_PACKAGED_LAYOUT = Path(__file__).resolve().parent == _OWN_CONTRACTS # A `contracts/` or `contracts/schemas/` that is a LINK OUT OF THIS TREE is # somebody else's contracts under this tree's own name: the escaping-link case # `openxdox.snapshot.find_validator` refuses for `scripts/`. It is never read, @@ -174,9 +180,10 @@ # schemas. So each family schema is found in ONE of three places, in this order: # 1. this tree's own `contracts/schemas/`, read first, as before. openxFactory's # farm (`doxbench_contracts._composed_validator`) supplies the whole family -# that way, and the packaged copy of this script -# (`openxdox/contracts/validate-ideation-dashboard-contracts.py`) finds the -# three packaged copies beside it that way; +# that way. The packaged copy of this script +# (`openxdox/contracts/validate-ideation-dashboard-contracts.py`) reads its +# three packaged copies beside it that way too, and there each is held to +# the record, which must be present (`_PACKAGED_LAYOUT`); # 2. for the three, the INSTALLED openxdox distribution's packaged copies # (`openxdox/contracts/schemas/`), found through the import system and never # by position. That is where a source checkout's `scripts/` copy, which has @@ -247,9 +254,11 @@ def schema_source(name: str) -> tuple[Path | None, str]: """Where this run reads the family schema `name`, and through which channel, or (None, why no channel supplies it).""" own = _OWN_SCHEMAS / name + if name in OWN_KIND_SCHEMAS and _PACKAGED_LAYOUT: + # Always held to the record beside it: a missing record, or a missing + # copy, is refused by `verified_copy` (harness exit 2), never skipped. + return verified_copy(_OWN_CONTRACTS, name), "this validator's own packaged copies" if own.is_file(): - if name in OWN_KIND_SCHEMAS and (_OWN_CONTRACTS / COPIES_RECORD).is_file(): - return verified_copy(_OWN_CONTRACTS, name), "this tree's own packaged copies" return own, "this tree's own contracts/" if name in OWN_KIND_SCHEMAS: distribution = distribution_contracts() diff --git a/tests/test_validator_schema_home.py b/tests/test_validator_schema_home.py index c86ec4b..82608ae 100644 --- a/tests/test_validator_schema_home.py +++ b/tests/test_validator_schema_home.py @@ -223,6 +223,28 @@ def test_the_packaged_validator_reads_the_copies_beside_it(tmp_path): assert "is not the spec leg's file" in proc.stderr +@pytest.mark.parametrize("missing", ["copies.yaml", "schemas/gate-action-record.schema.yaml"]) +def test_the_packaged_layout_is_refused_without_its_record_or_a_copy(tmp_path, missing): + """FAIL CLOSED (Copilot on openXdox-code#36). The packaged layout is told by + where the script sits, not by whether its record is there. So a package + whose `copies.yaml` is missing is refused by name (harness exit 2), rather + than having its copies read unchecked as a tree's own `contracts/`. A copy + missing beside a present record is refused the same way. And a stricter + snapshot schema on `CONTRACTS_DIR` never stands in for either.""" + site = tmp_path / "site" + shutil.copytree(PACKAGED, site / "openxdox" / "contracts", + ignore=shutil.ignore_patterns("__pycache__")) + (site / "openxdox" / "contracts" / missing).unlink() + script = site / "openxdox" / "contracts" / VALIDATOR.name + record = _write(tmp_path / "out" / "g.yaml", {"schema_version": 1, "kind": "gate-action-record"}) + stand_in = _spec_contracts(tmp_path / "spec") + proc = _run(script, record, cwd=tmp_path, contracts_dir=stand_in, + pythonpath=_decoy_distribution(tmp_path / "decoy")) + assert proc.returncode == 2, proc.stdout + proc.stderr + assert "cannot be read" in proc.stderr, proc.stderr + assert "0 error(s)" not in proc.stdout + + def test_a_referentially_broken_snapshot_is_a_verdict_not_a_harness_error(tmp_path): """(i) It is a real run, not an early exit: the validator's own snapshot rule fires on a dangling cluster edge — a FINDING (exit 1), which is what From 19b884fee46df78ad3efc8d2b3f32cb2c586fd20 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:09:26 +0000 Subject: [PATCH 38/44] Confine the installed validator to its package (plan 034 T061) Copilot on #36 (r4147791863): the install branch of find_validator returned the packaged validator once it was a file, with none of the containment check the source-tree branch makes. A contracts/ directory or a validator that links out of the installed package would therefore have been run as the distribution's own. _packaged_validator now checks the RESOLVED path against the installed package, as the source-tree branch checks scripts/ against the tree, and answers None for a link out. _validator_not_found_reason says which way the answer is None in both branches: absent, or resolving outside. A new case links first the validator, then the whole contracts/ directory, out of an installed copy. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- src/openxdox/snapshot.py | 36 +++++++++++++++++++++++++------- tests/test_packaged_validator.py | 21 +++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/openxdox/snapshot.py b/src/openxdox/snapshot.py index 2d01f17..4831def 100644 --- a/src/openxdox/snapshot.py +++ b/src/openxdox/snapshot.py @@ -172,11 +172,24 @@ def _inside(path: Path, root: Path) -> bool: return Path(path).resolve().is_relative_to(root.resolve()) +def _packaged_candidate() -> tuple[Path, Path]: + """This installed package's directory, and where its packaged validator + sits in it: `openxdox/contracts/`, beside this module.""" + package = Path(__file__).resolve().parent + return package, package / "contracts" / VALIDATOR_RELPATH.name + + def _packaged_validator() -> Path | None: - """The installed distribution's packaged validator, beside this module in - `openxdox/contracts/`, or None where this install carries none.""" - candidate = Path(__file__).resolve().parent / "contracts" / VALIDATOR_RELPATH.name - return candidate if candidate.is_file() else None + """The installed distribution's packaged validator, or None where this + install carries none. `is_file()` follows a symlink, so containment is + checked on the RESOLVED path too, as the source-tree branch of + `find_validator` checks it: a `contracts/` or a validator that links out of + the installed package is somebody else's script, and is never run + (Copilot on openXdox-code#36).""" + package, candidate = _packaged_candidate() + if candidate.is_file() and _inside(candidate, package): + return candidate + return None def find_validator(start: Path | None = None) -> Path | None: @@ -216,10 +229,19 @@ def _validator_not_found_reason(search_from: Path | None) -> str: not one of them: it is ignored (plan 034 T061).""" root = product_root() if root is None: - where = ("this openxdox is installed without its packaged validator " - f"(openxdox/contracts/{VALIDATOR_RELPATH.name})") + package, candidate = _packaged_candidate() + if candidate.is_file() and not _inside(candidate, package): + where = (f"this openxdox's packaged validator ({candidate}) resolves " + "outside the installed package, so it is not run") + else: + where = ("this openxdox is installed without its packaged validator " + f"(openxdox/contracts/{VALIDATOR_RELPATH.name})") else: - where = f"{root / VALIDATOR_RELPATH} does not exist" + candidate = root / VALIDATOR_RELPATH + if candidate.is_file() and not _inside(candidate, root): + where = f"{candidate} resolves outside this product's tree, so it is not run" + else: + where = f"{candidate} does not exist" return (f"{where}; a validator in an enclosing checkout is never adopted — " "pass validator= to use one explicitly") diff --git a/tests/test_packaged_validator.py b/tests/test_packaged_validator.py index bac4397..dfb3921 100644 --- a/tests/test_packaged_validator.py +++ b/tests/test_packaged_validator.py @@ -267,6 +267,27 @@ def test_an_install_answers_its_packaged_validator_whatever_the_start(tmp_path): assert answer == str((installed / "contracts" / contracts.VALIDATOR_NAME).resolve()) +@pytest.mark.parametrize("linked", ["validator", "contracts"]) +def test_an_install_whose_validator_links_out_of_the_package_answers_none(tmp_path, linked): + """CONFINED (Copilot on openXdox-code#36). A packaged validator, or a + `contracts/` directory, that is a symlink out of the installed package is + somebody else's script: `find_validator` answers None and says why, as the + source-tree branch does for a `scripts/` link out of the tree.""" + installed = _installed_copy(tmp_path / "site") + outside = tmp_path / "elsewhere" + shutil.copytree(installed / "contracts", outside) + if linked == "validator": + (installed / "contracts" / contracts.VALIDATOR_NAME).unlink() + (installed / "contracts" / contracts.VALIDATOR_NAME).symlink_to( + outside / contracts.VALIDATOR_NAME) + else: + shutil.rmtree(installed / "contracts") + (installed / "contracts").symlink_to(outside, target_is_directory=True) + answer, reason = _probe(tmp_path / "site", tmp_path, tmp_path, REPO_ROOT) + assert answer == "None" + assert "resolves outside the installed package" in reason + + def test_an_install_without_its_packaged_validator_answers_none_and_says_so(tmp_path): installed = _installed_copy(tmp_path / "site") (installed / "contracts" / contracts.VALIDATOR_NAME).unlink() From 29f058e6ac71b86351a4d7060957b8c25aadd2f1 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:22:28 +0000 Subject: [PATCH 39/44] Hold the validator's record to the package's contract; confine validator_path (plan 034 T061) Copilot on #36, at 19b884f5: * "Previously missed", on verified_copy. The script read copies.yaml loosely. It ignored schema_version, kind, spec_leg and commit, collapsed rows by basename, and accepted any path. So a record that openxdox.contracts.record() refuses could still have let the packaged validator read a schema. The script now checks the whole record first (record_digests), by the same contract: exactly its five keys, schema_version the integer 1, its kind and spec leg, a full commit id, and one well-formed row for each of the validator's own three kinds and no other. Then it takes any digest. It cannot import the package wherever it runs, so it carries the check itself. A new parametrized case runs every one of the package's fifteen record refusals through the installed validator. Each one is refused (exit 2) before any copy is read. The unedited record still reaches a verdict. * r4147880134: openxdox.contracts.validator_path() now checks the resolved path against the package, as openxdox.snapshot does. A validator file that links out answers None. The packaged copy is re-copied and is byte-equal again. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .../validate-ideation-dashboard-contracts.py | 90 +++++++++++++++---- src/openxdox/contracts/__init__.py | 12 ++- .../validate-ideation-dashboard-contracts.py | 90 +++++++++++++++---- tests/test_packaged_validator.py | 55 ++++++++++++ 4 files changed, 214 insertions(+), 33 deletions(-) diff --git a/scripts/validate-ideation-dashboard-contracts.py b/scripts/validate-ideation-dashboard-contracts.py index 55a6efe..27ae6f8 100755 --- a/scripts/validate-ideation-dashboard-contracts.py +++ b/scripts/validate-ideation-dashboard-contracts.py @@ -95,6 +95,7 @@ import hashlib import importlib.util import os +import re import subprocess import sys from pathlib import Path @@ -218,24 +219,83 @@ def distribution_contracts() -> Path | None: return Path(next(iter(spec.submodule_search_locations))).resolve() -def verified_copy(contracts: Path, name: str) -> Path: - """`/schemas/`, once its sha256 equals the digest the record - beside it (`/copies.yaml`) gives for it. `ContractRefused` - otherwise: an unreadable record, no digest for the name, a copy that cannot - be read, or a copy that differs.""" - record_path = contracts / COPIES_RECORD +#: The record's fixed values, and the shapes of its fields: the same contract +#: `openxdox.contracts.record()` holds it to, which this script cannot import +#: wherever it runs. tests/test_packaged_validator.py runs every one of that +#: module's record refusals through this script too, so the two cannot differ. +COPIES_KIND = "packaged-contract-copies" +COPIES_SPEC_LEG = "opensoft/openXdox-spec" +_COPY_ID = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") +_FULL_COMMIT = re.compile(r"[0-9a-f]{40}") +_FULL_SHA256 = re.compile(r"[0-9a-f]{64}") + + +def _untrusted(record_path: Path, detail: str) -> ContractRefused: + return ContractRefused(f"{record_path} cannot be trusted: {detail}, so no packaged " + "copy is read") + + +def _record_row(record_path: Path, index: int, row: object) -> tuple[str, str]: + """One `copies` row, checked, as (its schema file name, its sha256).""" + where = f"copies[{index}]" + if not isinstance(row, dict) or set(row) != {"id", "path", "sha256"}: + raise _untrusted(record_path, f"{where} is not a mapping of exactly id, path and sha256") + copy_id, path, digest = row["id"], row["path"], row["sha256"] + if not isinstance(copy_id, str) or not _COPY_ID.fullmatch(copy_id): + raise _untrusted(record_path, f"{where}.id is {copy_id!r}, not a lowercase hyphenated id") + if path != f"contracts/schemas/{copy_id}.schema.yaml": + raise _untrusted(record_path, f"{where}.path is {path!r}, not " + f"'contracts/schemas/{copy_id}.schema.yaml'") + if not isinstance(digest, str) or not _FULL_SHA256.fullmatch(digest): + raise _untrusted(record_path, f"{where}.sha256 is {digest!r}, not a 64-hex digest") + return f"{copy_id}.schema.yaml", digest + + +def record_digests(record_path: Path) -> dict[str, str]: + """`copies.yaml`'s digest for each of the three, by schema file name, once + the whole record holds to its contract: exactly its five keys, + `schema_version` the integer 1, its kind and spec leg, a full commit id, and + one well-formed row for each of this validator's own three kinds and no + other. `ContractRefused` otherwise, before any digest is used.""" try: record = yaml.safe_load(record_path.read_text(encoding="utf-8")) - except (OSError, yaml.YAMLError) as exc: + except (OSError, yaml.YAMLError, RecursionError, ValueError) as exc: raise ContractRefused(f"{record_path} cannot be read ({type(exc).__name__}), " - f"so the packaged {name} is not read") from exc - rows = record.get("copies") if isinstance(record, dict) else None - wanted = {Path(str(row.get("path", ""))).name: row.get("sha256") - for row in (rows if isinstance(rows, list) else []) if isinstance(row, dict)} - digest = wanted.get(name) - if not isinstance(digest, str) or len(digest) != 64: - raise ContractRefused(f"{record_path} records no sha256 for {name}, so the " - "packaged copy is not read") + "so no packaged copy is read") from exc + expected = {"schema_version", "kind", "spec_leg", "commit", "copies"} + if not isinstance(record, dict) or set(record) != expected: + raise _untrusted(record_path, f"it is not a mapping of exactly {sorted(expected)}") + version = record["schema_version"] + if type(version) is not int or version != 1: + raise _untrusted(record_path, f"schema_version is {version!r}, not 1") + if record["kind"] != COPIES_KIND: + raise _untrusted(record_path, f"kind is {record['kind']!r}, not {COPIES_KIND!r}") + if record["spec_leg"] != COPIES_SPEC_LEG: + raise _untrusted(record_path, f"spec_leg is {record['spec_leg']!r}, not {COPIES_SPEC_LEG!r}") + commit = record["commit"] + if not isinstance(commit, str) or not _FULL_COMMIT.fullmatch(commit): + raise _untrusted(record_path, f"commit is {commit!r}, not a full 40-hex commit id") + rows = record["copies"] + if not isinstance(rows, list) or not rows: + raise _untrusted(record_path, "copies is not a non-empty list") + checked = [_record_row(record_path, index, row) for index, row in enumerate(rows)] + names = [name for name, _digest in checked] + if len(set(names)) != len(names): + raise _untrusted(record_path, f"an id is given twice in {names}") + if set(names) != OWN_KIND_SCHEMAS: + raise _untrusted(record_path, f"it records {sorted(names)}, not this validator's " + f"own three {sorted(OWN_KIND_SCHEMAS)}") + return dict(checked) + + +def verified_copy(contracts: Path, name: str) -> Path: + """`/schemas/`, once the record beside it + (`/copies.yaml`) holds to its contract (`record_digests`) and the + copy's sha256 equals the digest it gives. `ContractRefused` otherwise: an + unreadable or malformed record, a copy that cannot be read, or a copy that + differs.""" + record_path = contracts / COPIES_RECORD + digest = record_digests(record_path)[name] path = contracts / "schemas" / name try: actual = hashlib.sha256(path.read_bytes()).hexdigest() diff --git a/src/openxdox/contracts/__init__.py b/src/openxdox/contracts/__init__.py index 2d4b29c..037685e 100644 --- a/src/openxdox/contracts/__init__.py +++ b/src/openxdox/contracts/__init__.py @@ -232,6 +232,12 @@ def verified_path(copy_id: str) -> Path: def validator_path() -> Path | None: - """The packaged validator, or None when this install carries none.""" - candidate = package_dir() / VALIDATOR_NAME - return candidate if candidate.is_file() else None + """The packaged validator, or None when this install carries none, or when + the file there links out of this package. `is_file()` follows a symlink, so + containment is checked on the RESOLVED path, as `openxdox.snapshot` checks + it before running the validator (Copilot on openXdox-code#36).""" + package = package_dir() + candidate = package / VALIDATOR_NAME + if candidate.is_file() and candidate.resolve().is_relative_to(package.resolve()): + return candidate + return None diff --git a/src/openxdox/contracts/validate-ideation-dashboard-contracts.py b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py index 55a6efe..27ae6f8 100755 --- a/src/openxdox/contracts/validate-ideation-dashboard-contracts.py +++ b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py @@ -95,6 +95,7 @@ import hashlib import importlib.util import os +import re import subprocess import sys from pathlib import Path @@ -218,24 +219,83 @@ def distribution_contracts() -> Path | None: return Path(next(iter(spec.submodule_search_locations))).resolve() -def verified_copy(contracts: Path, name: str) -> Path: - """`/schemas/`, once its sha256 equals the digest the record - beside it (`/copies.yaml`) gives for it. `ContractRefused` - otherwise: an unreadable record, no digest for the name, a copy that cannot - be read, or a copy that differs.""" - record_path = contracts / COPIES_RECORD +#: The record's fixed values, and the shapes of its fields: the same contract +#: `openxdox.contracts.record()` holds it to, which this script cannot import +#: wherever it runs. tests/test_packaged_validator.py runs every one of that +#: module's record refusals through this script too, so the two cannot differ. +COPIES_KIND = "packaged-contract-copies" +COPIES_SPEC_LEG = "opensoft/openXdox-spec" +_COPY_ID = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") +_FULL_COMMIT = re.compile(r"[0-9a-f]{40}") +_FULL_SHA256 = re.compile(r"[0-9a-f]{64}") + + +def _untrusted(record_path: Path, detail: str) -> ContractRefused: + return ContractRefused(f"{record_path} cannot be trusted: {detail}, so no packaged " + "copy is read") + + +def _record_row(record_path: Path, index: int, row: object) -> tuple[str, str]: + """One `copies` row, checked, as (its schema file name, its sha256).""" + where = f"copies[{index}]" + if not isinstance(row, dict) or set(row) != {"id", "path", "sha256"}: + raise _untrusted(record_path, f"{where} is not a mapping of exactly id, path and sha256") + copy_id, path, digest = row["id"], row["path"], row["sha256"] + if not isinstance(copy_id, str) or not _COPY_ID.fullmatch(copy_id): + raise _untrusted(record_path, f"{where}.id is {copy_id!r}, not a lowercase hyphenated id") + if path != f"contracts/schemas/{copy_id}.schema.yaml": + raise _untrusted(record_path, f"{where}.path is {path!r}, not " + f"'contracts/schemas/{copy_id}.schema.yaml'") + if not isinstance(digest, str) or not _FULL_SHA256.fullmatch(digest): + raise _untrusted(record_path, f"{where}.sha256 is {digest!r}, not a 64-hex digest") + return f"{copy_id}.schema.yaml", digest + + +def record_digests(record_path: Path) -> dict[str, str]: + """`copies.yaml`'s digest for each of the three, by schema file name, once + the whole record holds to its contract: exactly its five keys, + `schema_version` the integer 1, its kind and spec leg, a full commit id, and + one well-formed row for each of this validator's own three kinds and no + other. `ContractRefused` otherwise, before any digest is used.""" try: record = yaml.safe_load(record_path.read_text(encoding="utf-8")) - except (OSError, yaml.YAMLError) as exc: + except (OSError, yaml.YAMLError, RecursionError, ValueError) as exc: raise ContractRefused(f"{record_path} cannot be read ({type(exc).__name__}), " - f"so the packaged {name} is not read") from exc - rows = record.get("copies") if isinstance(record, dict) else None - wanted = {Path(str(row.get("path", ""))).name: row.get("sha256") - for row in (rows if isinstance(rows, list) else []) if isinstance(row, dict)} - digest = wanted.get(name) - if not isinstance(digest, str) or len(digest) != 64: - raise ContractRefused(f"{record_path} records no sha256 for {name}, so the " - "packaged copy is not read") + "so no packaged copy is read") from exc + expected = {"schema_version", "kind", "spec_leg", "commit", "copies"} + if not isinstance(record, dict) or set(record) != expected: + raise _untrusted(record_path, f"it is not a mapping of exactly {sorted(expected)}") + version = record["schema_version"] + if type(version) is not int or version != 1: + raise _untrusted(record_path, f"schema_version is {version!r}, not 1") + if record["kind"] != COPIES_KIND: + raise _untrusted(record_path, f"kind is {record['kind']!r}, not {COPIES_KIND!r}") + if record["spec_leg"] != COPIES_SPEC_LEG: + raise _untrusted(record_path, f"spec_leg is {record['spec_leg']!r}, not {COPIES_SPEC_LEG!r}") + commit = record["commit"] + if not isinstance(commit, str) or not _FULL_COMMIT.fullmatch(commit): + raise _untrusted(record_path, f"commit is {commit!r}, not a full 40-hex commit id") + rows = record["copies"] + if not isinstance(rows, list) or not rows: + raise _untrusted(record_path, "copies is not a non-empty list") + checked = [_record_row(record_path, index, row) for index, row in enumerate(rows)] + names = [name for name, _digest in checked] + if len(set(names)) != len(names): + raise _untrusted(record_path, f"an id is given twice in {names}") + if set(names) != OWN_KIND_SCHEMAS: + raise _untrusted(record_path, f"it records {sorted(names)}, not this validator's " + f"own three {sorted(OWN_KIND_SCHEMAS)}") + return dict(checked) + + +def verified_copy(contracts: Path, name: str) -> Path: + """`/schemas/`, once the record beside it + (`/copies.yaml`) holds to its contract (`record_digests`) and the + copy's sha256 equals the digest it gives. `ContractRefused` otherwise: an + unreadable or malformed record, a copy that cannot be read, or a copy that + differs.""" + record_path = contracts / COPIES_RECORD + digest = record_digests(record_path)[name] path = contracts / "schemas" / name try: actual = hashlib.sha256(path.read_bytes()).hexdigest() diff --git a/tests/test_packaged_validator.py b/tests/test_packaged_validator.py index dfb3921..c1f25f4 100644 --- a/tests/test_packaged_validator.py +++ b/tests/test_packaged_validator.py @@ -27,6 +27,7 @@ from __future__ import annotations import hashlib +import os import shutil import subprocess import sys @@ -177,6 +178,60 @@ def test_an_absent_record_is_refused_by_name(staged): contracts.record() +def test_the_packaged_validator_is_refused_where_it_links_out_of_the_package(staged, tmp_path): + """CONFINED (Copilot on openXdox-code#36): a validator file that links out + of `openxdox.contracts` is somebody else's script, so `validator_path()` + answers None, as `openxdox.snapshot` does before running one.""" + outside = tmp_path / "elsewhere" / contracts.VALIDATOR_NAME + outside.parent.mkdir() + outside.write_bytes(SCRIPT.read_bytes()) + (staged / contracts.VALIDATOR_NAME).symlink_to(outside) + assert contracts.validator_path() is None + (staged / contracts.VALIDATOR_NAME).unlink() + shutil.copy2(SCRIPT, staged / contracts.VALIDATOR_NAME) + assert contracts.validator_path() == staged / contracts.VALIDATOR_NAME + + +def _run_packaged(site: Path, cwd: Path) -> subprocess.CompletedProcess: + """The packaged validator of an installed copy at `site`, run on one + gate-action-record, so that it reads the three packaged copies.""" + instance = cwd / "g.yaml" + instance.write_text(yaml.safe_dump({"schema_version": 1, "kind": "gate-action-record"}), + encoding="utf-8") + env = {k: v for k, v in os.environ.items() if k not in ("CONTRACTS_DIR", "PYTHONPATH")} + return subprocess.run( + [sys.executable, str(site / "openxdox" / "contracts" / contracts.VALIDATOR_NAME), + str(instance)], cwd=cwd, env=env, capture_output=True, text=True, check=False) + + +@pytest.mark.parametrize("case", sorted(RECORD_REFUSALS)) +def test_the_packaged_validator_refuses_every_record_the_package_refuses(tmp_path, case): + """ONE CONTRACT, TWO READERS (Copilot on openXdox-code#36). The validator + script cannot import `openxdox.contracts` wherever it runs, so it checks the + record itself (`record_digests`). Each record the package refuses, the + installed validator refuses too, before any copy is read (harness exit 2).""" + site = tmp_path / "site" + shutil.copytree(PACKAGE, site / "openxdox" / "contracts", + ignore=shutil.ignore_patterns("__pycache__")) + change, _needle = RECORD_REFUSALS[case] + _edit_record(site / "openxdox" / "contracts", change) + proc = _run_packaged(site, tmp_path) + assert proc.returncode == 2, proc.stdout + proc.stderr + assert "copies.yaml cannot be trusted" in proc.stderr, proc.stderr + assert "0 error(s)" not in proc.stdout + + +def test_the_packaged_validator_reads_its_record_as_the_package_does(tmp_path): + """The unedited record passes both readers: the run reaches a verdict on + the instance rather than a harness error.""" + site = tmp_path / "site" + shutil.copytree(PACKAGE, site / "openxdox" / "contracts", + ignore=shutil.ignore_patterns("__pycache__")) + proc = _run_packaged(site, tmp_path) + assert proc.returncode in (0, 1), proc.stdout + proc.stderr + assert "cannot be trusted" not in proc.stderr + + def test_a_copy_edited_in_place_is_refused_not_read(staged): copy = staged / "schemas" / "ideation-dashboard-snapshot.schema.yaml" copy.write_bytes(copy.read_bytes() + b"\n") From 377bf954392eb8f1c685b6bfa9ac785306bf8b4a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:35:08 +0000 Subject: [PATCH 40/44] Correct two texts Copilot's review of #36 names (plan 034 T061) From the "previously missed" items at 29f058e6: * scripts/protected_suites.py: a chain step's diagnostic now reads "its before_blob is not the after_blob of the step before it". * scripts/validate-ideation-dashboard-contracts.py: build_registry's docstring said that the registry held the schemas SCHEMAS_DIR carries, and that an empty SCHEMAS_DIR was refused. Since T061 each schema comes from the one place schema_source names, so the registry merges three places. The docstring now says that, and says a run that no channel supplies with any of the ten is refused. The packaged copy is re-copied and byte-equal. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/protected_suites.py | 2 +- .../validate-ideation-dashboard-contracts.py | 31 +++++++++++-------- .../validate-ideation-dashboard-contracts.py | 31 +++++++++++-------- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/scripts/protected_suites.py b/scripts/protected_suites.py index becfe52..bf0b6af 100644 --- a/scripts/protected_suites.py +++ b/scripts/protected_suites.py @@ -387,7 +387,7 @@ def _step_why(repo: Path, chain: list[dict], step: int, text: str, following: st and `last` the suite at the landing.""" entry = chain[step - 1] if step > 1 and entry["before_blob"] != chain[step - 2]["after_blob"]: - return "its before_blob is not the step before it's after_blob" + return "its before_blob is not the after_blob of the step before it" if text.count(entry["old"]) != 1: return (f"its old text occurs {text.count(entry['old'])} times in the text " "the steps before it leave, not once") diff --git a/scripts/validate-ideation-dashboard-contracts.py b/scripts/validate-ideation-dashboard-contracts.py index 27ae6f8..2411f70 100755 --- a/scripts/validate-ideation-dashboard-contracts.py +++ b/scripts/validate-ideation-dashboard-contracts.py @@ -451,23 +451,28 @@ def load_yaml(path: Path) -> Any: # --------------------------- schema registry --------------------------- def build_registry() -> tuple[Registry, dict[str, dict]]: - """Offline registry over the family schemas SCHEMAS_DIR CARRIES, so the + """Offline registry over every family schema some channel SUPPLIES, so the register kernel's cross-file `$ref` into the snapshot's `evidence_pin` resolves (same approach as scripts/validate-document-catalog.py / validate-avatar-client.py). - A family schema SCHEMAS_DIR does not carry is left out here and refused BY - NAME where an instance needs it (`doc_validator`, harness exit 2). Since the - carve the ten are split across openXdox-spec, openDox-spec and openxFactory, - and this product's own spec leg carries three of them; a run that meets any - other kind has validated nothing and must say so, never pass (§ 8.9 residue - (i)). - - A SCHEMAS_DIR that carries NONE of the ten is refused HERE, before any mode - runs, as a harness failure (exit 2). An empty registry validates nothing. A - directory sweep that met no recognized instance, or a register transition, - would otherwise reach a verdict having loaded no schema, and the sweep would - exit 0 (Copilot review of openXdox-code#28).""" + Each schema comes from the one place `schema_source` names for it (plan 034 + T061): this tree's own `contracts/schemas/`; for this validator's own three, + the installed openxdox distribution's packaged copies, digest-checked; for + the other seven, `CONTRACTS_DIR`. So the registry is merged from up to three + places, and one directory no longer decides it. + + A family schema no channel supplies is left out here and refused BY NAME + where an instance needs it (`doc_validator`, harness exit 2). Since the carve + the ten are split across openXdox-spec, openDox-spec and openxFactory; a run + that meets a kind nothing supplies has validated nothing and must say so, + never pass (§ 8.9 residue (i)). + + A run that NO channel supplies with any of the ten is refused HERE, before + any mode runs, as a harness failure (exit 2). An empty registry validates + nothing. A directory sweep that met no recognized instance, or a register + transition, would otherwise reach a verdict having loaded no schema, and the + sweep would exit 0 (Copilot review of openXdox-code#28).""" sources = schema_sources() if not any(path is not None for path, _channel in sources.values()): raise FileNotFoundError( diff --git a/src/openxdox/contracts/validate-ideation-dashboard-contracts.py b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py index 27ae6f8..2411f70 100755 --- a/src/openxdox/contracts/validate-ideation-dashboard-contracts.py +++ b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py @@ -451,23 +451,28 @@ def load_yaml(path: Path) -> Any: # --------------------------- schema registry --------------------------- def build_registry() -> tuple[Registry, dict[str, dict]]: - """Offline registry over the family schemas SCHEMAS_DIR CARRIES, so the + """Offline registry over every family schema some channel SUPPLIES, so the register kernel's cross-file `$ref` into the snapshot's `evidence_pin` resolves (same approach as scripts/validate-document-catalog.py / validate-avatar-client.py). - A family schema SCHEMAS_DIR does not carry is left out here and refused BY - NAME where an instance needs it (`doc_validator`, harness exit 2). Since the - carve the ten are split across openXdox-spec, openDox-spec and openxFactory, - and this product's own spec leg carries three of them; a run that meets any - other kind has validated nothing and must say so, never pass (§ 8.9 residue - (i)). - - A SCHEMAS_DIR that carries NONE of the ten is refused HERE, before any mode - runs, as a harness failure (exit 2). An empty registry validates nothing. A - directory sweep that met no recognized instance, or a register transition, - would otherwise reach a verdict having loaded no schema, and the sweep would - exit 0 (Copilot review of openXdox-code#28).""" + Each schema comes from the one place `schema_source` names for it (plan 034 + T061): this tree's own `contracts/schemas/`; for this validator's own three, + the installed openxdox distribution's packaged copies, digest-checked; for + the other seven, `CONTRACTS_DIR`. So the registry is merged from up to three + places, and one directory no longer decides it. + + A family schema no channel supplies is left out here and refused BY NAME + where an instance needs it (`doc_validator`, harness exit 2). Since the carve + the ten are split across openXdox-spec, openDox-spec and openxFactory; a run + that meets a kind nothing supplies has validated nothing and must say so, + never pass (§ 8.9 residue (i)). + + A run that NO channel supplies with any of the ten is refused HERE, before + any mode runs, as a harness failure (exit 2). An empty registry validates + nothing. A directory sweep that met no recognized instance, or a register + transition, would otherwise reach a verdict having loaded no schema, and the + sweep would exit 0 (Copilot review of openXdox-code#28).""" sources = schema_sources() if not any(path is not None for path, _channel in sources.values()): raise FileNotFoundError( From 665efe20c28dc90db9295ef6f96417bacd5320ac Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:35:08 +0000 Subject: [PATCH 41/44] Rewrite two launch-suite docstrings for 7.3 (plan 034 T061; batch K) A PROTECTED SUITE EDIT, in its own commit, inside two of batch K's ten tests. Copilot's review of #36 at 29f058e6 names them, and their entries record the texts: * test_the_default_shaped_launch_validates_from_the_repo_root still opened with "validated anyway, because --repo-root is a checkout and the validator lives in it", which 7.3 makes false. It now says why the launch validates: once through the walk, and since T061 because the validator is the installed distribution's own, whatever the start. * test_when_neither_root_reaches_a_validator_the_message_names_both spoke of "a checkout with no validator in it" and "the two directories that were searched". It now names the one cause of a skip left, and the two roots openDox offered. No code line and no assertion changes. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_snapshot_validation_launch.py | 30 ++++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/test_snapshot_validation_launch.py b/tests/test_snapshot_validation_launch.py index c91f7bf..b843871 100644 --- a/tests/test_snapshot_validation_launch.py +++ b/tests/test_snapshot_validation_launch.py @@ -113,17 +113,18 @@ def _served(captured) -> bool: def test_the_default_shaped_launch_validates_from_the_repo_root(tmp_path, capsys, monkeypatch): """THE DEFECT: the run dir is OUTSIDE any aggregation checkout — the shape - `tempfile.mkdtemp()` always produces — and the snapshot is validated anyway, - because `--repo-root` is a checkout and the validator lives in it. + `tempfile.mkdtemp()` always produces — and the snapshot is validated anyway. A real temp dir is not used, because a test that wrote to /tmp/ would be asserting the same thing with less control; what matters is that the run dir has NO aggregation ancestor, which `tmp_path/run` also has not. - SINCE PLAN 034 T061 (#1144 7.3; admitted by T007's batch K, on Brett's - ruling at openxFactory#656 comment 5916000030) the validator is the - installed distribution's own, and no walk finds it. So the stub is planted - as the distribution's own validator, and the run dir's start answers it.""" + WHY IT VALIDATES. It once did because `--repo-root` was a checkout and the + walk found the validator in it. Since plan 034 T061 (#1144 7.3; admitted by + T007's batch K, on Brett's ruling at openxFactory#656 comment 5916000030) + neither root is searched: the validator is the installed distribution's own, + whatever the start. So the stub is planted as the distribution's own + validator, and the run dir's start answers it.""" repo_root = _corpus_with_a_reachable_validator(tmp_path) stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) @@ -174,19 +175,18 @@ def test_a_run_dir_beside_a_checkout_still_uses_that_one_first(tmp_path, capsys, def test_when_neither_root_reaches_a_validator_the_message_names_both( tmp_path, capsys, monkeypatch): - """A skip is still legal — a checkout with no validator in it is a real - state — but the line must not blame a checkout that is present. It names the - two directories that were searched, so the human can see which one to fix. + """A skip is still legal, but the line must not blame a checkout that is + present. Since plan 034 T061 (#1144 7.3; batch K) the roots never decide + where the validator is, so a skip has one cause left: the distribution + carries no validator of its own (an install built without its packaged + copy). That is the state staged here. The line still names both roots + openDox offered to search from, so neither is blamed on its own, and says + the validator is absent. On stderr, and leading with the consequence rather than the cause (the wording PR #50 landed for the same line): a diagnostic that says "this snapshot was NOT checked" must not be mistakable for the routine stdout - progress the surrounding `wrote …` lines are. - - Since plan 034 T061 (#1144 7.3; batch K) the roots never decide where the - validator is, so a skip has one cause left: the distribution carries no - validator of its own (an install built without its packaged copy). That is - the state staged here, and the line still names both roots it was offered.""" + progress the surrounding `wrote …` lines are.""" monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: None) corpus = tmp_path / "corpus" From 708cdd2751a9b1234a86ac42fffa2f8aeb84e822 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:35:22 +0000 Subject: [PATCH 42/44] Re-record the launch suite's chain for its rewritten docstrings (plan 034 T061) The launch suite's two docstrings changed in 665efe2, so the entries for the first and third steps of its chain record the new texts, and the blobs from the first step onward move with them. The entries are regenerated from this head by the same script as before (minimal whole-line hunks inside each test). All fifteen still load, and each chain, applied to main's text, gives this head's text. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/protected_suite_respellings.yaml | 64 +++++++++++++------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/tests/protected_suite_respellings.yaml b/tests/protected_suite_respellings.yaml index 5933153..4b385cb 100644 --- a/tests/protected_suite_respellings.yaml +++ b/tests/protected_suite_respellings.yaml @@ -472,7 +472,7 @@ entries: 0, not SKIPPED on either stream, the stub's verdict line, and the snapshot written all stay. before_blob: 1fb53b56ac219b4c7d8020c3263b300a141e6e27 - after_blob: 6c3f5e9e261473c61ccd84b366c8ad06e1a08630 + after_blob: 3057f6c3a5853f5111d4f4ccf3bd6c942482b87a old: |2 def test_the_default_shaped_launch_validates_from_the_repo_root(tmp_path, capsys): """THE DEFECT: the run dir is OUTSIDE any aggregation checkout — the shape @@ -489,17 +489,18 @@ entries: def test_the_default_shaped_launch_validates_from_the_repo_root(tmp_path, capsys, monkeypatch): """THE DEFECT: the run dir is OUTSIDE any aggregation checkout — the shape - `tempfile.mkdtemp()` always produces — and the snapshot is validated anyway, - because `--repo-root` is a checkout and the validator lives in it. + `tempfile.mkdtemp()` always produces — and the snapshot is validated anyway. A real temp dir is not used, because a test that wrote to /tmp/ would be asserting the same thing with less control; what matters is that the run dir has NO aggregation ancestor, which `tmp_path/run` also has not. - SINCE PLAN 034 T061 (#1144 7.3; admitted by T007's batch K, on Brett's - ruling at openxFactory#656 comment 5916000030) the validator is the - installed distribution's own, and no walk finds it. So the stub is planted - as the distribution's own validator, and the run dir's start answers it.""" + WHY IT VALIDATES. It once did because `--repo-root` was a checkout and the + walk found the validator in it. Since plan 034 T061 (#1144 7.3; admitted by + T007's batch K, on Brett's ruling at openxFactory#656 comment 5916000030) + neither root is searched: the validator is the installed distribution's own, + whatever the start. So the stub is planted as the distribution's own + validator, and the run dir's start answers it.""" repo_root = _corpus_with_a_reachable_validator(tmp_path) stub = repo_root.parent / snapshot_mod.VALIDATOR_RELPATH # the stub the helper wrote monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) @@ -531,8 +532,8 @@ entries: is `old` to `new`, inside the test named. No assertion is weakened: rc 0 stays, and the case now asserts both that the beside validator did not run and that the distribution's own did. - before_blob: 6c3f5e9e261473c61ccd84b366c8ad06e1a08630 - after_blob: 94b95b33fb298b0c8de24a84f827e0872fd74ec4 + before_blob: 3057f6c3a5853f5111d4f4ccf3bd6c942482b87a + after_blob: 554d6536c2e9d7fcff55c5428b33df50e241862c old: |2 def test_a_run_dir_beside_a_checkout_still_uses_that_one_first(tmp_path, capsys): """The output path keeps PRIORITY: a run dir deliberately placed inside an @@ -604,8 +605,8 @@ entries: is `old` to `new`, inside the test named. No assertion is weakened: rc 0, SKIPPED on stderr only, both offered roots named, and "NOT checked against the pinned schema" all stay. - before_blob: 94b95b33fb298b0c8de24a84f827e0872fd74ec4 - after_blob: a00b60881d240215ad04166982309245b503fc22 + before_blob: 554d6536c2e9d7fcff55c5428b33df50e241862c + after_blob: fa9b34f6677f694ced3c44665d01850e49e7e2a7 old: |2 tmp_path, capsys): """A skip is still legal — a checkout with no validator in it is a real @@ -618,19 +619,18 @@ entries: progress the surrounding `wrote …` lines are.""" new: |2 tmp_path, capsys, monkeypatch): - """A skip is still legal — a checkout with no validator in it is a real - state — but the line must not blame a checkout that is present. It names the - two directories that were searched, so the human can see which one to fix. + """A skip is still legal, but the line must not blame a checkout that is + present. Since plan 034 T061 (#1144 7.3; batch K) the roots never decide + where the validator is, so a skip has one cause left: the distribution + carries no validator of its own (an install built without its packaged + copy). That is the state staged here. The line still names both roots + openDox offered to search from, so neither is blamed on its own, and says + the validator is absent. On stderr, and leading with the consequence rather than the cause (the wording PR #50 landed for the same line): a diagnostic that says "this snapshot was NOT checked" must not be mistakable for the routine stdout - progress the surrounding `wrote …` lines are. - - Since plan 034 T061 (#1144 7.3; batch K) the roots never decide where the - validator is, so a skip has one cause left: the distribution carries no - validator of its own (an install built without its packaged copy). That is - the state staged here, and the line still names both roots it was offered.""" + progress the surrounding `wrote …` lines are.""" monkeypatch.setattr(snapshot_mod, "product_root", lambda: None) monkeypatch.setattr(snapshot_mod, "_packaged_validator", lambda: None) - suite: tests/test_snapshot_validation_launch.py @@ -655,8 +655,8 @@ entries: chain's recorded texts, each inside its own named test, and this step's is `old` to `new`, inside the test named. No assertion is weakened: rc 0, the server started, and "NOT checked against the pinned schema" stay. - before_blob: a00b60881d240215ad04166982309245b503fc22 - after_blob: 8a1e67670c5ad064d71345102b1d54ea0341ca18 + before_blob: fa9b34f6677f694ced3c44665d01850e49e7e2a7 + after_blob: 98c5ffad6df54868d13e30b567fdf6712e5a8c5b old: |2 tmp_path, capsys): """THE REGRESSION, in the shape Brett hit it: the validator is reachable, it @@ -701,8 +701,8 @@ entries: is `old` to `new`, inside the test named. No assertion is weakened: the pip remedy, the validator's own diagnosis, "the ENVIRONMENT, not the snapshot" and "SERVES this unchecked snapshot" all stay. - before_blob: 8a1e67670c5ad064d71345102b1d54ea0341ca18 - after_blob: e69afab186efc62531db003d52a303d7a34f389c + before_blob: 98c5ffad6df54868d13e30b567fdf6712e5a8c5b + after_blob: f24340ffb7ec201c954fa1993d61a1e2e6d7ba46 old: |2 tmp_path, capsys): """A warning that does not say what to type is a warning that gets ignored. @@ -746,8 +746,8 @@ entries: chain's recorded texts, each inside its own named test, and this step's is `old` to `new`, inside the test named. No assertion is weakened: rc 1, not served, REJECTED with the dangling edge, and no pip remedy stay. - before_blob: e69afab186efc62531db003d52a303d7a34f389c - after_blob: 068ee1632dd7fe3e52d90ab9ed054fc90fca8943 + before_blob: f24340ffb7ec201c954fa1993d61a1e2e6d7ba46 + after_blob: 7accc587a9a3b7481b22a627e8f85237163d5576 old: |2 tmp_path, capsys): """The other half of the distinction, and the one the fix must not spend: a @@ -791,8 +791,8 @@ entries: chain's recorded texts, each inside its own named test, and this step's is `old` to `new`, inside the test named. No assertion is weakened: rc 1, not served, "--strict was given", and no "SERVES" claim stay. - before_blob: 068ee1632dd7fe3e52d90ab9ed054fc90fca8943 - after_blob: 92c43770604369e19c904e4347580882020f4c6f + before_blob: 7accc587a9a3b7481b22a627e8f85237163d5576 + after_blob: e1b2380e19ae6016689a64840e69be9fa8d2de68 old: |2 def test_strict_makes_an_unrunnable_validator_fatal(tmp_path, capsys): """`--strict` is a demand for certainty, so "we could not check" is a @@ -832,8 +832,8 @@ entries: chain's recorded texts, each inside its own named test, and this step's is `old` to `new`, inside the test named. No assertion is weakened: it warns and serves without --strict, and is fatal with it, as before. - before_blob: 92c43770604369e19c904e4347580882020f4c6f - after_blob: cb20a9d2dc7e4a5e3297901d9f57d209ee58fc2a + before_blob: e1b2380e19ae6016689a64840e69be9fa8d2de68 + after_blob: f09d7d8634e527a97f6b827952a5474bdea88950 old: |2 def test_strict_is_fatal_when_no_validator_is_reachable_either(tmp_path, capsys): """The same rule for the other unavailable sub-case. A `--strict` run that @@ -871,8 +871,8 @@ entries: chain's recorded texts, each inside its own named test, and this step's is `old` to `new`, inside the test named. No assertion is weakened: rc 0, served, the stub's own diagnosis, and "NOT checked" stay. - before_blob: cb20a9d2dc7e4a5e3297901d9f57d209ee58fc2a - after_blob: c91f7bf7aa5b0dc08aa68f6d7846d4e3e5af3013 + before_blob: f09d7d8634e527a97f6b827952a5474bdea88950 + after_blob: b843871e4c353c7548dec6e064fce564a58f3869 old: |2 tmp_path, capsys): """The guard against fixing this brittlely. A different environmental From 71187f75525838f636a19e69e9b15e12772e9a7a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:41:43 +0000 Subject: [PATCH 43/44] Raise the triple's floors to CI's reading, 1076 and 1072 (plan 034 T061) Run 36759713758 measured 708cdd28, the head before this change, at openDox-code 814516b7: selected 1076, passed 1072, skipped 4, failures 0, errors 0. The +83 on both floors, file by file against #35's head 4feb8009: +47 tests/test_packaged_validator.py (new); +18 tests/test_snapshot.py, leaving the declaration; +11 tests/test_protected_suite_check.py; +5 tests/test_validator_schema_home.py; +2 tests/test_dependency_direction.py; +1 tests/test_projection_contributions.py; -1 tests/test_declared_exclusion.py (test_snapshot.py's case). The skips are the same four. The margin is zero again. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4b150b3..da46990 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -294,6 +294,24 @@ jobs: # with `main` `c41063d6` test by test). The floors sit ON the reading, # as #25 set them and T044 kept them, so the margin is zero again. # + # RAISED BY plan 034 T061 (openXdox-code#36), the same way: run + # 36759713758 (2026-09-30) measured `708cdd28`, the head before this + # change, at openDox-code `814516b7`: selected 1076, passed 1072, + # skipped 4, failures 0, errors 0. The +83 on both floors, file by file + # against #35's head `4feb8009`: + # * +47, `tests/test_packaged_validator.py` (new); + # * +18, `tests/test_snapshot.py`, which leaves the declaration now that + # 7.3 reads the consumer's schemas from the installed distribution; + # * +11, `tests/test_protected_suite_check.py` (the added-test rule's + # four cases, the chain's six, and 12.5's call refusing a chain); + # * +5, `tests/test_validator_schema_home.py`; +2, + # `tests/test_dependency_direction.py`; +1, + # `tests/test_projection_contributions.py` (two cases replaced by + # three); + # * -1, `tests/test_declared_exclusion.py`, which loses + # `tests/test_snapshot.py`'s case. + # Every selected test passes, and the four skips are the same four. + # # THE SKIPS ARE NAMED, which is why the exact pin is safe to take. All # FOUR are `tests/test_aggregation_register_instance.py`'s, which the # sixteen-file list never ran: each skips because the "aggregation @@ -323,8 +341,8 @@ jobs: # on a ruling, which lowers them with its reason (above). Set to # the measured values, which this suite has actually met on CI # rather than an aspiration. - MIN_SELECTED: "993" - MIN_PASSED: "989" + MIN_SELECTED: "1076" + MIN_PASSED: "1072" # EXACT - the load-bearing number. EXPECT_SKIPPED: "4" run: | From 5c2b134f20977ad1a97f3275a9d7a57ed5cba283 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:18:11 +0000 Subject: [PATCH 44/44] Record the triple at the pin main now carries (plan 034 T061) With #35 landed (839492d9) and merged in (8d891652), the openDox-code pin is 047bb4fa, T062's commit. Run 36784210624 read 1076 selected, 1072 passed and 4 skipped, the triple the floors already name, so they stand. The floor paragraph says so, and nothing moves. Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/validate.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1a59a3c..4489cb1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -311,6 +311,10 @@ jobs: # * -1, `tests/test_declared_exclusion.py`, which loses # `tests/test_snapshot.py`'s case. # Every selected test passes, and the four skips are the same four. + # AT THE PIN MAIN NOW CARRIES. #35 landed (`839492d9`) and moved the pin + # to openDox-code `047bb4fa`, T062's commit. Run 36784210624 measured + # `8d891652`, this branch with that `main` merged in, and read the same + # triple: 1076, 1072 and 4. So the floors stand. # # THE SKIPS ARE NAMED, which is why the exact pin is safe to take. All # FOUR are `tests/test_aggregation_register_instance.py`'s, which the