diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 22a914e..4489cb1 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: @@ -294,6 +294,28 @@ 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. + # 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 # sixteen-file list never ran: each skips because the "aggregation @@ -323,8 +345,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: | 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/pyproject.toml b/pyproject.toml index c444e07..406e5b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,18 +94,40 @@ 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, 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 +# stated for it until now (openxFactory's validator-input lock pins 0.1.4). dependencies = [ "opendox @ git+https://github.com/opensoft/openDox-code@047bb4fa394f3e1bf42466062a67ef18e99f8d6a", "PyYAML>=6.0", "jsonschema>=4.18", + "referencing>=0.28.4", + "rfc3339-validator>=0.1.4", ] [project.optional-dependencies] @@ -123,15 +145,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 +189,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/protected_suites.py b/scripts/protected_suites.py index bfa1e99..bf0b6af 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 @@ -46,9 +48,43 @@ * 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. +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 +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. + +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 @@ -264,8 +300,31 @@ 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.""" + """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) @@ -273,7 +332,76 @@ 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]) + 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) + 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']}" + 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 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") + 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.""" 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" @@ -283,6 +411,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"]): @@ -296,34 +428,47 @@ 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], 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 = [] - 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 chains else ()): + 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], - 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()) @@ -336,12 +481,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, chains) + 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 @@ -365,6 +511,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: @@ -383,7 +532,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. @@ -392,10 +541,17 @@ 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: - 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/scripts/validate-ideation-dashboard-contracts.py b/scripts/validate-ideation-dashboard-contracts.py index a3081ad..2411f70 100755 --- a/scripts/validate-ideation-dashboard-contracts.py +++ b/scripts/validate-ideation-dashboard-contracts.py @@ -92,7 +92,10 @@ from __future__ import annotations import argparse +import hashlib +import importlib.util import os +import re import subprocess import sys from pathlib import Path @@ -126,10 +129,20 @@ # ... 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" +# 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, @@ -160,26 +173,198 @@ 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. 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 +# 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). +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" + + +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(next(iter(spec.submodule_search_locations))).resolve() + + +#: 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, RecursionError, ValueError) as exc: + raise ContractRefused(f"{record_path} cannot be read ({type(exc).__name__}), " + "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() + 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 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(): + 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", + 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): @@ -266,33 +451,40 @@ 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).""" - if not any((SCHEMAS_DIR / name).is_file() for name in SCHEMA_FILENAMES): + 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( 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 +495,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..037685e --- /dev/null +++ b/src/openxdox/contracts/__init__.py @@ -0,0 +1,243 @@ +"""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, 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/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..2411f70 --- /dev/null +++ b/src/openxdox/contracts/validate-ideation-dashboard-contracts.py @@ -0,0 +1,2215 @@ +#!/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 re +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" +# 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, +# 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. 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 +# 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). +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" + + +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(next(iter(spec.submodule_search_locations))).resolve() + + +#: 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, RecursionError, ValueError) as exc: + raise ContractRefused(f"{record_path} cannot be read ({type(exc).__name__}), " + "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() + 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 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(): + 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 = [ + 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, + "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": 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, + "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 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). + + 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( + 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/src/openxdox/projection_contributions.py b/src/openxdox/projection_contributions.py index 3787069..88db066 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..4831def 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,50 @@ 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, 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: - """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=...)`.""" + del start # the declared signature is kept, and the start is ignored (7.3) 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,16 +225,23 @@ 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})") + 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") @@ -303,9 +336,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/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/protected_suite_respellings.yaml b/tests/protected_suite_respellings.yaml index 97d6ad9..4b385cb 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 @@ -68,9 +72,23 @@ # 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. +# * 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). @@ -279,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: 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 + `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. + + 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. + + 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) + 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: 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 + 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: 554d6536c2e9d7fcff55c5428b33df50e241862c + after_blob: fa9b34f6677f694ced3c44665d01850e49e7e2a7 + 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, 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.""" + 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: fa9b34f6677f694ced3c44665d01850e49e7e2a7 + after_blob: 98c5ffad6df54868d13e30b567fdf6712e5a8c5b + 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: 98c5ffad6df54868d13e30b567fdf6712e5a8c5b + after_blob: f24340ffb7ec201c954fa1993d61a1e2e6d7ba46 + 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: f24340ffb7ec201c954fa1993d61a1e2e6d7ba46 + after_blob: 7accc587a9a3b7481b22a627e8f85237163d5576 + 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: 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 + 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: 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 + 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: f09d7d8634e527a97f6b827952a5474bdea88950 + after_blob: b843871e4c353c7548dec6e064fce564a58f3869 + 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) 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`). diff --git a/tests/test_dependency_direction.py b/tests/test_dependency_direction.py index 63743bf..dd69a0e 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..c1f25f4 --- /dev/null +++ b/tests/test_packaged_validator.py @@ -0,0 +1,352 @@ +"""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 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.""" +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess +import sys +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_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") + 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} + + +# ------------------------ 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()) + + +@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() + 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 ef2c317..485a915 100644 --- a/tests/test_projection_contributions.py +++ b/tests/test_projection_contributions.py @@ -428,13 +428,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) @@ -443,16 +446,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 diff --git a/tests/test_protected_suite_check.py b/tests/test_protected_suite_check.py index 1bf0b64..7190098 100644 --- a/tests/test_protected_suite_check.py +++ b/tests/test_protected_suite_check.py @@ -14,7 +14,14 @@ 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; +* 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 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 @@ -105,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) # -------------------------------------------------------------------------- @@ -250,6 +257,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, []) == [] @@ -271,6 +323,93 @@ 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), 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 + 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) + 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), chains=True)} + assert findings[landing].chain == (1, 2) + assert findings[replay].admitted_by is None + 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: + 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, 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"), chains=True) + 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, 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"), + chains=True) + 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: 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") # -------------------------------------------------------------------------- diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index 312224f..bcd2c0b 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 @@ -141,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 diff --git a/tests/test_snapshot_validation_launch.py b/tests/test_snapshot_validation_launch.py index 1fb53b5..b843871 100644 --- a/tests/test_snapshot_validation_launch.py +++ b/tests/test_snapshot_validation_launch.py @@ -110,17 +110,27 @@ 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. + `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.""" + dir has NO aggregation ancestor, which `tmp_path/run` also has not. + + 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) + 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 +142,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,19 +169,26 @@ 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): - """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. + tmp_path, capsys, monkeypatch): + """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.""" + 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() 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( 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..82608ae 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,138 @@ 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 + + +@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 - `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 +271,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 +300,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 +323,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 +344,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 +361,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 +381,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")