From c139e88326eb2b9f1854eddb2413c35f8427da95 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:51:00 +0000 Subject: [PATCH 1/4] Give the test suite explicit code, assembly, spec and dependency roots so it reads each file from the tree that owns it, and refuses composed acceptance without all of them. Checkpoint of T009 (specs/004-migrate-to-triad/tasks.md, #186). Adopted into the triad, README.md, AGENTS.md and CLAUDE.md move to the assembly root and docs/ to the spec leg, while the commands, tests, .gitmodules and the openRepoShape pin stay in the code leg. The suite assumed one tree. tests/conftest.py now resolves the four roots from OPENREPOTOOLS_CODE_ROOT, OPENREPOTOOLS_ASSEMBLY_ROOT and OPENREPOTOOLS_SPEC_ROOT, or from the layout (an assembly above the code root that mounts it as its code gitlink), and ROOTS.path_for() reads a file from the root that owns it. Standalone, an absent root skips the tests that need it, naming the root; on today's single tree nothing new skips, because that tree is all of them, and the no-submodule skip is exactly today's. With OPENREPOTOOLS_COMPOSED=1 the session is refused (pytest exit 4) when the assembly or spec root is absent, the dependency is uninitialized, or a leg is not at the commit the assembly records, and NEEDS_UPSTREAM fails instead of skipping. The hygiene module reads the front-door documents and the lane manual through those roots and scans every repository the run can read; the pin module reads the code root's own gitlink and the dependency nested in it; the lane suite and the installer's README check take the manual and the README from their owning roots; tests/run.sh makes a relative root absolute before its cd. tests/test_test_roots.py holds the resolver on fixture layouts and runs the suite itself, nested, against fixture triads. Not yet run through the workstation lock; the scoped runs and the CI patches follow. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 419 +++++++++++++++++++- tests/run.sh | 23 ++ tests/test_lane_helpers.sh | 26 +- tests/test_lane_helpers_suite.py | 12 +- tests/test_openrepotools_command.py | 7 +- tests/test_repo_hygiene.py | 113 ++++-- tests/test_test_roots.py | 573 ++++++++++++++++++++++++++++ tests/test_upstream_pin.py | 54 ++- 8 files changed, 1160 insertions(+), 67 deletions(-) create mode 100644 tests/test_test_roots.py diff --git a/tests/conftest.py b/tests/conftest.py index 0f53a612..934ce39e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,27 @@ therefore has no template bytes to copy — so those tests SKIP, naming the one command that fixes it. They never fail: a fork's first `pytest` going red on a missing submodule is a fork nobody finishes. + +FOUR ROOTS, NAMED RATHER THAN ASSUMED (opensoft/openRepoTools#186, T009 of +`specs/004-migrate-to-triad/tasks.md`). Today this repository is one tree and +every path the suite reads hangs off it. Adopted into the triad it is three: +the ASSEMBLY root (`README.md`, `AGENTS.md`, `CLAUDE.md`, the pins), the SPEC +leg (`docs/`, `openspec/`, `specs/`) and the CODE leg (every command, `tests/`, +`.gitmodules`, `contracts/openreposhape-pin.yaml`), with the DEPENDENCY, +`upstream/openRepoShape`, nested in the code leg. `ROOTS` below is the one +place that says which tree is which for this run, and `ROOTS.path_for()` +is how a test reads a file from the root that owns it. Two modes: + + * STANDALONE (the default, and the only mode today's layout has): a root + this checkout does not carry is SKIPPED, naming it — the same courtesy the + missing submodule gets, and on today's layout nothing new skips, because + the one tree is all four roots at once. + * COMPOSED (`OPENREPOTOOLS_COMPOSED=1`, the assembly's exact-pin job): the + run is a claim about the whole arrangement, so a missing assembly, spec or + dependency root — or a code or spec leg that is not at the commit the + assembly pins — REFUSES the session before a test runs, and a test that + reaches an absent root FAILS. Nothing skips its way into composed + acceptance (FR-008). """ from __future__ import annotations @@ -23,10 +44,371 @@ import stat import subprocess from pathlib import Path +from typing import Dict, List, Mapping, Optional, Tuple import pytest -REPO = Path(__file__).resolve().parents[1] +#: THE TREE THIS SUITE WAS LOADED FROM, which is the code root unless +#: `OPENREPOTOOLS_CODE_ROOT` names another one. +SUITE_ROOT = Path(__file__).resolve().parents[1] + +#: The four variables a caller names the roots and the mode with. `tests/run.sh` +#: passes them through, made absolute against the directory it was run from. +ENV_CODE_ROOT = "OPENREPOTOOLS_CODE_ROOT" +ENV_ASSEMBLY_ROOT = "OPENREPOTOOLS_ASSEMBLY_ROOT" +ENV_SPEC_ROOT = "OPENREPOTOOLS_SPEC_ROOT" +ENV_COMPOSED = "OPENREPOTOOLS_COMPOSED" + +#: WHERE EACH FILE LIVES, by the adoption mapping's placement table +#: (`specs/004-migrate-to-triad/plan.md`, "Target placement"): the front-door +#: documents are the assembly's and the spec trees are the spec leg's. +#: Everything else is the code leg's — and that includes `.gitattributes`, +#: DELIBERATELY: the placement table puts it at the assembly, but git reads a +#: submodule's attributes from the submodule's own tree, so the line-ending +#: rule protects the bash files only where the bash files are. Reading it from +#: the assembly would pass while the code leg's checkout went unprotected. +GUIDANCE_DOCUMENTS = ("README.md", "AGENTS.md", "CLAUDE.md") +SPEC_TREES = ("docs", "openspec", "specs", "ideation") + +#: TODAY'S SINGLE REPOSITORY CARRIES THE CANONICAL SPEC TREES BESIDE THE CODE, +#: and a code leg never does (FR-006 puts every full proposal and every +#: canonical spec in the spec leg). So a code root holding either IS the +#: single-repository layout, whose one tree is assembly, spec and code at once +#: — decided by the trees and never by the documents, so a deleted README there +#: is a failure and not a skip. +SINGLE_REPOSITORY_MARKERS = ("openspec", "specs") + +#: What makes a directory the root it is claimed to be. A variable that names +#: a tree without these is refused, in either mode: it names the wrong tree. +CODE_MARKERS = ("openRepoTools", "contracts/openreposhape-pin.yaml") +ASSEMBLY_MARKERS = ("contracts/code-pin.yaml", "project.yaml") + +#: The dependency, nested in the code leg, and the files the suite's skips +#: probe for: `scripts/repo_shape.py` is `NEEDS_UPSTREAM`'s below and +#: `templates/workspace-root/README.md` is `test_wip_init_command.py`'s. A +#: composed run needs both, because either one missing is a skip. +DEPENDENCY_PATH = "upstream/openRepoShape" +DEPENDENCY_PROBE = "scripts/repo_shape.py" +DEPENDENCY_PROBES = (DEPENDENCY_PROBE, "templates/workspace-root/README.md") + + +def _absolute(value: str) -> Path: + path = Path(os.path.expanduser(value)) + if not path.is_absolute(): + path = Path.cwd() / path + return path.resolve() + + +def _same(a: Path, b: Path) -> bool: + try: + return a.resolve() == b.resolve() + except OSError: + return False + + +def _git_out(cwd: Path, *args: str) -> Optional[str]: + """`git ` in `cwd`, its stdout stripped, or None if it failed.""" + try: + proc = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, + text=True, check=False) + except OSError: + return None + return proc.stdout.strip() if proc.returncode == 0 else None + + +def _gitlink(repository: Path, rel: str) -> Optional[str]: + """The commit `repository`'s HEAD records at `rel`, if it is a gitlink.""" + row = _git_out(repository, "ls-tree", "HEAD", "--", rel) + if not row: + return None + meta = row.split("\t", 1)[0].split(" ") + if len(meta) != 3 or meta[0] != "160000" or meta[1] != "commit": + return None + return meta[2] + + +def _scalar(path: Path, key: str) -> Optional[str]: + """`: ` at column 0 of a flat pin or manifest file, with its + comment and quotes stripped. The pins are a fixed grammar written by one + tool, so a reader of one key is all this needs (no YAML dependency).""" + try: + text = path.read_text(encoding="utf-8") + except OSError: + return None + for raw in text.splitlines(): + if raw.startswith(key + ":"): + value = raw[len(key) + 1:].split("#", 1)[0].strip() + return value.strip('"').strip("'") or None + return None + + +def _carries(root: Path, names) -> bool: + return all((root / name).exists() for name in names) + + +def _carries_a_spec_tree(root: Path) -> bool: + return root.is_dir() and any((root / name).is_dir() for name in SPEC_TREES) + + +def _mount(assembly: Path, role: str) -> str: + """Where the assembly mounts a leg: its pin's `submodule_path:`.""" + return _scalar(assembly / "contracts" / f"{role}-pin.yaml", + "submodule_path") or role + + +def discover_assembly(code: Path) -> Optional[Path]: + """The nearest directory above `code` that carries the assembly's pins and + manifest AND records `code` as the gitlink its code pin names — or None, + which is today's layout and a standalone code clone alike.""" + for candidate in code.resolve().parents: + if not _carries(candidate, ASSEMBLY_MARKERS): + continue + mount = _mount(candidate, "code") + top = _git_out(candidate, "rev-parse", "--show-toplevel") + if (top and _same(Path(top), candidate) and _same(candidate / mount, code) + and _gitlink(candidate, mount)): + return candidate + return None + return None + + +class Roots: + """The code, assembly, spec and dependency roots of one run, and its mode.""" + + def __init__(self, code: Path, assembly: Optional[Path], + spec: Optional[Path], composed: bool, + how: Dict[str, str], problems: List[str]) -> None: + self.code = code + self.assembly = assembly + self.spec = spec + self.dependency = code / DEPENDENCY_PATH + self.composed = composed + self.how = how + self.problems = problems + #: `single` — today's one tree; `leg` — a code leg, standalone or + #: mounted. Decided by the code root alone; the assembly and spec + #: roots are found, or named, independently of it. + self.layout = ("single" if any((code / name).is_dir() + for name in SINGLE_REPOSITORY_MARKERS) + else "leg") + + # --- which root a file is read from ------------------------------------ + + def owner_of(self, rel: str) -> str: + rel = rel.replace("\\", "/") + if rel in GUIDANCE_DOCUMENTS: + return "assembly" + if "/" in rel and rel.split("/", 1)[0] in SPEC_TREES: + return "spec" + return "code" + + def root_of(self, role: str) -> Optional[Path]: + """The tree that answers for `role` in this run. On today's layout the + code root answers for all three; in a leg, only what is there.""" + if role == "code": + return self.code + found = self.assembly if role == "assembly" else self.spec + if found is not None: + return found + return self.code if self.layout == "single" else None + + def path_for(self, rel: str) -> Path: + """`rel` in the root that owns it. Where that root is absent this SKIPS + the calling test — or, composed, FAILS it — naming the root.""" + role = self.owner_of(rel) + root = self.root_of(role) + if root is None: + self.need(role, rel) + return root / rel + + def why_absent(self, role: str) -> str: + if role == "assembly": + return (f"no {ENV_ASSEMBLY_ROOT}, and no directory above {self.code} " + f"carries {' and '.join(ASSEMBLY_MARKERS)} mounting it") + if self.assembly is not None: + return (f"the assembly's spec leg {self.assembly / _mount(self.assembly, 'spec')} " + f"is not checked out, and {ENV_SPEC_ROOT} is unset") + return f"no {ENV_SPEC_ROOT}, and no assembly root to find a spec leg in" + + def need(self, role: str, rel: str): + """The outcome of a test that reached a root this run does not have.""" + why = self.why_absent(role) + if self.composed: + pytest.fail(f"{ENV_COMPOSED}=1 and {rel} is the {role} root's, which " + f"is absent: {why}", pytrace=False) + pytest.skip(f"{rel} is the {role} root's, and this standalone code " + f"checkout has none ({why}); the composed run " + f"({ENV_COMPOSED}=1 from the assembly root) reads it") + + def tracked_roots(self) -> List[Tuple[str, Path]]: + """Every distinct repository this run can read, code first.""" + out: List[Tuple[str, Path]] = [] + for role in ("code", "assembly", "spec"): + root = self.root_of(role) + if root is not None and not any(_same(root, seen) for _, seen in out): + out.append((role, root)) + return out + + # --- the dependency ------------------------------------------------------ + + def dependency_initialized(self, probes=DEPENDENCY_PROBES) -> bool: + return all((self.dependency / probe).is_file() for probe in probes) + + # --- the composed contract ----------------------------------------------- + + def _leg_at_its_pin(self, role: str, root: Path) -> List[str]: + assert self.assembly is not None + mount = _mount(self.assembly, role) + recorded = _gitlink(self.assembly, mount) + if recorded is None: + return [f"the assembly root {self.assembly} records no gitlink at " + f"{mount!r}, which contracts/{role}-pin.yaml names"] + head = _git_out(root, "rev-parse", "HEAD") + if head != recorded: + return [f"the {role} root {root} is at {head or 'no commit'}, and the " + f"assembly root records {recorded} at {mount!r}: composed " + f"acceptance is for the pinned {role} leg and no other"] + dirty = _git_out(root, "status", "--porcelain", "--untracked-files=no") + if dirty: + return [f"the {role} root {root} has changes to tracked files " + f"({dirty.splitlines()[0].strip()} …): composed acceptance is " + f"for the committed bytes the pin names"] + return [] + + def absent(self) -> List[str]: + """What a composed run is missing, one line per context.""" + lines: List[str] = [] + if self.assembly is None: + lines.append(f"the ASSEMBLY root is absent: {self.why_absent('assembly')}") + else: + lines += self._leg_at_its_pin("code", self.code) + if self.spec is None: + lines.append(f"the SPEC root is absent: {self.why_absent('spec')}") + elif self.assembly is not None: + lines += self._leg_at_its_pin("spec", self.spec) + if not self.dependency_initialized(): + missing = [p for p in DEPENDENCY_PROBES + if not (self.dependency / p).is_file()] + lines.append(f"the DEPENDENCY {self.dependency} is not initialized " + f"(no {', '.join(missing)}): run `git submodule update " + f"--init --recursive` from the assembly root") + return lines + + def refusal(self) -> Optional[str]: + """Why this run must not start: a variable naming the wrong tree (either + mode), or a composed run missing a context. None when it may run.""" + lines = list(self.problems) + if self.composed: + lines += self.absent() + if not lines: + return None + head = ("openRepoTools test roots: " if not self.composed else + f"openRepoTools composed run ({ENV_COMPOSED}=1) refused — it " + f"never skips its way to acceptance: ") + return head + "; ".join(lines) + + def describe(self) -> List[str]: + """The roots, one line each, for the report header.""" + lines = [f"openRepoTools test roots: " + f"{'COMPOSED' if self.composed else 'standalone'}, " + f"{self.layout} layout"] + for role in ("code", "assembly", "spec"): + root = self.root_of(role) + if root is None: + lines.append(f" {role}: absent") + continue + how = self.how.get(role) or ( + "default" if role == "code" else "the code root: today's single tree") + lines.append(f" {role}: {root} ({how})") + state = "initialized" if self.dependency_initialized() else "NOT initialized" + lines.append(f" dependency: {self.dependency} ({state})") + return lines + + +def resolve_roots(environ: Mapping[str, str], suite_root: Path) -> Roots: + """The roots `environ` names, else the ones the layout around `suite_root` + has. Pure apart from reading the disk and git, so the suite can ask it about + a fixture layout as well as about itself.""" + problems: List[str] = [] + how: Dict[str, str] = {} + + composed_value = environ.get(ENV_COMPOSED, "") + if composed_value not in ("", "0", "1"): + problems.append(f"{ENV_COMPOSED}={composed_value!r} is neither 1 " + f"(composed) nor 0 or unset (standalone)") + composed = composed_value == "1" + + code = suite_root.resolve() + named = environ.get(ENV_CODE_ROOT, "") + if named: + candidate = _absolute(named) + if candidate.is_dir() and _carries(candidate, CODE_MARKERS): + code, how["code"] = candidate, ENV_CODE_ROOT + else: + problems.append(f"{ENV_CODE_ROOT}={named} is not a code root: it " + f"carries no {' and '.join(CODE_MARKERS)}") + + assembly: Optional[Path] = None + named = environ.get(ENV_ASSEMBLY_ROOT, "") + if named: + candidate = _absolute(named) + if candidate.is_dir() and _carries(candidate, ASSEMBLY_MARKERS): + assembly, how["assembly"] = candidate, ENV_ASSEMBLY_ROOT + else: + problems.append(f"{ENV_ASSEMBLY_ROOT}={named} is not an assembly " + f"root: it carries no {' and '.join(ASSEMBLY_MARKERS)}") + else: + assembly = discover_assembly(code) + if assembly is not None: + how["assembly"] = "found above the code root, which it mounts" + + spec: Optional[Path] = None + named = environ.get(ENV_SPEC_ROOT, "") + if named: + candidate = _absolute(named) + if _carries_a_spec_tree(candidate): + spec, how["spec"] = candidate, ENV_SPEC_ROOT + else: + problems.append(f"{ENV_SPEC_ROOT}={named} is not a spec root: it " + f"carries none of {', '.join(SPEC_TREES)}") + elif assembly is not None: + candidate = assembly / _mount(assembly, "spec") + if _carries_a_spec_tree(candidate): + spec, how["spec"] = candidate, "the assembly's spec leg" + + return Roots(code, assembly, spec, composed, how, problems) + + +#: THIS RUN'S ROOTS. Module-level names below are its fields, so the modules +#: that import `REPO` and `UPSTREAM` read the code root and its dependency +#: whatever the layout. +ROOTS = resolve_roots(os.environ, SUITE_ROOT) +CODE_ROOT = ROOTS.code +ASSEMBLY_ROOT = ROOTS.assembly +SPEC_ROOT = ROOTS.spec +DEPENDENCY_ROOT = ROOTS.dependency +COMPOSED = ROOTS.composed + +#: `REPO` is the CODE root: every module that reads a command, a test or a pin +#: through it keeps reading the code leg's copy, which is what it always meant. +#: A document the assembly or the spec leg owns is read through +#: `ROOTS.path_for()` instead. +REPO = CODE_ROOT + + +def pytest_configure(config): + """REFUSE BEFORE THE FIRST TEST, naming every missing or misnamed root. + + A `UsageError` is pytest's own refusal (exit 4, `ERROR: `), which is + what a composed run that cannot be the claim it makes should be: not a + session of skips that exits 0.""" + refusal = ROOTS.refusal() + if refusal: + raise pytest.UsageError(refusal) + + +def pytest_report_header(config): + return ROOTS.describe() #: AMENDMENT 14 — NO TEST NUDGES AN INSTALLED INDEXER BY ACCIDENT. Every #: `lanes-edit.sh` write that pushes starts a detached `lanes-index sync` @@ -41,21 +423,42 @@ #: state directory it stamps may be the workstation's own. os.environ["LANE_WORKTREES_REPORT"] = "off" -#: The pinned openRepoShape checkout. Every path this suite reads out of the -#: standard hangs off this one name, so a bump of the pin moves one line in +#: The pinned openRepoShape checkout: the DEPENDENCY root, nested in the code +#: root. Every path this suite reads out of the standard hangs off this one +#: name, so a bump of the pin moves one line in #: `contracts/openreposhape-pin.yaml` and the gitlink beside it, and nothing #: here. -UPSTREAM = REPO / "upstream" / "openRepoShape" +UPSTREAM = DEPENDENCY_ROOT #: THE TESTS THAT NEED THE STANDARD'S REAL BYTES. `scripts/repo_shape.py` is #: the probe rather than the directory itself, because `git clone` without #: `--recurse-submodules` leaves an EMPTY `upstream/openRepoShape/` behind — #: the directory exists and holds nothing, so `is_dir()` would answer yes and #: the tests would fail on a missing file instead of skipping. -NEEDS_UPSTREAM = pytest.mark.skipif( - not (UPSTREAM / "scripts" / "repo_shape.py").is_file(), - reason="the pinned openRepoShape is not checked out; " - "run `git submodule update --init upstream/openRepoShape`") +#: +#: STANDALONE, THAT SKIP IS EXACTLY TODAY'S — the same condition and the same +#: reason, which is what the `tests-no-submodule` job asserts. COMPOSED, the +#: mark is the `required_dependency` fixture instead, which FAILS: a composed +#: session without the dependency is refused before it starts, and this is the +#: same rule held at the test, where a `--noconftest` run cannot route round it +#: (the fixture is then not found, which is an error and not a skip either). +_DEPENDENCY_SKIP_REASON = ("the pinned openRepoShape is not checked out; " + "run `git submodule update --init upstream/openRepoShape`") +if COMPOSED: + NEEDS_UPSTREAM = pytest.mark.usefixtures("required_dependency") +else: + NEEDS_UPSTREAM = pytest.mark.skipif( + not (UPSTREAM / DEPENDENCY_PROBE).is_file(), + reason=_DEPENDENCY_SKIP_REASON) + + +@pytest.fixture +def required_dependency(): + """`NEEDS_UPSTREAM` in a composed run: the dependency is there, or the test + fails naming it.""" + if not (UPSTREAM / DEPENDENCY_PROBE).is_file(): + pytest.fail(f"{ENV_COMPOSED}=1 and the dependency {UPSTREAM} is not " + f"initialized; {_DEPENDENCY_SKIP_REASON}", pytrace=False) #: THE TESTS THAT CANNOT RUN ON WINDOWS. Two shapes of test wear this: one that #: runs `bash`, and one that puts a `#!`-shebang script named `gh` on PATH and diff --git a/tests/run.sh b/tests/run.sh index 22da7990..585a343d 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -72,6 +72,29 @@ set -uo pipefail prog="${0##*/}" +# THE ROOTS ARE PASSED THROUGH, NEVER CHOSEN HERE (opensoft/openRepoTools#186, +# T009). `OPENREPOTOOLS_CODE_ROOT`, `_ASSEMBLY_ROOT` and `_SPEC_ROOT` name the +# trees `tests/conftest.py` reads, and they reach pytest in the environment as +# they are - except that the `cd` below would read a RELATIVE one against the +# code root rather than against where the caller stood. So a relative value is +# made absolute against the caller's directory first, and nothing else is +# touched. `OPENREPOTOOLS_COMPOSED` is a mode, not a path, and passes as it is. +abs_root() { # - absolute against the caller's directory + case "$1" in + /*) printf '%s\n' "$1" ;; + *) printf '%s/%s\n' "$PWD" "$1" ;; + esac +} +if [ -n "${OPENREPOTOOLS_CODE_ROOT:-}" ]; then + OPENREPOTOOLS_CODE_ROOT="$(abs_root "$OPENREPOTOOLS_CODE_ROOT")"; export OPENREPOTOOLS_CODE_ROOT +fi +if [ -n "${OPENREPOTOOLS_ASSEMBLY_ROOT:-}" ]; then + OPENREPOTOOLS_ASSEMBLY_ROOT="$(abs_root "$OPENREPOTOOLS_ASSEMBLY_ROOT")"; export OPENREPOTOOLS_ASSEMBLY_ROOT +fi +if [ -n "${OPENREPOTOOLS_SPEC_ROOT:-}" ]; then + OPENREPOTOOLS_SPEC_ROOT="$(abs_root "$OPENREPOTOOLS_SPEC_ROOT")"; export OPENREPOTOOLS_SPEC_ROOT +fi + # THE REPOSITORY ROOT, so the suite runs the same from anywhere. `cd -P` is # the portable physical path: `readlink -f` is not in the stock macOS userland. cd -P -- "$(dirname -- "$0")/.." 2>/dev/null || { diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 2d4287e9..2200c8d1 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -48,6 +48,14 @@ TESTS_DIR="$(cd -- "$(dirname -- "$SELF")" && pwd)" # `repos.tsv` BESIDE it, because in `opensoft/brett-wip` they were. They are at # the root of `opensoft/openRepoTools` now and this suite is in `tests/`. SRC_DIR="$(cd -- "$TESTS_DIR/.." && pwd)" +# THE LANE MANUAL IS THE SPEC LEG'S (opensoft/openRepoTools#186, T009). Two +# sections below quote `docs/README-lanes.md` back against the code, and in the +# triad that file is in the spec leg, not beside the code. The pytest runner +# (`tests/test_lane_helpers_suite.py`) names it from the run's roots; SET AND +# EMPTY means the run has no spec root - a standalone code leg - and those +# quotes are SKIPPED, naming it. Unset, it is this checkout's own copy, as it +# always was, and a composed run never reaches here without one. +LANE_MANUAL="${OPENREPOTOOLS_LANE_MANUAL-$SRC_DIR/docs/README-lanes.md}" for f in lane-start lane-end lanes-edit.sh link-estates lane lanes lane-rename lane-worktrees; do [ -x "$SRC_DIR/$f" ] || { echo "missing or not executable: $SRC_DIR/$f" >&2; exit 1; } @@ -3733,9 +3741,10 @@ unset FAKE_TMUX_WINDOW_NAME # and not one example block carrying the `as of … (no fetch)` line Amendment # 8(e) requires on EVERY block — and nothing noticed, because no assertion in # this suite had ever read the document. A quoted line nobody reads is the same -# defect as an untested sentence (RV-B2), one file over. Read from `$SRC_DIR`, -# the checkout's own copy, which is the one a person opens. -ss_doc="$(cat "$SRC_DIR/docs/README-lanes.md" 2>/dev/null || :)" +# defect as an untested sentence (RV-B2), one file over. Read from +# `$LANE_MANUAL`, the run's own copy, which is the one a person opens. +if [ -n "$LANE_MANUAL" ]; then +ss_doc="$(cat "$LANE_MANUAL" 2>/dev/null || :)" has "the README states the subcommand's THIRD safety property, not just two" "$ss_doc" "It never writes, it never touches the network, and it always exits 0" has "…and carries the line every block ends with" "$ss_doc" "as of 4m ago (no fetch)" has "…quoting the superseded-transcript branch as the hook really prints it" "$ss_doc" "WARNING: this is a superseded transcript of lane repoSS-1; the live one is ; you resumed — exit this session and run: lane-start repoSS 1" @@ -3743,6 +3752,9 @@ has "…its \`unknown\`-stamp sub-branch, which sends the reader to the RECORDI has "…the in-no-row branch, its command filled in too" "$ss_doc" "this session is in no row — the harness minted a new transcript — run: lane-start --no-launch repoSS 1" has "…and the third bound-branch line, the one Rule 3's stamp is owed on" "$ss_doc" "stamp the handoff RESUMED (Rule 3) and follow its top block" hasnt "…never the drifted spelling, which no branch of the hook has ever printed" "$ss_doc" "whose current session is ; you resumed — run: lane-start " +else + skip "the manual quotes the session-start hook (seven assertions)" "no spec root in this run: docs/README-lanes.md is the spec leg's (#186, T009)" +fi # ------------------------------------------------- Rule 1's sibling reads # @@ -6223,9 +6235,10 @@ has "…in ITS arm's own words" "$err" "--estate needs a name" # went on calling `lanes` "every lane on this workstation, newest write first" # — the pre-settlement default, wrong twice over, because the scope is the # ESTATE now and inside a checkout it is one repository. Nothing was red for it: -# no assertion had ever read that sentence. Read from `$SRC_DIR`, the checkout's +# no assertion had ever read that sentence. Read from `$LANE_MANUAL`, the run's # own copy, which is the one a person opens. -ln_doc="$(cat "$SRC_DIR/docs/README-lanes.md" 2>/dev/null || :)" +if [ -n "$LANE_MANUAL" ]; then +ln_doc="$(cat "$LANE_MANUAL" 2>/dev/null || :)" has "the manual quotes the settlement that narrowed the bare word" "$ln_doc" \ "Narrow inside a checkout (Recommended)" has "…and the one that made \`lane-start \` list and suggest" "$ln_doc" \ @@ -6240,6 +6253,9 @@ has "…and the way back to every lane, which is the word the stale sentence l "\`lanes --all\`, and a bare \`lanes\` outside every checkout, are clause (j)'s" hasnt "…never the pre-settlement default it documented while building the narrowing" "$ln_doc" \ "every lane on this workstation" +else + skip "the manual says what a bare lanes lists (seven assertions)" "no spec root in this run: docs/README-lanes.md is the spec leg's (#186, T009)" +fi run "$LANES_CMD" --all Path: + """`rel` from the root that owns it: `ROOTS.path_for`, which skips a + standalone code leg's test (fails a composed one) where that root is + absent. A command passed here is simply the code root's file.""" + return ROOTS.path_for(rel) + + +def code_first(*names: str) -> list: + """`names`, the code root's own files first: where a test reads a command + AND the documents about it, a standalone code leg still asserts the + command's half before the first document's root ends the test.""" + return sorted(names, key=lambda name: ROOTS.owner_of(name) != "code") + + +def tracked_files(): + """(label, path) for every file `git ls-files` tracks in every repository + this run can read — the code root alone on today's layout, and in the + composed run the assembly and the spec leg too, so a claim about every + COMMITTED file still covers every file the single repository had.""" + for role, root in ROOTS.tracked_roots(): + listed = subprocess.run(["git", "ls-files"], cwd=str(root), + capture_output=True, text=True, + check=True).stdout.splitlines() + for rel in listed: + yield (rel if role == "code" else f"{role} root: {rel}"), root / rel #: EVERY BASH FILE THIS REPOSITORY SHIPS, and nothing else is one. Each is a #: file a person has on their PATH — the installer, the two estate verbs, the @@ -846,7 +880,7 @@ def test_the_readme_prints_the_install_line_the_pointer_prints(): """openRepoShape's `--install` prints one `curl` line pointing here, and a reader who follows it must land on the same bytes this README documents. Two spellings of one install line is one of them being wrong.""" - readme = (REPO / "README.md").read_text(encoding="utf-8") + readme = doc("README.md").read_text(encoding="utf-8") assert RAW_INSTALL_LINE in readme, ( "README.md does not carry the install line openRepoShape's --install " f"points at, byte for byte:\n {RAW_INSTALL_LINE}") @@ -857,7 +891,7 @@ def test_the_readme_also_carries_the_gh_api_form(): raw.githubusercontent.com still has a working `gh` — the same reason the installer tries the API first. The shim's own pointer has room for one line; the READMEs are where the twin lives.""" - readme = (REPO / "README.md").read_text(encoding="utf-8") + readme = doc("README.md").read_text(encoding="utf-8") assert "gh api repos/opensoft/openRepoTools/contents/openRepoTools" in readme assert "-H 'Accept: application/vnd.github.raw'" in readme assert "bash -s -- --install" in readme @@ -884,7 +918,7 @@ def test_the_readme_carries_the_two_line_onboarding_chain(): login from `gh api user`, and `~/.local/bin` on `PATH`, which needs a restarted terminal before anything `--install` placed can be typed. """ - readme = (REPO / "README.md").read_text(encoding="utf-8") + readme = doc("README.md").read_text(encoding="utf-8") assert "gh repo clone opensoft/workBenches && cd workBenches && ./setup.sh" \ in readme, ( "README.md does not carry Amendment 9(e)'s first line, byte for byte") @@ -908,14 +942,14 @@ def test_no_document_offers_a_windows_powershell_twin(): two-liner and no PowerShell twin to point at, so on Windows the way in is WSL2 — which both documents say, once each.""" for name in ("README.md", "AGENTS.md"): - text = (REPO / name).read_text(encoding="utf-8") + text = doc(name).read_text(encoding="utf-8") assert "Invoke-WebRequest" not in text, ( f"{name} offers a Windows download line for a bash-only toolset") assert "WSL2" in text, f"{name} must say what a Windows reader does" def test_claude_md_points_at_agents_md(): - assert "AGENTS.md" in (REPO / "CLAUDE.md").read_text() + assert "AGENTS.md" in doc("CLAUDE.md").read_text() def test_the_documents_say_what_bare_park_does_now(): @@ -938,8 +972,8 @@ def test_the_documents_say_what_bare_park_does_now(): estate the bare form does not need or, worse, treats a bare `park` in the wrong folder as a one-keystroke sweep of the workstation. """ - for name in ("README.md", "AGENTS.md", "park"): - text = (REPO / name).read_text(encoding="utf-8") + for name in code_first("README.md", "AGENTS.md", "park"): + text = doc(name).read_text(encoding="utf-8") assert "--all" in text, f"{name} does not name the sweep's flag" assert re.search(r"(?`, which asserts "this is act 0". """ - tracked = subprocess.run(["git", "ls-files"], cwd=str(REPO), - capture_output=True, text=True, - check=True).stdout.splitlines() offenders = {} cited = 0 - for rel in tracked: - path = REPO / rel + for rel, path in tracked_files(): if not path.is_file(): continue try: @@ -2621,7 +2651,7 @@ def test_the_manual_offers_the_fork_act_and_not_a_kill(): refuses because they are not forks - and their `kill` lines are untouched here for the same reason the test above leaves them alone. """ - text = (REPO / "docs/README-lanes.md").read_text(encoding="utf-8") + text = doc("docs/README-lanes.md").read_text(encoding="utf-8") offenders = [" ".join(para.split())[:200] for para in re.split(r"\n\s*\n", text) if re.search(r"\bfork\b", para, re.I) @@ -2646,13 +2676,14 @@ def test_no_committed_file_names_a_host_absolute_path(): `upstream/openRepoShape` is listed as a path and is not a file, so it is skipped too: what the standard's own tree carries is the standard's own suite to police. + + EVERY REPOSITORY THIS RUN CAN READ (#186, T009): the composed run scans the + assembly and the spec leg beside the code leg, which is every file the one + repository carried before the split. Their gitlinks are paths and not files, + like the one above. """ - tracked = subprocess.run(["git", "ls-files"], cwd=str(REPO), - capture_output=True, text=True, - check=True).stdout.splitlines() offenders = {} - for rel in tracked: - path = REPO / rel + for rel, path in tracked_files(): if not path.is_file(): continue try: @@ -2677,6 +2708,12 @@ def test_the_root_carries_the_line_ending_rule(): to LF in the object store on check-in and checks it out as LF on every platform, whatever `core.autocrlf` says — so nobody has to be told a git setting before cloning, and nobody who was never told is punished for it. + + THE CODE ROOT'S, IN EVERY LAYOUT (#186, T009). The adoption mapping places + `.gitattributes` at the assembly, but git reads a submodule's attributes + from the submodule's own tree: the assembly's copy protects no byte of the + code leg. So this reads the file beside the bash files, and a code leg that + has none is red here until it carries its own. """ text = (REPO / ".gitattributes").read_text(encoding="utf-8") assert "* text=auto eol=lf" in text @@ -3071,7 +3108,7 @@ def test_the_suite_wrapper_takes_one_lock_and_names_it_where_agents_read_it(): wrapper = REPO / "tests" / "run.sh" assert wrapper.is_file(), "tests/run.sh is the way this suite is run" text = wrapper.read_text(encoding="utf-8") - agents = (REPO / "AGENTS.md").read_text(encoding="utf-8") + agents = doc("AGENTS.md").read_text(encoding="utf-8") lock = '${TMPDIR:-/tmp}/openrepotools-pytest.lock' assert lock in text, f"the wrapper must take {lock}" diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py new file mode 100644 index 00000000..6078904e --- /dev/null +++ b/tests/test_test_roots.py @@ -0,0 +1,573 @@ +# SPDX-License-Identifier: Apache-2.0 +"""THE FOUR ROOTS AND THE TWO MODES, held (opensoft/openRepoTools#186, T009 of +`specs/004-migrate-to-triad/tasks.md`). + +`tests/conftest.py` resolves a CODE root (the commands, `tests/`, the pin), an +ASSEMBLY root (`README.md`, `AGENTS.md`, `CLAUDE.md`, the leg pins), a SPEC root +(`docs/`, `openspec/`, `specs/`) and the DEPENDENCY nested in the code root +(`upstream/openRepoShape`) — from `OPENREPOTOOLS_CODE_ROOT`, +`OPENREPOTOOLS_ASSEMBLY_ROOT` and `OPENREPOTOOLS_SPEC_ROOT` where they are set, +from the layout where they are not. `OPENREPOTOOLS_COMPOSED=1` makes the run a +claim about the whole arrangement: every root present, each leg at the commit +the assembly pins, and nothing skipped for want of one. + +TWO KINDS OF TEST HERE. The resolver is asked directly about fixture layouts +built under `tmp_path` — today's single tree, a triad, a standalone code leg — +which is fast and runs everywhere. And because a resolver nobody calls proves +nothing about the suite, the SUITE ITSELF is run against those fixtures, in a +nested `pytest` with the variables set: the hygiene module must read the +README from the fixture's assembly root and the lane manual from its spec +root, the pin module must read the fixture code root's own gitlink, the +no-submodule skip must still be today's skip, and a composed run missing a +context must refuse rather than start. Those nested runs are what was red at +`origin/main`, where every one of those variables was ignored. + +NOTHING HERE TOUCHES A REAL REPOSITORY'S STATE. Every fixture is a copy or a +fresh `git init` under `tmp_path`; the real code root and its dependency are +only READ (a `git ls-files`, a local `git clone`). Every fixture's legs are +pinned with the tree digest recomputed from the fixture's own tree, never +written in to agree. +""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +import conftest +from conftest import NEEDS_UPSTREAM, REPO, WINDOWS_SKIP + +TESTS = Path(__file__).resolve().parent +DEPENDENCY = REPO / "upstream" / "openRepoShape" +WRAPPER = REPO / "tests" / "run.sh" + +#: What a code leg does NOT carry after the split, by the adoption mapping's +#: placement table: the six root files went to the assembly and the four +#: trees to the spec leg. A fixture code leg is the code root less these. +ASSEMBLY_FILES = ("README.md", "AGENTS.md", "CLAUDE.md", "LICENSE", + ".gitattributes", ".gitignore") +SPEC_TREES = ("docs", "openspec", "specs", "ideation") + +INSTALL_LINE = ( + "curl -fsSL https://raw.githubusercontent.com/opensoft/openRepoTools/main/" + "openRepoTools | bash -s -- --install") +FORK_ACT = "lane-end --retire " + +#: The rehearsed assembly's `contracts/code-pin.yaml` grammar +#: (`adopt/three-repo-shape` at 2726d3a), field for field. +LEG_PIN = """\ +schema_version: 1 +kind: pinned_contract_manifest + +leg_role: {role} +source_repository: example/fixture-{role} +submodule_path: {role} + +commit: "{commit}" +revision_kind: commit + +digest_algorithm: sha256 +digest_definition: sorted-ls-tree-r-v1 +digests: + tree_sha256: "{digest}" + +verify_pin: scripts/validate-pins.py +resync_runbook: "README.md#the-lockstep-invariant" +""" + +PROJECT_YAML = """\ +schema_version: 1 +kind: project-manifest +id: fixture +name: "fixture" +legs: + - role: assembly + repository: example/fixture + path: "." + - role: spec + repository: example/fixture-spec + path: spec + - role: code + repository: example/fixture-code + path: code +""" + +#: Git in a fixture: an identity of its own, and file transport allowed for +#: the one local clone of the dependency. +GIT_ENV = {"GIT_AUTHOR_NAME": "openRepoTools CI", + "GIT_AUTHOR_EMAIL": "ci@openrepotools.invalid", + "GIT_COMMITTER_NAME": "openRepoTools CI", + "GIT_COMMITTER_EMAIL": "ci@openrepotools.invalid"} + + +def run_git(cwd: Path, *args: str) -> str: + env = dict(os.environ) + env.update(GIT_ENV) + proc = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, + text=True, env=env, check=False) + assert proc.returncode == 0, (f"git {' '.join(args)} in {cwd}:\n" + f"{proc.stderr}{proc.stdout}") + return proc.stdout.strip() + + +def tree_sha256(repository: Path, commit: str) -> str: + """`sorted-ls-tree-r-v1`, exactly as the pin grammar defines it.""" + out = subprocess.run(["git", "ls-tree", "-r", "-z", commit], + cwd=str(repository), capture_output=True, + check=True).stdout + records = sorted(record for record in out.split(b"\0") if record) + return hashlib.sha256(b"".join(r + b"\n" for r in records)).hexdigest() + + +def pinned_commit() -> str: + for line in (REPO / "contracts" / "openreposhape-pin.yaml").read_text( + encoding="utf-8").splitlines(): + if line.startswith("commit:"): + return line.split(":", 1)[1].strip().strip('"') + raise AssertionError("the dependency pin records no commit") + + +def dependency_present() -> bool: + return (DEPENDENCY / "scripts" / "repo_shape.py").is_file() + + +# --- fixture layouts ---------------------------------------------------------- + +def code_leg(dest: Path, *, single: bool = False, repository: bool = False, + gitlink: str = "", dependency: str = "") -> Path: + """A copy of the code root's tracked files at `dest`. + + `single` keeps the assembly's files and the spec trees (today's layout); + otherwise they are left out, as the split leaves them. `repository` makes + it a repository of its own with one commit, recording `gitlink` (default: + the pinned commit) at `upstream/openRepoShape`. `dependency` is "" for no + directory there, "empty" for the empty one a clone without + `--recurse-submodules` leaves, or "clone" for a checkout of the real + dependency at the pinned commit. + """ + listed = run_git(REPO, "ls-files", "-z").split("\0") + for rel in filter(None, listed): + top = rel.split("/", 1)[0] + if not single and (rel in ASSEMBLY_FILES or top in SPEC_TREES): + continue + source = REPO / rel + if not source.is_file(): + continue # the dependency's gitlink, which is not a file + target = dest / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + if single: + # TODAY'S TREE EVEN WHERE THIS RUN IS A CODE LEG (the composed job): + # the documents and the spec trees a single repository carries. + for rel, text in (("README.md", f"# fixture\n\n {INSTALL_LINE}\n"), + ("AGENTS.md", "# fixture\n"), + ("docs/README-lanes.md", f"# Lanes\n\n{FORK_ACT}\n"), + ("openspec/README.md", "# fixture spec\n")): + target = dest / rel + if not target.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + if repository: + run_git(dest, "init", "-q") + run_git(dest, "add", "-A") + run_git(dest, "update-index", "--add", "--cacheinfo", + f"160000,{gitlink or pinned_commit()},upstream/openRepoShape") + run_git(dest, "commit", "-q", "-m", "the code leg, copied for a fixture") + if dependency == "empty": + (dest / "upstream" / "openRepoShape").mkdir(parents=True, exist_ok=True) + elif dependency == "clone": + target = dest / "upstream" / "openRepoShape" + if target.exists(): + target.rmdir() + run_git(dest, "clone", "-q", "--no-checkout", str(DEPENDENCY), str(target)) + run_git(target, "checkout", "-q", "--detach", pinned_commit()) + return dest + + +def spec_leg(dest: Path, *, manual: str = f"# Lanes\n\n{FORK_ACT}\n", + repository: bool = False) -> Path: + (dest / "docs").mkdir(parents=True) + (dest / "docs" / "README-lanes.md").write_text(manual, encoding="utf-8") + (dest / "openspec").mkdir() + (dest / "openspec" / "README.md").write_text("# fixture spec\n", + encoding="utf-8") + if repository: + run_git(dest, "init", "-q") + run_git(dest, "add", "-A") + run_git(dest, "commit", "-q", "-m", "the spec leg, for a fixture") + return dest + + +def assembly(dest: Path, *, readme: str = f"# fixture\n\n {INSTALL_LINE}\n", + code: Path | None = None, spec: Path | None = None, + repository: bool = False) -> Path: + """An assembly root at `dest`: the manifest, the two leg pins and a + README. With `repository`, a repository of its own whose HEAD records + `code` and `spec` as gitlinks at each one's own HEAD, the pins recording + the same commits and each leg's recomputed tree digest.""" + (dest / "contracts").mkdir(parents=True, exist_ok=True) + (dest / "project.yaml").write_text(PROJECT_YAML, encoding="utf-8") + (dest / "README.md").write_text(readme, encoding="utf-8") + heads = {} + for role, leg in (("code", code), ("spec", spec)): + head = run_git(leg, "rev-parse", "HEAD") if (leg and repository) else "0" * 40 + digest = tree_sha256(leg, head) if (leg and repository) else "0" * 64 + heads[role] = head + (dest / "contracts" / f"{role}-pin.yaml").write_text( + LEG_PIN.format(role=role, commit=head, digest=digest), + encoding="utf-8") + if repository: + run_git(dest, "init", "-q") + run_git(dest, "add", "--", "project.yaml", "README.md", "contracts") + for role in ("code", "spec"): + run_git(dest, "update-index", "--add", "--cacheinfo", + f"160000,{heads[role]},{role}") + run_git(dest, "commit", "-q", "-m", "the assembly, for a fixture") + return dest + + +# --- the suite, run nested against a fixture ---------------------------------- + +class Nested: + def __init__(self, proc: subprocess.CompletedProcess, outcomes: dict): + self.returncode = proc.returncode + self.output = proc.stdout + proc.stderr + self.outcomes = outcomes + + def outcome(self, name: str) -> tuple: + assert name in self.outcomes, ( + f"{name} did not run; the nested run said:\n{self.output[-3000:]}") + return self.outcomes[name] + + +_nested_runs = [0] + + +def nested(tmp_path: Path, env: dict, select: str, *modules: str) -> Nested: + """`python -m pytest` on this suite's own `modules`, `-k select`, with + `env` as the ONLY `OPENREPOTOOLS_*` variables: a run that read anything + from the outer run's roots would prove nothing about these.""" + _nested_runs[0] += 1 + n = _nested_runs[0] + junit = tmp_path / f"nested-{n}.xml" + child = {k: v for k, v in os.environ.items() + if not k.startswith("OPENREPOTOOLS_") and k != "PYTEST_ADDOPTS"} + child.update(GIT_ENV) + child.update(env) + child["PYTHONDONTWRITEBYTECODE"] = "1" + command = [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", + "-rA", f"--junitxml={junit}", + f"--basetemp={tmp_path / f'nested-{n}-tmp'}", "-k", select, + *(str(TESTS / module) for module in modules)] + proc = subprocess.run(command, cwd=str(tmp_path), env=child, + capture_output=True, text=True, timeout=900, + check=False) + outcomes = {} + if junit.is_file(): + for case in ET.parse(str(junit)).getroot().iter("testcase"): + outcome, message = "passed", "" + for kind in ("failure", "error", "skipped"): + found = case.find(kind) + if found is not None: + outcome = kind + message = (found.get("message") or "") + (found.text or "") + break + outcomes[case.get("name")] = (outcome, message) + return Nested(proc, outcomes) + + +README_TEST = "test_the_readme_prints_the_install_line_the_pointer_prints" +MANUAL_TEST = "test_the_manual_offers_the_fork_act_and_not_a_kill" +LOCKSTEP_TEST = "test_the_gitlink_and_the_pin_name_the_same_commit" +DIGEST_TEST = "test_the_tree_digest_is_the_pinned_commits_own_number" +HEAD_TEST = "test_the_checked_out_submodule_is_at_the_recorded_commit" + + +def test_the_hygiene_suite_reads_the_readme_from_the_assembly_root(tmp_path): + """`OPENREPOTOOLS_ASSEMBLY_ROOT` decides which README the install-line + test reads: one that lacks the line FAILS it, one that carries it passes. + Red at `origin/main`, which read this checkout's own README either way.""" + lacking = assembly(tmp_path / "lacking", readme="# no install line here\n") + carrying = assembly(tmp_path / "carrying") + red = nested(tmp_path, {"OPENREPOTOOLS_ASSEMBLY_ROOT": str(lacking)}, + README_TEST, "test_repo_hygiene.py") + outcome, message = red.outcome(README_TEST) + assert outcome == "failure" and "does not carry the install line" in message, ( + f"the README test {outcome} against an assembly README with no install " + f"line; it did not read the assembly root:\n{message}\n{red.output[-2000:]}") + green = nested(tmp_path, {"OPENREPOTOOLS_ASSEMBLY_ROOT": str(carrying)}, + README_TEST, "test_repo_hygiene.py") + assert green.outcome(README_TEST)[0] == "passed", green.output[-3000:] + + +def test_the_hygiene_suite_reads_the_lane_manual_from_the_spec_root(tmp_path): + """`OPENREPOTOOLS_SPEC_ROOT` decides which `docs/README-lanes.md` is read: + a manual that never spells the fork act FAILS the manual test.""" + lacking = spec_leg(tmp_path / "lacking", manual="# Lanes\n\nnothing here\n") + carrying = spec_leg(tmp_path / "carrying") + red = nested(tmp_path, {"OPENREPOTOOLS_SPEC_ROOT": str(lacking)}, + MANUAL_TEST, "test_repo_hygiene.py") + outcome, message = red.outcome(MANUAL_TEST) + assert outcome == "failure" and "never spells the one fork act" in message, ( + f"the manual test {outcome} against a spec root whose manual lacks the " + f"act; it did not read the spec root:\n{message}\n{red.output[-2000:]}") + green = nested(tmp_path, {"OPENREPOTOOLS_SPEC_ROOT": str(carrying)}, + MANUAL_TEST, "test_repo_hygiene.py") + assert green.outcome(MANUAL_TEST)[0] == "passed", green.output[-3000:] + + +def test_a_standalone_code_leg_skips_what_only_the_assembly_carries(tmp_path): + """A code leg cloned on its own has no README: standalone, the README test + SKIPS naming the assembly root, which is the courtesy the missing submodule + gets; composed, the session is REFUSED naming it (exit 4), never a skip.""" + leg = code_leg(tmp_path / "code") + standalone = nested(tmp_path, {"OPENREPOTOOLS_CODE_ROOT": str(leg)}, + README_TEST, "test_repo_hygiene.py") + outcome, message = standalone.outcome(README_TEST) + assert outcome == "skipped" and "assembly root" in message, ( + f"a standalone code leg's README test {outcome}, not a skip naming the " + f"assembly root:\n{message}\n{standalone.output[-2000:]}") + composed = nested(tmp_path, {"OPENREPOTOOLS_CODE_ROOT": str(leg), + "OPENREPOTOOLS_COMPOSED": "1"}, + README_TEST, "test_repo_hygiene.py") + assert composed.returncode == 4, ( + f"a composed run with no assembly root exited {composed.returncode}, " + f"not pytest's refusal (4):\n{composed.output[-3000:]}") + assert "the ASSEMBLY root is absent" in composed.output, composed.output[-3000:] + assert "the SPEC root is absent" in composed.output, composed.output[-3000:] + assert not composed.outcomes, "a refused session ran tests anyway" + + +def test_the_pin_checks_read_the_code_roots_own_git_identity(tmp_path): + """The lockstep check reads `git ls-tree HEAD` IN the code root it is given: + a code leg whose gitlink disagrees with its pin FAILS it. Red at + `origin/main`, which read this checkout's gitlink whatever was named.""" + leg = code_leg(tmp_path / "code", repository=True, gitlink="1" * 40) + run = nested(tmp_path, {"OPENREPOTOOLS_CODE_ROOT": str(leg)}, + LOCKSTEP_TEST, "test_upstream_pin.py") + outcome, message = run.outcome(LOCKSTEP_TEST) + assert outcome == "failure" and "1" * 40 in message, ( + f"the lockstep check {outcome} on a code root whose gitlink is " + f"{'1' * 40}; it did not read that root:\n{message}\n{run.output[-2000:]}") + + +def test_the_no_submodule_skip_is_todays_skip_and_composed_refuses_it(tmp_path): + """A code root whose dependency was never initialized — the empty directory + a clone without `--recurse-submodules` leaves — SKIPS the three checks of + the dependency's bytes with today's reason, standalone, while the checks of + the pin itself still run and pass; composed, the session is refused + naming the dependency. The worktree's own submodule is never touched.""" + leg = code_leg(tmp_path / "code", repository=True, dependency="empty") + select = f"{LOCKSTEP_TEST} or {DIGEST_TEST} or {HEAD_TEST}" + standalone = nested(tmp_path, {"OPENREPOTOOLS_CODE_ROOT": str(leg)}, + select, "test_upstream_pin.py") + assert standalone.returncode == 0, standalone.output[-3000:] + assert standalone.outcome(LOCKSTEP_TEST)[0] == "passed", standalone.output[-3000:] + for name in (DIGEST_TEST, HEAD_TEST): + outcome, message = standalone.outcome(name) + assert outcome == "skipped" and ( + "the pinned openRepoShape is not checked out; run `git submodule " + "update --init upstream/openRepoShape`") in message, ( + f"{name} {outcome} without the dependency, not today's skip:\n{message}") + composed = nested(tmp_path, {"OPENREPOTOOLS_CODE_ROOT": str(leg), + "OPENREPOTOOLS_COMPOSED": "1"}, + select, "test_upstream_pin.py") + assert composed.returncode == 4, composed.output[-3000:] + assert "the DEPENDENCY" in composed.output and "is not initialized" in \ + composed.output, composed.output[-3000:] + + +@NEEDS_UPSTREAM +def test_a_composed_triad_runs_every_check_and_skips_none(tmp_path): + """THE POSITIVE CONTROL: a fixture triad — an assembly whose HEAD pins a + code leg and a spec leg at their own HEADs, the dependency checked out at + its pin — runs the README, manual and pin checks composed, found by + discovery alone (only the code root is named), and every one PASSES. + Then one moved gitlink: the spec leg no longer at its pin is refused.""" + code = code_leg(tmp_path / "asm" / "code", repository=True, + dependency="clone") + spec = spec_leg(tmp_path / "asm" / "spec", repository=True) + root = assembly(tmp_path / "asm", code=code, spec=spec, repository=True) + env = {"OPENREPOTOOLS_CODE_ROOT": str(code), "OPENREPOTOOLS_COMPOSED": "1"} + run = nested(tmp_path, env, f"{README_TEST} or {MANUAL_TEST} or upstream_pin", + "test_repo_hygiene.py", "test_upstream_pin.py") + assert run.returncode == 0, run.output[-4000:] + assert run.outcomes, run.output[-3000:] + not_passed = {name: o for name, o in run.outcomes.items() if o[0] != "passed"} + assert not not_passed, f"a composed run did not pass everything: {not_passed}" + for name in (README_TEST, MANUAL_TEST, LOCKSTEP_TEST, DIGEST_TEST, HEAD_TEST): + run.outcome(name) + (spec / "openspec" / "README.md").write_text("# moved\n", encoding="utf-8") + run_git(spec, "commit", "-q", "-a", "-m", "the spec leg moves off its pin") + moved = nested(tmp_path, env, README_TEST, "test_repo_hygiene.py") + assert moved.returncode == 4, moved.output[-3000:] + assert f"the spec root {spec.resolve()}" in moved.output and \ + "composed acceptance is for the pinned spec leg" in moved.output, ( + moved.output[-3000:]) + assert root.is_dir() + + +@WINDOWS_SKIP +@pytest.mark.skipif(shutil.which("bash") is None, reason="the wrapper is bash") +def test_the_wrapper_passes_a_relative_root_through_as_an_absolute_one(tmp_path): + """`tests/run.sh` `cd`s to its own repository before it runs pytest, so a + RELATIVE root would be read against the code root, not against where the + caller stood. It is made absolute first; an absolute one and the mode pass + as they are. The suite is a stub that records its environment, and `pgrep` + a stub that sees no other run — the real one would see this suite.""" + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + log = tmp_path / "suite.env" + (fakebin / "python3").write_text( + "#!/bin/sh\nenv | grep '^OPENREPOTOOLS_' > \"$FAKE_SUITE_LOG\"\nexit 0\n") + (fakebin / "pgrep").write_text("#!/bin/sh\nexit 1\n") + for name in ("python3", "pgrep"): + (fakebin / name).chmod(0o755) + caller = tmp_path / "caller" + caller.mkdir() + env = {k: v for k, v in os.environ.items() + if not k.startswith(("XDG_", "PYTHON", "OPENREPOTOOLS_")) + and k not in ("PWD", "OLDPWD")} + env.update(PATH=f"{fakebin}{os.pathsep}{os.environ.get('PATH', '')}", + TMPDIR=str(tmp_path), XDG_STATE_HOME=str(tmp_path / "state"), + XDG_CACHE_HOME=str(tmp_path / "cache"), HOME=str(tmp_path / "home"), + FAKE_SUITE_LOG=str(log), + OPENREPOTOOLS_ASSEMBLY_ROOT="asm", + OPENREPOTOOLS_SPEC_ROOT="asm/spec", + OPENREPOTOOLS_CODE_ROOT=str(tmp_path / "elsewhere"), + OPENREPOTOOLS_COMPOSED="1") + proc = subprocess.run([shutil.which("bash") or "bash", str(WRAPPER), "-k", "x"], + cwd=str(caller), env=env, capture_output=True, text=True, + stdin=subprocess.DEVNULL, timeout=120, check=False) + assert proc.returncode == 0, proc.stderr + seen = dict(line.split("=", 1) for line in log.read_text().splitlines()) + assert seen["OPENREPOTOOLS_ASSEMBLY_ROOT"] == f"{caller.resolve()}/asm", seen + assert seen["OPENREPOTOOLS_SPEC_ROOT"] == f"{caller.resolve()}/asm/spec", seen + assert seen["OPENREPOTOOLS_CODE_ROOT"] == str(tmp_path / "elsewhere"), seen + assert seen["OPENREPOTOOLS_COMPOSED"] == "1", seen + + +# --- the resolver, asked directly ------------------------------------------- + +def test_todays_single_tree_is_every_root_at_once(tmp_path): + """Today's layout: the code root carries the spec trees beside the + commands, so it answers for the assembly and the spec leg too, and a + document test reads exactly the file it always read.""" + code = code_leg(tmp_path / "repo", single=True) + roots = conftest.resolve_roots({}, code) + assert roots.refusal() is None, roots.refusal() + assert (roots.layout, roots.assembly, roots.spec) == ("single", None, None) + assert roots.root_of("assembly") == roots.root_of("spec") == roots.code + assert roots.path_for("README.md") == roots.code / "README.md" + assert roots.path_for("docs/README-lanes.md") == roots.code / "docs/README-lanes.md" + assert [role for role, _ in roots.tracked_roots()] == ["code"] + + +def test_a_mounted_code_leg_finds_its_assembly_and_spec_leg(tmp_path): + code = code_leg(tmp_path / "asm" / "code", repository=True) + spec = spec_leg(tmp_path / "asm" / "spec", repository=True) + root = assembly(tmp_path / "asm", code=code, spec=spec, repository=True) + roots = conftest.resolve_roots({}, code) + assert roots.layout == "leg" + assert roots.assembly == root.resolve() and roots.spec == spec.resolve() + assert roots.path_for("README.md") == root.resolve() / "README.md" + assert roots.path_for("AGENTS.md").parent == root.resolve() + assert roots.path_for("docs/README-lanes.md") == spec.resolve() / "docs/README-lanes.md" + assert roots.path_for(".gitattributes") == code.resolve() / ".gitattributes", ( + "the line-ending rule is the code root's: git does not read a " + "superproject's attributes into a submodule") + assert roots.path_for("tests/run.sh") == code.resolve() / "tests/run.sh" + assert [role for role, _ in roots.tracked_roots()] == ["code", "assembly", "spec"] + + +def test_a_directory_that_does_not_mount_the_code_root_is_not_its_assembly(tmp_path): + """An assembly above a code root that it does not record as its code + gitlink is somebody else's: discovery answers no rather than borrow it.""" + root = assembly(tmp_path / "asm") # no repository, no gitlink + code = code_leg(root / "code") + assert conftest.resolve_roots({}, code).assembly is None + + +def test_the_variables_name_the_roots(tmp_path): + code = code_leg(tmp_path / "code") + root = assembly(tmp_path / "elsewhere" / "asm") + spec = spec_leg(tmp_path / "spec") + roots = conftest.resolve_roots({"OPENREPOTOOLS_CODE_ROOT": str(code), + "OPENREPOTOOLS_ASSEMBLY_ROOT": str(root), + "OPENREPOTOOLS_SPEC_ROOT": str(spec)}, + tmp_path / "unused") + assert roots.refusal() is None, roots.refusal() + assert (roots.code, roots.assembly, roots.spec) == ( + code.resolve(), root.resolve(), spec.resolve()) + assert roots.how == {"code": "OPENREPOTOOLS_CODE_ROOT", + "assembly": "OPENREPOTOOLS_ASSEMBLY_ROOT", + "spec": "OPENREPOTOOLS_SPEC_ROOT"} + assert roots.dependency == code.resolve() / "upstream" / "openRepoShape" + + +@pytest.mark.parametrize("variable, value, said", [ + ("OPENREPOTOOLS_COMPOSED", "yes", "is neither 1"), + ("OPENREPOTOOLS_CODE_ROOT", "missing", "is not a code root"), + ("OPENREPOTOOLS_ASSEMBLY_ROOT", "missing", "is not an assembly root"), + ("OPENREPOTOOLS_SPEC_ROOT", "missing", "is not a spec root"), +]) +def test_a_variable_that_names_the_wrong_thing_is_refused_in_either_mode( + tmp_path, variable, value, said): + """A root named by hand that is not that root is a typo, not a layout: + refused before any test, standalone as well as composed.""" + code = code_leg(tmp_path / "repo", single=True) + target = value if variable == "OPENREPOTOOLS_COMPOSED" else str(tmp_path / value) + refusal = conftest.resolve_roots({variable: target}, code).refusal() + assert refusal and said in refusal and variable in refusal, refusal + + +def test_composed_names_every_absent_context(tmp_path): + leg = code_leg(tmp_path / "code", repository=True, dependency="empty") + refusal = conftest.resolve_roots({"OPENREPOTOOLS_COMPOSED": "1"}, leg).refusal() + assert refusal is not None + for context in ("the ASSEMBLY root is absent", "the SPEC root is absent", + "the DEPENDENCY", "is not initialized"): + assert context in refusal, refusal + assert conftest.resolve_roots({}, leg).refusal() is None, ( + "standalone, the same leg is a layout and not a refusal") + + +def test_composed_refuses_a_code_leg_off_its_pin_or_with_changes(tmp_path): + code = code_leg(tmp_path / "asm" / "code", repository=True) + spec = spec_leg(tmp_path / "asm" / "spec", repository=True) + assembly(tmp_path / "asm", code=code, spec=spec, repository=True) + (code / "upstream" / "openRepoShape").mkdir(parents=True, exist_ok=True) + for probe in ("scripts/repo_shape.py", "templates/workspace-root/README.md"): + target = code / "upstream" / "openRepoShape" / probe + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("# a probe\n", encoding="utf-8") + composed = {"OPENREPOTOOLS_COMPOSED": "1"} + assert conftest.resolve_roots(composed, code).refusal() is None, ( + conftest.resolve_roots(composed, code).refusal()) + (code / "park").write_text("#!/usr/bin/env bash\n# edited\n", encoding="utf-8") + refusal = conftest.resolve_roots(composed, code).refusal() + assert refusal and "has changes to tracked files" in refusal, refusal + run_git(code, "commit", "-q", "-a", "-m", "the code leg moves off its pin") + refusal = conftest.resolve_roots(composed, code).refusal() + assert refusal and "composed acceptance is for the pinned code leg" in refusal, refusal + + +def test_this_runs_roots_are_the_ones_it_is_running_with(): + """This session's own roots: coherent, and — composed — complete. The + composed half is the same check `pytest_configure` refused on, held as a + test so a green composed run lists it.""" + roots = conftest.ROOTS + assert roots.refusal() is None, roots.refusal() + assert REPO == roots.code == conftest.CODE_ROOT + for marker in ("openRepoTools", "contracts/openreposhape-pin.yaml"): + assert (roots.code / marker).exists(), marker + if roots.composed: + assert roots.assembly is not None and roots.spec is not None + assert roots.dependency_initialized() diff --git a/tests/test_upstream_pin.py b/tests/test_upstream_pin.py index 504c684e..109dcb8f 100644 --- a/tests/test_upstream_pin.py +++ b/tests/test_upstream_pin.py @@ -7,11 +7,21 @@ `commit:` moves the YAML and no byte of the tree. Either way the next reader is told the tests were verified against a commit they were not. -FOUR OF THE FIVE CHECKS HERE NEED NO SUBMODULE, on purpose. The gitlink is read -out of `git ls-tree HEAD` in three lines of subprocess and nothing in this file -imports anything from `upstream/openRepoShape` — so a clone made without -`--recurse-submodules` still gets the lockstep check. Only the digest needs the -real bytes, and it is the one thing here that carries `NEEDS_UPSTREAM`. +THE CHECKS OF THE PIN ITSELF NEED NO SUBMODULE, on purpose. The gitlink is read +out of `git ls-tree HEAD` in three lines of subprocess and nothing in those +checks imports anything from `upstream/openRepoShape` — so a clone made without +`--recurse-submodules` still gets the lockstep check. Only the three that read +the checkout's real bytes — the digest, its HEAD and the eight files — carry +`NEEDS_UPSTREAM`. + +THE CODE ROOT'S GIT IDENTITY, AND THE DEPENDENCY NESTED IN IT +(opensoft/openRepoTools#186, T009). In the triad the pin file, `.gitmodules` +and the gitlink all move with the CODE leg, which is its own repository +mounted under the assembly — so every read here is of `CODE_ROOT`, `git +ls-tree HEAD` runs IN it (never in an assembly, whose HEAD records the code +leg and not the standard), and `DEPENDENCY_ROOT` is the checkout nested +there. On today's layout `CODE_ROOT` is this repository and nothing moved; a +composed run that lacks the dependency fails rather than skips (conftest). """ from __future__ import annotations @@ -23,9 +33,9 @@ import pytest -from conftest import NEEDS_UPSTREAM, REPO, UPSTREAM +from conftest import CODE_ROOT, DEPENDENCY_ROOT, NEEDS_UPSTREAM -PIN = REPO / "contracts" / "openreposhape-pin.yaml" +PIN = CODE_ROOT / "contracts" / "openreposhape-pin.yaml" SUBMODULE_PATH = "upstream/openRepoShape" SOURCE_REPOSITORY = "opensoft/openRepoShape" SOURCE_URL = "https://github.com/opensoft/openRepoShape.git" @@ -71,6 +81,24 @@ def test_the_pin_records_a_full_lowercase_commit(): f"commit: {commit!r} is not 40 lowercase hex") +def test_the_code_root_is_a_repository_of_its_own(): + """THE GITLINK IS READ FROM THE CODE REPOSITORY'S OWN HEAD, so the code + root must BE that repository's top level: a directory inside some other + checkout would answer `git ls-tree HEAD` with that checkout's tree. In the + triad the code leg is a submodule and its own top level; today it is this + repository's.""" + proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], + cwd=str(CODE_ROOT), capture_output=True, text=True, + check=False) + assert proc.returncode == 0, ( + f"the code root {CODE_ROOT} is not inside a git repository, so it has " + f"no gitlink to agree with {PIN.name}:\n{proc.stderr}") + assert Path(proc.stdout.strip()).resolve() == CODE_ROOT.resolve(), ( + f"the code root {CODE_ROOT} is not the top level of its repository " + f"({proc.stdout.strip()}); the gitlink read below would be that " + f"repository's, not the code leg's") + + def test_the_gitlink_and_the_pin_name_the_same_commit(): """THE LOCKSTEP CHECK, and the reason this file exists. @@ -81,7 +109,7 @@ def test_the_gitlink_and_the_pin_name_the_same_commit(): it bites on a hand-edited `commit:` the same way. """ proc = subprocess.run(["git", "ls-tree", "HEAD", "--", SUBMODULE_PATH], - cwd=str(REPO), capture_output=True, text=True, + cwd=str(CODE_ROOT), capture_output=True, text=True, check=True) row = proc.stdout.strip() assert row, (f"git records no gitlink at {SUBMODULE_PATH}; the pin file " @@ -99,7 +127,7 @@ def test_gitmodules_names_the_same_path_and_repository(): """The third record of the same fact, and the one a `git submodule update` reads. A pin whose `submodule_path:` named a path `.gitmodules` does not would be a pin nothing materializes.""" - text = (REPO / ".gitmodules").read_text(encoding="utf-8") + text = (CODE_ROOT / ".gitmodules").read_text(encoding="utf-8") assert f'[submodule "{SUBMODULE_PATH}"]' in text assert f"path = {SUBMODULE_PATH}" in text assert f"url = {SOURCE_URL}" in text @@ -134,7 +162,7 @@ def test_the_tree_digest_is_the_pinned_commits_own_number(): a bump of the pin that changed the definition would fail HERE, naming the disagreement, instead of producing a number nothing could reproduce. """ - sys.path.insert(0, str(UPSTREAM / "scripts")) + sys.path.insert(0, str(DEPENDENCY_ROOT / "scripts")) try: from repo_shape import TREE_DIGEST_DEFINITION, tree_digest finally: @@ -144,7 +172,7 @@ def test_the_tree_digest_is_the_pinned_commits_own_number(): f"the pin says `digest_definition: {values['digest_definition']}` and " f"the pinned openRepoShape's own repo_shape.py says " f"{TREE_DIGEST_DEFINITION!r}") - computed = tree_digest(UPSTREAM, "HEAD") + computed = tree_digest(DEPENDENCY_ROOT, "HEAD") assert computed == values["tree_sha256"], ( f"the pinned checkout's tree digests to {computed} and {PIN.name} " f"records {values['tree_sha256']}. RECOMPUTE the row from the commit " @@ -156,7 +184,7 @@ def test_the_checked_out_submodule_is_at_the_recorded_commit(): """The fourth record: what is on disk right now. `git ls-tree` above says what a fresh clone gets; this says what THIS working tree has, which is what the rest of the suite actually reads its template bytes out of.""" - head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(UPSTREAM), + head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(DEPENDENCY_ROOT), capture_output=True, text=True, check=True) assert head.stdout.strip() == pin_scalars()["commit"], ( "the checked-out submodule is not at the pinned commit; " @@ -189,5 +217,5 @@ def test_the_pinned_commit_carries_what_this_suite_reads(rel): `park`, the pre-carve witness, leaves this list in THIS commit: the one that bumps the pin past openRepoShape#94, which deleted it from the tree. """ - assert (UPSTREAM / rel).is_file(), ( + assert (DEPENDENCY_ROOT / rel).is_file(), ( f"the pinned openRepoShape has no {rel}") From e3a76b5af6c2e1dd46b7bfffad83849751747b99 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:45:15 +0000 Subject: [PATCH 2/4] Refuse composed acceptance when a leg's git status cannot be read, and keep the code leg's own .gitattributes in the fixture legs (Copilot round 1 on #190). A failed `git status` came back as None and was read as "no changes", so a leg whose index could not be read passed the tracked-changes guarantee. It is now a refusal naming the leg; a new test corrupts a fixture leg's index and asserts it. The fixture code legs dropped .gitattributes with the other assembly files, contradicting the contract that the code leg carries its own line-ending rule; they keep it now. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 5 +++++ tests/test_test_roots.py | 29 +++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 934ce39e..28274597 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -269,6 +269,11 @@ def _leg_at_its_pin(self, role: str, root: Path) -> List[str]: f"assembly root records {recorded} at {mount!r}: composed " f"acceptance is for the pinned {role} leg and no other"] dirty = _git_out(root, "status", "--porcelain", "--untracked-files=no") + if dirty is None: + return [f"the {role} root {root} answered no `git status` (a corrupt " + f"index, a lock, no repository): whether its tracked files " + f"are the pinned bytes cannot be read, and composed " + f"acceptance refuses what it cannot read"] if dirty: return [f"the {role} root {root} has changes to tracked files " f"({dirty.splitlines()[0].strip()} …): composed acceptance is " diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index 6078904e..0dc6f3cb 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -49,10 +49,13 @@ WRAPPER = REPO / "tests" / "run.sh" #: What a code leg does NOT carry after the split, by the adoption mapping's -#: placement table: the six root files went to the assembly and the four -#: trees to the spec leg. A fixture code leg is the code root less these. -ASSEMBLY_FILES = ("README.md", "AGENTS.md", "CLAUDE.md", "LICENSE", - ".gitattributes", ".gitignore") +#: placement table: the root files went to the assembly and the four trees to +#: the spec leg. A fixture code leg is the code root less these. EXCEPT +#: `.gitattributes`, which the table also sends to the assembly and which the +#: code leg must carry too — git never applies the assembly's inside a +#: submodule (conftest's `GUIDANCE_DOCUMENTS` note) — so a fixture leg keeps +#: it, as the leg is meant to be (Copilot on #190). +ASSEMBLY_FILES = ("README.md", "AGENTS.md", "CLAUDE.md", "LICENSE", ".gitignore") SPEC_TREES = ("docs", "openspec", "specs", "ideation") INSTALL_LINE = ( @@ -559,6 +562,24 @@ def test_composed_refuses_a_code_leg_off_its_pin_or_with_changes(tmp_path): assert refusal and "composed acceptance is for the pinned code leg" in refusal, refusal +def test_composed_refuses_a_leg_whose_status_cannot_be_read(tmp_path): + """A `git status` that fails is not a clean one (Copilot on #190): a code + leg at its pin whose index is unreadable is refused, naming it, rather + than accepted because no changes were printed.""" + code = code_leg(tmp_path / "asm" / "code", repository=True) + spec = spec_leg(tmp_path / "asm" / "spec", repository=True) + assembly(tmp_path / "asm", code=code, spec=spec, repository=True) + for probe in ("scripts/repo_shape.py", "templates/workspace-root/README.md"): + target = code / "upstream" / "openRepoShape" / probe + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("# a probe\n", encoding="utf-8") + composed = {"OPENREPOTOOLS_COMPOSED": "1"} + assert conftest.resolve_roots(composed, code).refusal() is None + (code / ".git" / "index").write_bytes(b"not an index") + refusal = conftest.resolve_roots(composed, code).refusal() + assert refusal and "answered no `git status`" in refusal, refusal + + def test_this_runs_roots_are_the_ones_it_is_running_with(): """This session's own roots: coherent, and — composed — complete. The composed half is the same check `pytest_configure` refused on, held as a From dbc6837ff329434e688147e8bae76cd4b3dec79c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:22:46 +0000 Subject: [PATCH 3/4] Hold the assembly's README and AGENTS.md to their own length caps, and fail the lane suite by name when the lane manual is missing (T006's record of 2026-10-08, section 10). In the triad the hygiene caps read the ASSEMBLY's files, which T006's root guidance (assembly-0001) lengthens by 24 lines each: the shape's first-line pointer and triad map in AGENTS.md, the lockstep-invariant section the leg pins name and the three-repository front door in README.md. The caps are 340 and 510 where an assembly root is named or found, in dated entries; today's single repository keeps 316 and 486, so main buys no line of prose. The lane suite's two manual sections now fail naming the path when the manual the roots point at is unreadable, instead of handing the assertions an empty string. Three new tests; all three are red at origin/main (c4864ac). The scoped green run was still queued behind the workstation lock at this checkpoint; CI is the record. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/test_lane_helpers.sh | 6 +++++ tests/test_repo_hygiene.py | 22 ++++++++++++++-- tests/test_test_roots.py | 53 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 2200c8d1..ed523652 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -3744,6 +3744,10 @@ unset FAKE_TMUX_WINDOW_NAME # defect as an untested sentence (RV-B2), one file over. Read from # `$LANE_MANUAL`, the run's own copy, which is the one a person opens. if [ -n "$LANE_MANUAL" ]; then +# A MANUAL THAT IS NOT THERE IS A FAILURE NAMING IT, never an empty string the +# assertions below then misread as a manual that says nothing (#186, T009). +if [ -r "$LANE_MANUAL" ]; then ok "the lane manual is where the run's roots put it" +else bad "the lane manual is where the run's roots put it" "no readable file at $LANE_MANUAL"; fi ss_doc="$(cat "$LANE_MANUAL" 2>/dev/null || :)" has "the README states the subcommand's THIRD safety property, not just two" "$ss_doc" "It never writes, it never touches the network, and it always exits 0" has "…and carries the line every block ends with" "$ss_doc" "as of 4m ago (no fetch)" @@ -6238,6 +6242,8 @@ has "…in ITS arm's own words" "$err" "--estate needs a name" # no assertion had ever read that sentence. Read from `$LANE_MANUAL`, the run's # own copy, which is the one a person opens. if [ -n "$LANE_MANUAL" ]; then +if [ -r "$LANE_MANUAL" ]; then ok "…and still there for the bare-lanes quotes" +else bad "…and still there for the bare-lanes quotes" "no readable file at $LANE_MANUAL"; fi ln_doc="$(cat "$LANE_MANUAL" 2>/dev/null || :)" has "the manual quotes the settlement that narrowed the bare word" "$ln_doc" \ "Narrow inside a checkout (Recommended)" diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 6bbe97dc..407f697e 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -1765,9 +1765,19 @@ def test_agents_md_is_short_enough_to_be_read(): 265, so the merged file is 265 + 27 + #93's eleven (the one-line `awk -v` rule) + #83's thirteen (the ONE BINDING rule): the count of what merged, rather than either side's number — the rule the 224 entry states. + + 316 HERE AND 340 AT AN ASSEMBLY ROOT, on 2026-10-08 (opensoft/openRepoTools + #186, T009). In the triad this test reads the ASSEMBLY's AGENTS.md, and + the root guidance of T006 (`specs/004-migrate-to-triad/ + root-guidance-2026-10-08.md`, patch `assembly-0001`) adds twenty-four lines + to it: the shape's first-line pointer and the triad map the bootstrap + protocol requires a root to carry. The assembly's cap is this count plus + those lines and nothing else; today's single repository keeps 316, so no + line of prose is bought on `main` by a file that is not here yet. """ lines = doc("AGENTS.md").read_text().splitlines() - assert len(lines) <= 316, f"AGENTS.md is {len(lines)} lines; the cap is 316" + cap = 316 if ROOTS.assembly is None else 340 + assert len(lines) <= cap, f"AGENTS.md is {len(lines)} lines; the cap is {cap}" def test_readme_is_short_enough_to_be_read(): @@ -2262,9 +2272,17 @@ def test_readme_is_short_enough_to_be_read(): them is the person's. The paragraph already says why the worktree half of the same rule exists; this is its history half, and a reader of one should not meet the other as a surprise. Every dated entry above stays. + + 486 HERE AND 510 AT AN ASSEMBLY ROOT, on 2026-10-08 (opensoft/openRepoTools + #186, T009), for the reason the AGENTS.md entry of that day gives. The + twenty-four lines `assembly-0001` adds to the assembly's README are the + lockstep-invariant section the leg pins' `resync_runbook:` names + (`README.md#the-lockstep-invariant`) and the three-repository front door. + Today's single repository keeps 486. """ lines = doc("README.md").read_text().splitlines() - assert len(lines) <= 486, f"README.md is {len(lines)} lines; the cap is 486" + cap = 486 if ROOTS.assembly is None else 510 + assert len(lines) <= cap, f"README.md is {len(lines)} lines; the cap is {cap}" #: A host-absolute path baked into a committed file (the estate's Rule 1): diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index 0dc6f3cb..d3818412 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -326,6 +326,59 @@ def test_the_hygiene_suite_reads_the_lane_manual_from_the_spec_root(tmp_path): assert green.outcome(MANUAL_TEST)[0] == "passed", green.output[-3000:] +AGENTS_CAP_TEST = "test_agents_md_is_short_enough_to_be_read" +README_CAP_TEST = "test_readme_is_short_enough_to_be_read" + + +def test_the_length_caps_are_the_assembly_roots_where_one_is_named(tmp_path): + """At an assembly root the two caps are 340 and 510 — this repository's 316 + and 486 plus the twenty-four lines each that T006's root guidance adds + there — and they are asked of the ASSEMBLY's files: one line over FAILS, + the cap itself passes. Red at `origin/main`, which counted this checkout's + own README and AGENTS.md whatever was named.""" + def lines(n: int) -> str: + return "".join(f"line {i}\n" for i in range(n)) + at_cap = assembly(tmp_path / "at", readme=lines(510)) + (at_cap / "AGENTS.md").write_text(lines(340), encoding="utf-8") + over = assembly(tmp_path / "over", readme=lines(511)) + (over / "AGENTS.md").write_text(lines(341), encoding="utf-8") + select = f"{AGENTS_CAP_TEST} or {README_CAP_TEST}" + green = nested(tmp_path, {"OPENREPOTOOLS_ASSEMBLY_ROOT": str(at_cap)}, + select, "test_repo_hygiene.py") + for name in (AGENTS_CAP_TEST, README_CAP_TEST): + assert green.outcome(name)[0] == "passed", green.output[-3000:] + red = nested(tmp_path, {"OPENREPOTOOLS_ASSEMBLY_ROOT": str(over)}, + select, "test_repo_hygiene.py") + for name, said in ((AGENTS_CAP_TEST, "AGENTS.md is 341 lines; the cap is 340"), + (README_CAP_TEST, "README.md is 511 lines; the cap is 510")): + outcome, message = red.outcome(name) + assert outcome == "failure" and said in message, ( + f"{name} {outcome} on an assembly file one line over its cap:\n" + f"{message}\n{red.output[-2000:]}") + + +def test_the_lane_suite_fails_a_missing_manual_by_name(): + """`tests/test_lane_helpers.sh` reads the lane manual where the run's roots + put it, and a manual that is not there FAILS naming the path, before the + `cat … || :` that would otherwise hand the assertions an empty string + (T006's record of 2026-10-08, §10). Held on the text, as this module's + neighbours hold the shell suite's other properties: the suite itself is + forty minutes of bash, and its own run proves the readable branch.""" + lines = (REPO / "tests" / "test_lane_helpers.sh").read_text( + encoding="utf-8").splitlines() + reads = [i for i, line in enumerate(lines) + if "README-lanes.md" not in line and 'cat "$LANE_MANUAL"' in line] + assert len(reads) == 2, f"expected the two manual reads, found {len(reads)}" + assert not any('cat "$SRC_DIR/docs/README-lanes.md"' in l for l in lines), ( + "a manual read still bypasses the run's roots") + for i in reads: + guard = "\n".join(lines[max(0, i - 3):i]) + assert '[ -r "$LANE_MANUAL" ]' in guard and "bad " in guard and \ + "no readable file at $LANE_MANUAL" in guard, ( + f"line {i + 1} reads the manual with no failure for a missing one " + f"in the three lines above it:\n{guard}") + + def test_a_standalone_code_leg_skips_what_only_the_assembly_carries(tmp_path): """A code leg cloned on its own has no README: standalone, the README test SKIPS naming the assembly root, which is the courtesy the missing submodule From b50f1974f3aa4b78b1bcdc5d770e63795d42e4ac Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:29:11 +0000 Subject: [PATCH 4/4] Guard the lane suite's last two manual reads, so a code leg with no spec root skips them instead of aborting the whole suite under set -u. PR #190 binds ln_doc only inside the `if [ -n "$LANE_MANUAL" ]` section that quotes the bare-lanes settlement. The --retire section, 350 lines later, used it unguarded. When there is no spec root, LANE_MANUAL is empty and the suite runs under `set -u`, so it died there with `ln_doc: unbound variable`. That is not the named skip the PR promises. Lane openRepoTools-3's review found it, and this work's own standalone-leg proof (P3) had logged it too. Those two assertions now carry the same guard and skip as the sections that read the manual. A new text test holds every use of the manual's text inside such a guard. It is red at dbc6837, where it names lines 6601 and 6602. Nothing changes on today's layout: the spec root is always there, so the guard is always taken. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/test_lane_helpers.sh | 8 ++++++++ tests/test_test_roots.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index ed523652..53e5d3c1 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -6598,8 +6598,16 @@ hasnt "the listing no longer says the retire act writes a record" "$lanes_text" has "…it says what the act does, which is prove and print" "$lanes_text" "which PROVES the pid or uuid is" hasnt "the helper's parser comment no longer says one writer still writes that line" "$le_text" '`` writes `RETIRED' has "…it names the line as the legacy an append-only log still carries" "$le_text" "NOTHING WRITES ONE ANY MORE" +# The manual's half is guarded like the two sections that read it: `ln_doc` is +# bound only when the run has a spec root, and this suite runs under `set -u`, +# so an unguarded read ABORTS a code leg's run instead of skipping (the #190 +# review by lane openRepoTools-3). +if [ -n "$LANE_MANUAL" ]; then hasnt "the manual no longer promises an Amendment 6(d) record" "$ln_doc" "which writes the Amendment 6(d) record" has "…and says in terms that it writes nothing" "$ln_doc" "**It writes nothing**" +else + skip "the manual says --retire writes nothing (two assertions)" "no spec root in this run: docs/README-lanes.md is the spec leg's (#186, T009)" +fi # A PAUSED LANE WITH NO RECORDED PROFILE GETS NO `restart` LINE — it gets the # form that works, with the profile named as the one token to supply. has "a lane with no recorded profile is offered the launcher form, not a line it cannot type" \ diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index d3818412..9377536d 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -379,6 +379,35 @@ def test_the_lane_suite_fails_a_missing_manual_by_name(): f"in the three lines above it:\n{guard}") +def test_the_lane_suite_reads_the_manual_only_inside_its_guard(): + """Every use of the manual's text in `tests/test_lane_helpers.sh` sits + inside an `if [ -n "$LANE_MANUAL" ]` guard, before that guard's `else` or + `fi`. `ss_doc` and `ln_doc` are bound only there, and the suite runs under + `set -u`. So one use outside a guard does not skip in a code leg with no + spec root: it ABORTS the whole suite with `ln_doc: unbound variable`. + Lane openRepoTools-3's review of #190 reproduced that, and so did this + work's own standalone-leg proof. Held on the text, like its neighbour + above.""" + lines = (REPO / "tests" / "test_lane_helpers.sh").read_text( + encoding="utf-8").splitlines() + guard = 'if [ -n "$LANE_MANUAL" ]; then' + uses = [i for i, line in enumerate(lines) + if ("$ln_doc" in line or "$ss_doc" in line) + and not line.lstrip().startswith("#")] + assert len(uses) >= 16, ( + f"expected the manual's sixteen uses, found {len(uses)}") + unguarded = [] + for i in uses: + j = i - 1 + while j >= 0 and lines[j] not in (guard, "else", "fi"): + j -= 1 + if j < 0 or lines[j] != guard: + unguarded.append(f"{i + 1}: {lines[i].strip()}") + assert not unguarded, ( + "the manual's text is used outside its guard, which aborts a run with " + "no spec root under `set -u`:\n" + "\n".join(unguarded)) + + def test_a_standalone_code_leg_skips_what_only_the_assembly_carries(tmp_path): """A code leg cloned on its own has no README: standalone, the README test SKIPS naming the assembly root, which is the courtesy the missing submodule