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 01/17] 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 02/17] 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 03/17] 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 6e63083cef535acdd6992e7768275f1a887e3d92 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:46:21 +0000 Subject: [PATCH 04/17] Assert each mixed test's code-only claims before its first document read, and document the four roots in AGENTS.md (the #190 review). Five hygiene tests read a command and the documents about it, and three of them (park's header, status's --no-optional-locks, the installer's `lanes [--all] [--fetch]`) asserted the command's half only after a document read, so a standalone code leg skipped those code-only checks along with the documents; the exit-3 table and the wrapper's own lock claims had the same order. Each now asserts the code root's half first; on today's layout every assertion is unchanged. AGENTS.md's "Testing your changes" gains seven lines naming the roots, OPENREPOTOOLS_CODE_ROOT, OPENREPOTOOLS_ASSEMBLY_ROOT, OPENREPOTOOLS_SPEC_ROOT and the composed switch; its cap moves 316 -> 323 here and 340 -> 347 at an assembly root, in a dated entry. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 +++ tests/test_repo_hygiene.py | 102 +++++++++++++++++++++---------------- tests/test_test_roots.py | 8 +-- 3 files changed, 69 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 443880a6..08f9948d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,6 +224,13 @@ git submodule update --init upstream/openRepoShape tests/run.sh # the suite, serialized — pass any pytest argument ``` +**FOUR ROOTS, NOT ONE TREE** (#186, T009): the suite reads each file from the +tree that owns it — the code leg, the assembly root (`README.md`, `AGENTS.md`, +`CLAUDE.md`) and the spec leg (`docs/`), with `upstream/openRepoShape` nested in +the code leg — found from the layout or named by `OPENREPOTOOLS_CODE_ROOT`, +`OPENREPOTOOLS_ASSEMBLY_ROOT` and `OPENREPOTOOLS_SPEC_ROOT`. Standalone, a root +a checkout lacks is SKIPPED by name; `OPENREPOTOOLS_COMPOSED=1` REFUSES instead. + Brett Heap's RULING of 2026-09-16 ("do all three", on the coordinator's three proposals — measured: `tests-macos` running 49-54 minutes per push against Linux's 17-22, pull requests drawing 6-12 Copilot review rounds, and this diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 407f697e..2efdac06 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -972,6 +972,12 @@ 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. """ + # THE COMMAND'S OWN HALF FIRST (#186, T009): in a standalone code leg the + # documents are another root's, and the first one read ends the test. + park = (REPO / "park").read_text(encoding="utf-8") + assert "PARKED EVERY ESTATE unasked" in park, ( + "the `park` header no longer says what #91's bare form did and that " + "it is superseded; a reader of the ruling trail needs both") 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" @@ -990,10 +996,6 @@ def test_the_documents_say_what_bare_park_does_now(): assert "`resume`'s OWN bare form still refuses" in \ doc("AGENTS.md").read_text(encoding="utf-8"), ( "AGENTS.md does not say resume's own bare form still refuses") - park = (REPO / "park").read_text(encoding="utf-8") - assert "PARKED EVERY ESTATE unasked" in park, ( - "the `park` header no longer says what #91's bare form did and that " - "it is superseded; a reader of the ruling trail needs both") def test_the_documents_say_what_status_is_and_is_not(): @@ -1018,6 +1020,10 @@ def test_the_documents_say_what_status_is_and_is_not(): "four" precisely so that a document which grows the install and forgets to say so is a red test. """ + status = (REPO / "status").read_text(encoding="utf-8") + assert "--no-optional-locks" in status, ( + "status takes locks: a read-only command beside an editor must not be " + "the thing that says index.lock exists") for name in code_first("README.md", "AGENTS.md", "status"): text = doc(name).read_text(encoding="utf-8") assert "fetches nothing" in text, ( @@ -1048,10 +1054,6 @@ def test_the_documents_say_what_status_is_and_is_not(): "README.md does not count the thirteen files") assert "13 of 13 placed" in readme, ( "README.md does not show the count line `--install` actually prints") - status = (REPO / "status").read_text(encoding="utf-8") - assert "--no-optional-locks" in status, ( - "status takes locks: a read-only command beside an editor must not be " - "the thing that says index.lock exists") def test_the_documents_say_the_sweep_skips_a_root_without_the_overlay(): @@ -1247,6 +1249,10 @@ def test_the_documents_say_what_a_bare_lanes_lists(): """ surfaces = ("README.md", "docs/README-lanes.md", "lanes", "lane", "openRepoTools") + usage = (REPO / "openRepoTools").read_text(encoding="utf-8") + assert "lanes [--all] [--fetch]" in usage, ( + "`openRepoTools --help` does not offer --all, so a person narrowed " + "into a checkout cannot find the way back to every lane") for name in code_first(*surfaces): text = doc(name).read_text(encoding="utf-8") assert "every lane on this workstation" not in text, ( @@ -1260,10 +1266,6 @@ def test_the_documents_say_what_a_bare_lanes_lists(): assert "next free position" in readme, ( "README.md does not say the narrowed listing ends with the next free " "position, which is the half of the settlement that is not a filter") - usage = (REPO / "openRepoTools").read_text(encoding="utf-8") - assert "lanes [--all] [--fetch]" in usage, ( - "`openRepoTools --help` does not offer --all, so a person narrowed " - "into a checkout cannot find the way back to every lane") manual = doc("docs/README-lanes.md").read_text(encoding="utf-8") assert "Narrow inside a checkout (Recommended)" in manual, ( "the manual does not quote the settlement verbatim") @@ -1774,9 +1776,19 @@ def test_agents_md_is_short_enough_to_be_read(): 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. + + 316 -> 323 HERE AND 340 -> 347 AT AN ASSEMBLY ROOT, on 2026-10-09, for the + same work (#186, T009; the #190 review). Seven lines in "Testing your + changes" — a blank and six — say what an agent running the suite now has to + know and would get wrong unaided: the suite reads each file from the root + that owns it, the three variables that name those roots + (`OPENREPOTOOLS_CODE_ROOT`, `OPENREPOTOOLS_ASSEMBLY_ROOT`, + `OPENREPOTOOLS_SPEC_ROOT`), and that a root a standalone checkout lacks + SKIPS by name while `OPENREPOTOOLS_COMPOSED=1` REFUSES instead. The + assembly's AGENTS.md carries the same seven, so its cap moves with them. """ lines = doc("AGENTS.md").read_text().splitlines() - cap = 316 if ROOTS.assembly is None else 340 + cap = 323 if ROOTS.assembly is None else 347 assert len(lines) <= cap, f"AGENTS.md is {len(lines)} lines; the cap is {cap}" @@ -2439,7 +2451,6 @@ def test_the_exit_3_documentation_agrees_with_the_die_message_it_describes(): start = src.index("# EXIT CODES — every subcommand, one table") end = src.index("# --no-sweep", start) table = src[start:end] - manual = doc("docs/README-lanes.md").read_text(encoding="utf-8") def flatten(text): # A row that wraps onto a comment-continuation line puts a literal # `#` back-to-back with the next word once newlines alone are @@ -2451,25 +2462,14 @@ def flatten(text): lines = (re.sub(r"^#\s*", "", ln) for ln in text.splitlines()) return re.sub(r"\s+", " ", " ".join(lines)).strip() - for name, text in (("lanes-edit.sh's own exit-code table", table), - ("docs/README-lanes.md's copy of it", manual)): - flat = flatten(text) - assert "nothing was pushed" not in flat and "nothing pushed" not in flat, ( + def holds_exit_3(name, whole, rows, attempt): + assert "nothing was pushed" not in whole and "nothing pushed" not in whole, ( f"{name} still claims exit 3 means nothing reached origin, which " "an aborted pull does not prove (#32)") - manual_start = manual.index("### Exit codes") - manual_end = manual.index("3 to 6 are the codes this helper already used", manual_start) - manual_table = manual[manual_start:manual_end] - table_flat = flatten(table) - manual_flat = flatten(manual_table) - assert "not pushed BY THIS ATTEMPT" in table_flat, ( - "lanes-edit.sh's exit-3 row no longer names the attempt-scoped wording") - assert "not pushed by this attempt" in manual_flat, ( - "docs/README-lanes.md's exit-3 row no longer names the attempt-scoped wording") - for name, flat in (("lanes-edit.sh's own exit-code table", table_flat), - ("docs/README-lanes.md's own exit-codes table", manual_flat)): + assert attempt in rows, ( + f"{name}'s exit-3 row no longer names the attempt-scoped wording") for cause in ("timeout", "push", "pull", "peer's"): - assert cause in flat, ( + assert cause in rows, ( f"{name} no longer names a {cause!r} cause, part of exit 3's " "other two causes (#50 rounds 3 and 4, 5203261904 and " "5203455553) — `git_timeout_die` fires for a push OR a pull " @@ -2478,6 +2478,18 @@ def flatten(text): "code with three causes has to name all three or it is back " "to being wrong") + # THE CODE'S TABLE FIRST, THEN THE MANUAL (#186, T009): in a standalone + # code leg the manual is the spec leg's, and the table's half of every + # claim above is asserted before that absence ends the test. + table_flat = flatten(table) + holds_exit_3("lanes-edit.sh's own exit-code table", table_flat, table_flat, + "not pushed BY THIS ATTEMPT") + manual = doc("docs/README-lanes.md").read_text(encoding="utf-8") + manual_start = manual.index("### Exit codes") + manual_end = manual.index("3 to 6 are the codes this helper already used", manual_start) + holds_exit_3("docs/README-lanes.md's copy of it", flatten(manual), + flatten(manual[manual_start:manual_end]), "not pushed by this attempt") + #: ADOPTION ACT 0, AND THE ONE SHA THAT IS IT. `opensoft/brett-wip#5` merged #: 2026-09-13T19:14:37Z, SQUASHED — so the PR's pre-merge head is not an @@ -3126,22 +3138,8 @@ 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 = doc("AGENTS.md").read_text(encoding="utf-8") - lock = '${TMPDIR:-/tmp}/openrepotools-pytest.lock' assert lock in text, f"the wrapper must take {lock}" - assert lock in agents, ( - f"AGENTS.md must name {lock}: every lane on one workstation locks the " - "same file or there is no lock") - assert "tests/run.sh" in agents, ( - "AGENTS.md must name tests/run.sh as the way to run the suite") - # THE BY-HAND FORM LEAVES NOTHING EITHER (#170 E9): the one line a person - # with no checkout runs relocates bytecode and turns pytest's cache off, as - # the wrapper does, or it lands `__pycache__` and `.pytest_cache` beside - # the code the wrapper keeps clean. - by_hand = [ln for ln in agents.splitlines() if lock in ln and "python3 -m pytest" in ln] - assert by_hand and all("-p no:cacheprovider" in ln and "PYTHONPYCACHEPREFIX=" in ln - for ln in by_hand), by_hand assert "'^python3 -m pyt'" in text, ( "the pgrep pattern must be anchored and split so it cannot match its " @@ -3160,3 +3158,19 @@ def test_the_suite_wrapper_takes_one_lock_and_names_it_where_agents_read_it(): "macOS ships no `flock`, so the wrapper needs the `mkdir` lock as its " "fallback — that platform is the one the fallback exists for") assert "python3 -m pytest tests -q" in text + + # AND THEN WHERE AGENTS READ IT — AGENTS.md is the assembly root's in the + # triad (#186, T009), so the wrapper's own half is asserted above first. + agents = doc("AGENTS.md").read_text(encoding="utf-8") + assert lock in agents, ( + f"AGENTS.md must name {lock}: every lane on one workstation locks the " + "same file or there is no lock") + assert "tests/run.sh" in agents, ( + "AGENTS.md must name tests/run.sh as the way to run the suite") + # THE BY-HAND FORM LEAVES NOTHING EITHER (#170 E9): the one line a person + # with no checkout runs relocates bytecode and turns pytest's cache off, as + # the wrapper does, or it lands `__pycache__` and `.pytest_cache` beside + # the code the wrapper keeps clean. + by_hand = [ln for ln in agents.splitlines() if lock in ln and "python3 -m pytest" in ln] + assert by_hand and all("-p no:cacheprovider" in ln and "PYTHONPYCACHEPREFIX=" in ln + for ln in by_hand), by_hand diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index d3818412..7e93cfbe 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -331,7 +331,7 @@ def test_the_hygiene_suite_reads_the_lane_manual_from_the_spec_root(tmp_path): 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 + """At an assembly root the two caps are 347 and 510 — this repository's 323 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 @@ -339,9 +339,9 @@ def test_the_length_caps_are_the_assembly_roots_where_one_is_named(tmp_path): 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") + (at_cap / "AGENTS.md").write_text(lines(347), encoding="utf-8") over = assembly(tmp_path / "over", readme=lines(511)) - (over / "AGENTS.md").write_text(lines(341), encoding="utf-8") + (over / "AGENTS.md").write_text(lines(348), 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") @@ -349,7 +349,7 @@ def lines(n: int) -> str: 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"), + for name, said in ((AGENTS_CAP_TEST, "AGENTS.md is 348 lines; the cap is 347"), (README_CAP_TEST, "README.md is 511 lines; the cap is 510")): outcome, message = red.outcome(name) assert outcome == "failure" and said in message, ( From 2064390d55458fe7bb32a1dbba90d40e5ae4543a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:14:41 +0000 Subject: [PATCH 05/17] Prove with a nested run that a standalone code leg still checks each mixed test's command half, not only by reading the order (#190 review, finding 2). A fixture code leg whose park, status and openRepoTools lost the lines the three mixed hygiene tests assert now FAILS those tests instead of skipping them with the documents. Red at origin/main, which read this checkout's own files whatever was named, and red at dbc6837, where the documents' skip came first. Follow-up to #190, held off its branch while the macOS gate runs. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/test_test_roots.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index 7e93cfbe..0de0a29a 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -401,6 +401,41 @@ def test_a_standalone_code_leg_skips_what_only_the_assembly_carries(tmp_path): assert not composed.outcomes, "a refused session ran tests anyway" +#: The command's half of three mixed hygiene tests, and the line each asserts. +COMMAND_HALVES = ( + ("test_the_documents_say_what_bare_park_does_now", "park", + "PARKED EVERY ESTATE unasked"), + ("test_the_documents_say_what_status_is_and_is_not", "status", + "--no-optional-locks"), + ("test_the_documents_say_what_a_bare_lanes_lists", "openRepoTools", + "lanes [--all] [--fetch]"), +) + + +def test_a_standalone_code_leg_still_checks_each_commands_own_half(tmp_path): + """Where a test reads a command and the documents about it, the command's + half is asserted FIRST, so a standalone code leg — which skips the + documents — still checks the command (the #190 review). A leg whose three + commands lost their lines FAILS those three tests rather than skipping + them with the documents.""" + leg = code_leg(tmp_path / "code") + for _, name, line in COMMAND_HALVES: + path = leg / name + text = path.read_text(encoding="utf-8") + assert line in text, f"{name} no longer carries {line!r}; this test is stale" + path.write_text(text.replace(line, "(removed for the fixture)"), + encoding="utf-8") + run = nested(tmp_path, {"OPENREPOTOOLS_CODE_ROOT": str(leg)}, + " or ".join(test for test, _, _ in COMMAND_HALVES), + "test_repo_hygiene.py") + for test, name, _ in COMMAND_HALVES: + outcome, message = run.outcome(test) + assert outcome == "failure", ( + f"{test} {outcome} on a leg whose {name} lost its line: the " + f"command's half was not asserted before the documents' skip:\n" + f"{message}\n{run.output[-2000:]}") + + 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 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 06/17] 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 From 0c89b9eb50f64ddca6c77419aa2b60eb278cba88 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:36:07 +0000 Subject: [PATCH 07/17] Name the value a refused root was named by in the composed refusal, instead of saying its variable is unset (#193 item 15). When OPENREPOTOOLS_SPEC_ROOT or OPENREPOTOOLS_ASSEMBLY_ROOT named a directory that is not that root, the run was rightly refused. But the composed line for the absent root still said the variable "is unset", or that there was "no" such variable, though the run had set it. Lane openRepoTools-2 found this in the composed-run logs. The resolver now records each named-and-refused root with its value, and why_absent says that variable names that value, which is not that kind of root. The refusal itself is unchanged. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 17 +++++++++++++++-- tests/test_test_roots.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 28274597..c72294cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -177,7 +177,8 @@ class Roots: def __init__(self, code: Path, assembly: Optional[Path], spec: Optional[Path], composed: bool, - how: Dict[str, str], problems: List[str]) -> None: + how: Dict[str, str], problems: List[str], + rejected: Optional[Dict[str, str]] = None) -> None: self.code = code self.assembly = assembly self.spec = spec @@ -185,6 +186,10 @@ def __init__(self, code: Path, assembly: Optional[Path], self.composed = composed self.how = how self.problems = problems + #: A root a variable NAMED and this run refused, by role, with the + #: value as it was given: an absent root that was named is absent for + #: that reason, never because its variable "is unset" (#193 item 15). + self.rejected = dict(rejected or {}) #: `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. @@ -222,6 +227,11 @@ def path_for(self, rel: str) -> Path: return root / rel def why_absent(self, role: str) -> str: + if role in self.rejected: + variable = ENV_ASSEMBLY_ROOT if role == "assembly" else ENV_SPEC_ROOT + kind = "an assembly root" if role == "assembly" else "a spec root" + return (f"{variable} names {self.rejected[role]}, which is not " + f"{kind}, and a named root is never replaced by a found one") if role == "assembly": return (f"no {ENV_ASSEMBLY_ROOT}, and no directory above {self.code} " f"carries {' and '.join(ASSEMBLY_MARKERS)} mounting it") @@ -336,6 +346,7 @@ def resolve_roots(environ: Mapping[str, str], suite_root: Path) -> Roots: a fixture layout as well as about itself.""" problems: List[str] = [] how: Dict[str, str] = {} + rejected: Dict[str, str] = {} composed_value = environ.get(ENV_COMPOSED, "") if composed_value not in ("", "0", "1"): @@ -360,6 +371,7 @@ def resolve_roots(environ: Mapping[str, str], suite_root: Path) -> Roots: if candidate.is_dir() and _carries(candidate, ASSEMBLY_MARKERS): assembly, how["assembly"] = candidate, ENV_ASSEMBLY_ROOT else: + rejected["assembly"] = named problems.append(f"{ENV_ASSEMBLY_ROOT}={named} is not an assembly " f"root: it carries no {' and '.join(ASSEMBLY_MARKERS)}") else: @@ -374,6 +386,7 @@ def resolve_roots(environ: Mapping[str, str], suite_root: Path) -> Roots: if _carries_a_spec_tree(candidate): spec, how["spec"] = candidate, ENV_SPEC_ROOT else: + rejected["spec"] = named 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: @@ -381,7 +394,7 @@ def resolve_roots(environ: Mapping[str, str], suite_root: Path) -> Roots: if _carries_a_spec_tree(candidate): spec, how["spec"] = candidate, "the assembly's spec leg" - return Roots(code, assembly, spec, composed, how, problems) + return Roots(code, assembly, spec, composed, how, problems, rejected) #: THIS RUN'S ROOTS. Module-level names below are its fields, so the modules diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index 3f738886..0a446e84 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -648,6 +648,35 @@ def test_a_variable_that_names_the_wrong_thing_is_refused_in_either_mode( assert refusal and said in refusal and variable in refusal, refusal +@pytest.mark.parametrize("variable, role", [ + ("OPENREPOTOOLS_SPEC_ROOT", "SPEC"), + ("OPENREPOTOOLS_ASSEMBLY_ROOT", "ASSEMBLY"), +]) +def test_composed_names_the_value_a_rejected_root_was_named_by( + tmp_path, variable, role): + """A root that was NAMED and refused is absent because of that value, + and the composed refusal says so: never that its variable "is unset", or + that there is "no" such variable, when the run set it (#193 item 15, lane + openRepoTools-2's reading of the composed-run logs). The refusal itself was + right before; only its words were wrong.""" + 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) + wrong = tmp_path / "not-a-root" + wrong.mkdir() + refusal = conftest.resolve_roots( + {"OPENREPOTOOLS_COMPOSED": "1", variable: str(wrong)}, code).refusal() + assert refusal, "a named root that is not one must be refused" + absent = [part for part in refusal.split("; ") + if part.startswith(f"the {role} root is absent")] + assert absent, refusal + assert f"{variable} names {wrong}" in absent[0], absent[0] + for misnomer in (f"{variable} is unset", f"no {variable}"): + assert misnomer not in refusal, ( + f"the refusal says {misnomer!r} although the run named " + f"{variable}={wrong}:\n{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() From e79bd82f8382908e39b75d3636e039422f1729fc Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:38:09 +0000 Subject: [PATCH 08/17] Refuse a composed run whose leg pin names another commit than the gitlink, and accept only regular files as a root's markers (#193 item 4). Copilot's round 2 on #190 found two gaps. First, with contracts/code-pin.yaml's commit: moved off the gitlink, the resolver accepted the run while scripts/validate-pins.py exits 1 on the same tree. Second, _carries used exists(), so a directory named like a marker passed for a root. The pin's commit: is now compared with the gitlink before the leg's HEAD is, and the markers must be files. The tree digest stays the validator's job. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 22 +++++++++++++++++++- tests/test_test_roots.py | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index c72294cb..fc538694 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -143,7 +143,12 @@ def _scalar(path: Path, key: str) -> Optional[str]: def _carries(root: Path, names) -> bool: - return all((root / name).exists() for name in names) + """Every marker is a REGULAR FILE under `root`. Each marker is one (a + command, a pin, a manifest), so a directory spelled like one is not it: + with `exists()`, a tree holding a directory named `openRepoTools` or + `project.yaml` passed for a root and failed later in unrelated tests + (Copilot on #190, #193 item 4).""" + return all((root / name).is_file() for name in names) def _carries_a_spec_tree(root: Path) -> bool: @@ -273,6 +278,21 @@ def _leg_at_its_pin(self, role: str, root: Path) -> List[str]: if recorded is None: return [f"the assembly root {self.assembly} records no gitlink at " f"{mount!r}, which contracts/{role}-pin.yaml names"] + # THE PIN AND THE GITLINK ARE ONE INVARIANT (the lockstep rule + # `scripts/validate-pins.py` holds). A pin naming another commit than + # the gitlink is drift even where the gitlink and the leg's HEAD agree, + # so it is refused here too and not only by the validator the assembly + # job runs first (Copilot on #190, #193 item 4). The tree digest stays + # the validator's: it needs the leg's object store, not this check. + pinned = (_scalar(self.assembly / "contracts" / f"{role}-pin.yaml", + "commit") or "").lower() + if pinned != recorded: + return [f"contracts/{role}-pin.yaml pins {pinned or 'no commit'}, " + f"and the assembly root {self.assembly} records {recorded} " + f"at {mount!r}: the pin and the gitlink are one invariant " + f"(scripts/validate-pins.py calls this pin-gitlink-mismatch), " + f"and composed acceptance refuses a pin that names another " + f"commit"] head = _git_out(root, "rev-parse", "HEAD") if head != recorded: return [f"the {role} root {root} is at {head or 'no commit'}, and the " diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index 0a446e84..3c888dd3 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -726,6 +726,49 @@ def test_composed_refuses_a_leg_whose_status_cannot_be_read(tmp_path): assert refusal and "answered no `git status`" in refusal, refusal +def test_composed_refuses_a_pin_that_names_another_commit_than_the_gitlink(tmp_path): + """The leg pin's `commit:` and the gitlink are one invariant. A pin moved + off the gitlink, with the gitlink and the leg's HEAD still agreeing, was + accepted (`refusal()` was None) while `scripts/validate-pins.py` exits 1 on + the same tree (Copilot round 2 on #190, #193 item 4). Refused now, naming + the pin, the gitlink and the mount.""" + 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) + 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()) + pin = root / "contracts" / "code-pin.yaml" + head = run_git(code, "rev-parse", "HEAD") + pin.write_text(pin.read_text(encoding="utf-8").replace(head, "1" * 40), + encoding="utf-8") + refusal = conftest.resolve_roots(composed, code).refusal() + assert refusal and "contracts/code-pin.yaml pins " + "1" * 40 in refusal, refusal + assert f"records {head}" in refusal and "one invariant" in refusal, refusal + + +@pytest.mark.parametrize("variable, markers", [ + ("OPENREPOTOOLS_CODE_ROOT", ("openRepoTools", "contracts/openreposhape-pin.yaml")), + ("OPENREPOTOOLS_ASSEMBLY_ROOT", ("contracts/code-pin.yaml", "project.yaml")), +]) +def test_a_directory_spelled_like_a_marker_is_not_one(tmp_path, variable, markers): + """A root's markers are files. A tree whose `openRepoTools` or + `project.yaml` is a DIRECTORY is not that root, and naming it is refused + like any other wrong tree; with `exists()` it passed (#193 item 4).""" + code = code_leg(tmp_path / "repo", single=True) + fake = tmp_path / "fake" + for marker in markers: + (fake / marker).mkdir(parents=True) + refusal = conftest.resolve_roots({variable: str(fake)}, code).refusal() + assert refusal and variable in refusal and "is not a" in refusal, ( + f"{variable} naming a tree of marker-named directories was accepted: " + f"{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 14210a87c32707646a2ea15812ec57118093f621 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:00:24 +0000 Subject: [PATCH 09/17] Find the assembly from a paired feature worktree, /worktrees//code, and read that feature's own spec worktree (#193 item 5). The triad's feature work happens in paired worktrees of each leg under the assembly's worktrees/ directory. That is how T006's root guidance and the code leg's own AGENTS.md lay it out. Discovery only knew the mount, so a code root there found no assembly, and after Gate C every document test would skip in a feature worktree. Lane openRepoTools-3's review of #190 measured this on run C. Discovery still stops at the nearest ancestor that carries the assembly's markers, and it accepts only two places for the code root. One is the mount. The other is worktrees//, and only when that is a repository of its own whose object store holds the commit the assembly pins. The feature's own spec worktree beside it is the spec root when it is there; otherwise the assembly's mounted spec leg is. Composed acceptance is unchanged: a feature off the pin is refused. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 56 ++++++++++++++++++++++++++++------ tests/test_test_roots.py | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index fc538694..2f1d4780 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -88,6 +88,13 @@ #: `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" + +#: THE PAIRED LAYOUT. A feature is worked in `/worktrees//`, +#: a worktree of each leg named like that leg's mount (`code/`, `spec/`) and +#: never committed in the assembly (T006's root guidance and the code leg's own +#: AGENTS.md). A code root there belongs to that assembly as surely as the +#: mounted one does, so it is found there too (#193 item 5). +PAIRED_WORKTREES = "worktrees" DEPENDENCY_PROBE = "scripts/repo_shape.py" DEPENDENCY_PROBES = (DEPENDENCY_PROBE, "templates/workspace-root/README.md") @@ -161,17 +168,38 @@ def _mount(assembly: Path, role: str) -> str: "submodule_path") or role +def _paired_with(assembly: Path, code: Path, recorded: str) -> bool: + """Whether `code` is a paired feature worktree of `assembly`'s code leg: + `/worktrees//`, a repository + of its own, whose object store holds the commit the assembly pins. That + last check is what keeps a checkout that merely sits there from being + taken for the leg.""" + mount = _mount(assembly, "code") + if not (_same(code.parent.parent, assembly / PAIRED_WORKTREES) + and code.name == Path(mount).name): + return False + top = _git_out(code, "rev-parse", "--show-toplevel") + return bool(top and _same(Path(top), code)) and _git_out( + code, "cat-file", "-e", f"{recorded}^{{commit}}") is not None + + 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: + manifest, is its repository's top level, records a gitlink where its code + pin names, AND holds `code` in one of the two designed places: mounted at + that gitlink, or as a paired feature worktree + (`/worktrees//code`). Anything else is None, which is + today's layout and a standalone code clone alike.""" + code = code.resolve() + for candidate in code.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)): + recorded = _gitlink(candidate, mount) + if not (top and _same(Path(top), candidate) and recorded): + return None + if _same(candidate / mount, code) or _paired_with(candidate, code, recorded): return candidate return None return None @@ -397,7 +425,10 @@ def resolve_roots(environ: Mapping[str, str], suite_root: Path) -> Roots: else: assembly = discover_assembly(code) if assembly is not None: - how["assembly"] = "found above the code root, which it mounts" + how["assembly"] = ( + "found above the code root, which it mounts" + if _same(assembly / _mount(assembly, "code"), code) else + "found above the code root, a paired feature worktree of its code leg") spec: Optional[Path] = None named = environ.get(ENV_SPEC_ROOT, "") @@ -410,9 +441,16 @@ def resolve_roots(environ: Mapping[str, str], suite_root: Path) -> Roots: 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" + # A paired feature's own spec worktree, beside its code worktree, is + # that feature's spec root; the assembly's mounted spec leg otherwise. + paired = code.parent / Path(_mount(assembly, "spec")).name + if (_same(code.parent.parent, assembly / PAIRED_WORKTREES) + and _carries_a_spec_tree(paired)): + spec, how["spec"] = paired, "the paired spec worktree beside the code root" + else: + 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, rejected) diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index 3c888dd3..535a5c65 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -615,6 +615,72 @@ def test_a_directory_that_does_not_mount_the_code_root_is_not_its_assembly(tmp_p assert conftest.resolve_roots({}, code).assembly is None +def paired_triad(tmp_path: Path, *, spec_worktree: bool = True): + """An assembly with both legs mounted, and a feature's paired worktrees + `worktrees/001-feature/{code,spec}` made from those legs, as T006's root + guidance and the code leg's AGENTS.md lay them out.""" + 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) + feature = root / "worktrees" / "001-feature" + run_git(code, "worktree", "add", "-q", "-b", "001-feature", + str(feature / "code")) + if spec_worktree: + run_git(spec, "worktree", "add", "-q", "-b", "001-feature", + str(feature / "spec")) + return root, feature + + +def test_a_paired_feature_worktree_finds_its_assembly_and_its_own_spec(tmp_path): + """The paired layout: a feature's code worktree at + `/worktrees//code` reads the assembly's README and its + own spec worktree's manual. Discovery returned None there, so every + document test would skip in a feature worktree after Gate C (lane + openRepoTools-3's review of #190, #193 item 5).""" + root, feature = paired_triad(tmp_path) + roots = conftest.resolve_roots({}, feature / "code") + assert roots.refusal() is None, roots.refusal() + assert roots.assembly == root.resolve(), roots.describe() + assert roots.spec == (feature / "spec").resolve(), roots.describe() + assert roots.path_for("README.md") == root.resolve() / "README.md" + assert roots.path_for("docs/README-lanes.md") == ( + feature / "spec" / "docs" / "README-lanes.md").resolve() + assert "paired feature worktree" in roots.how["assembly"], roots.how + # Without a spec worktree of its own, the feature reads the mounted one. + root2, feature2 = paired_triad(tmp_path / "two", spec_worktree=False) + roots2 = conftest.resolve_roots({}, feature2 / "code") + assert roots2.spec == (root2 / "spec").resolve(), roots2.describe() + + +def test_only_the_two_designed_places_are_an_assemblys_code(tmp_path): + """Discovery stays bounded to the mount and the paired worktree: a + repository that is not the code leg's, at the paired path, is not found, + and neither is the code leg's own worktree anywhere but `worktrees/`.""" + root, feature = paired_triad(tmp_path) + # A repository of its own with a history of its own: built from the same + # files, but never holding the commit the assembly pins (a fixture leg + # committed in the same second with the same tree would BE that commit). + stranger = code_leg(root / "worktrees" / "002-stranger" / "code") + (stranger / "STRANGER").write_text("not the code leg\n", encoding="utf-8") + run_git(stranger, "init", "-q") + run_git(stranger, "add", "-A") + run_git(stranger, "commit", "-q", "-m", "a history of its own") + assert conftest.resolve_roots({}, stranger).assembly is None, ( + "a checkout that does not hold the pinned commit was taken for the leg") + elsewhere = root / "elsewhere" / "001-feature" / "code" + run_git(root / "code", "worktree", "add", "-q", "-b", "elsewhere", + str(elsewhere)) + assert conftest.resolve_roots({}, elsewhere).assembly is None, ( + "a worktree outside worktrees/ was taken for a paired one") + # Found is not accepted: a feature's commits are not the pinned leg, and + # a composed run there is refused for that, naming it. + run_git(feature / "code", "commit", "-q", "--allow-empty", "-m", "feature work") + composed = conftest.resolve_roots({"OPENREPOTOOLS_COMPOSED": "1"}, + feature / "code").refusal() + assert composed and "composed acceptance is for the pinned code leg" in composed, ( + composed) + + def test_the_variables_name_the_roots(tmp_path): code = code_leg(tmp_path / "code") root = assembly(tmp_path / "elsewhere" / "asm") From dfe5b7e7430ae5f23c21e6fcee2f6d53de62b6f7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:01:49 +0000 Subject: [PATCH 10/17] Say the roots a redirected run used at its foot, where -q keeps them, and print nothing new on today's layout (#193 item 6). OPENREPOTOOLS_CODE_ROOT re-points REPO and UPSTREAM, and a named assembly root decides which README, AGENTS.md and length caps the suite holds. Only the report header showed this, and -q drops the header. Every CI job and tests/run.sh run -q, so a run pointed at another tree said nothing about it. Lane openRepoTools-3's review of #190 raised this. pytest_terminal_summary now repeats ROOTS.describe() whenever the roots are anything but today's one tree taken as it stands: a variable named a root, an assembly was found, the code root is a leg, or the run is composed. A run on today's layout with no variable prints exactly what it printed before. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 21 +++++++++++++++++++++ tests/test_test_roots.py | 20 ++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 2f1d4780..cf89a145 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -370,6 +370,12 @@ def refusal(self) -> Optional[str]: f"never skips its way to acceptance: ") return head + "; ".join(lines) + def redirected(self) -> bool: + """Whether this run's roots are anything but today's one tree taken as + it stands: a root named by a variable, an assembly found, a leg, or the + composed mode.""" + return bool(self.how) or self.layout != "single" or self.composed + def describe(self) -> List[str]: """The roots, one line each, for the report header.""" lines = [f"openRepoTools test roots: " @@ -486,6 +492,21 @@ def pytest_configure(config): def pytest_report_header(config): return ROOTS.describe() + +def pytest_terminal_summary(terminalreporter, exitstatus, config): + """THE ROOTS A RUN USED, WHERE `-q` CANNOT HIDE THEM (#193 item 6). + + `OPENREPOTOOLS_CODE_ROOT` re-points `REPO` and `UPSTREAM`, and + `OPENREPOTOOLS_ASSEMBLY_ROOT` decides which README, AGENTS.md and length + caps are held. The report header says so, but `-q` drops the header, and + every CI job and `tests/run.sh` run `-q`. So a run whose roots are not + today's one tree says them again at its foot, which `-q` keeps. Today's + layout with no variable prints nothing here: its output is unchanged.""" + if ROOTS.redirected(): + terminalreporter.write_sep("-", "openRepoTools test roots") + for line in ROOTS.describe(): + terminalreporter.write_line(line) + #: AMENDMENT 14 — NO TEST NUDGES AN INSTALLED INDEXER BY ACCIDENT. Every #: `lanes-edit.sh` write that pushes starts a detached `lanes-index sync` #: wherever one is on PATH, and several suites here run with the workstation's diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index 535a5c65..25bd7a3a 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -408,6 +408,26 @@ def test_the_lane_suite_reads_the_manual_only_inside_its_guard(): "no spec root under `set -u`:\n" + "\n".join(unguarded)) +def test_a_redirected_run_names_its_roots_where_quiet_output_keeps_them(tmp_path): + """A run whose roots a variable named says them at its foot, under the + `-q` every CI job and `tests/run.sh` use. The report header that said them + is dropped by `-q`, so a run pointed at another tree was silent about it + (lane openRepoTools-3's review of #190, #193 item 6). A run on today's + layout with no variable prints nothing new.""" + root = assembly(tmp_path / "asm") + named = nested(tmp_path, {"OPENREPOTOOLS_ASSEMBLY_ROOT": str(root)}, + README_TEST, "test_repo_hygiene.py") + assert named.outcome(README_TEST)[0] == "passed", named.output[-2000:] + assert "openRepoTools test roots" in named.output, named.output[-2000:] + assert f"assembly: {root.resolve()} (OPENREPOTOOLS_ASSEMBLY_ROOT)" in named.output, ( + named.output[-2000:]) + plain = nested(tmp_path, {}, README_TEST, "test_repo_hygiene.py") + if conftest.ROOTS.layout == "single" and not conftest.ROOTS.how: + assert "openRepoTools test roots" not in plain.output, ( + "today's layout, with no variable, printed the roots section:\n" + + plain.output[-2000:]) + + 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 43ee05bb2ce467d346e8751fadb66f87a21869e0 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:04:26 +0000 Subject: [PATCH 11/17] Run the lane suite's manual sections with a missing, an absent and a present manual, and read their guard by following the if/fi nesting (#193 items 3 and 7). Item 3. The missing-manual branch was only checked by its text. The suite's three $LANE_MANUAL sections are now cut out of it and run under its own set -uo pipefail with its own one-line helpers. A manual that is not there must FAIL, naming the path, in both sections that check it. No manual must give three named skips and reach the end. The run's own manual must pass every quote. Item 7, the review of b50f197, point 6. The guard test found the guard from the nearest line spelled exactly `fi` or `else`. So it missed a use spelled ${ln_doc} and a closing fi that was indented. It now follows the if/elif/else/fi nesting, skipping here-documents, and requires that it ends with no frame open. Planted cases hold both misses and two cases it must keep right. Item 7 also: the GIT_ENV comment no longer claims a file-transport allowance that nothing sets. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/test_test_roots.py | 263 +++++++++++++++++++++++++++++++++++---- 1 file changed, 236 insertions(+), 27 deletions(-) diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index 25bd7a3a..ca375281 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -33,6 +33,7 @@ import hashlib import os +import re import shutil import subprocess import sys @@ -102,8 +103,8 @@ path: code """ -#: Git in a fixture: an identity of its own, and file transport allowed for -#: the one local clone of the dependency. +#: Git in a fixture: an identity of its own. The one clone of the dependency +#: is a local-path clone, which needs no `protocol.file.allow` (#193 item 7). GIT_ENV = {"GIT_AUTHOR_NAME": "openRepoTools CI", "GIT_AUTHOR_EMAIL": "ci@openrepotools.invalid", "GIT_COMMITTER_NAME": "openRepoTools CI", @@ -379,33 +380,241 @@ def test_the_lane_suite_fails_a_missing_manual_by_name(): f"in the three lines above it:\n{guard}") +# --- the lane suite's manual sections, read and run --------------------------- + +SUITE = REPO / "tests" / "test_lane_helpers.sh" +MANUAL_GUARD = re.compile(r'^if \[ -n "\$LANE_MANUAL" \]$') +MANUAL_USE = re.compile(r'\$\{?(?:ln_doc|ss_doc)\b') +HEREDOC = re.compile(r"""<<(-?)\s*\\?(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2""") + + +def shell_commands(line: str) -> list: + """`line` cut at each `;` outside quotes, stopping at a `#` comment: a + reading of the suite's one-line `if …; then …` / `else …; fi` forms that + is enough to follow their nesting, and no more than that.""" + out, cur, quote, i = [], [], None, 0 + while i < len(line): + ch = line[i] + if quote: + cur.append(ch) + if ch == "\\" and quote == '"' and i + 1 < len(line): + cur.append(line[i + 1]) + i += 2 + continue + if ch == quote: + quote = None + elif ch in "\"'": + quote = ch + cur.append(ch) + elif ch == "\\" and i + 1 < len(line): + cur.append(line[i:i + 2]) + i += 2 + continue + elif ch == "#" and (not cur or "".join(cur)[-1:].isspace()): + break + elif ch == ";": + out.append("".join(cur).strip()) + cur = [] + else: + cur.append(ch) + i += 1 + out.append("".join(cur).strip()) + return [c for c in out if c] + + +def heredoc_in(line: str): + """The first here-document `line` opens outside quotes, as (dash, word): + its body is not shell this reading follows (the suite writes fakes and + Python through them).""" + quote, i = None, 0 + while i < len(line): + ch = line[i] + if quote: + if ch == "\\" and quote == '"': + i += 2 + continue + if ch == quote: + quote = None + elif ch in "\"'": + quote = ch + elif ch == "\\": + i += 2 + continue + elif ch == "#" and (i == 0 or line[i - 1].isspace()): + return None + elif line.startswith("<<", i) and not line.startswith("<<<", i): + found = HEREDOC.match(line, i) + return (found.group(1) == "-", found.group(3)) if found else None + i += 1 + return None + + +def manual_reads(lines: list): + """Follow the suite's `if`/`elif`/`else`/`fi` nesting, here-documents + skipped. Returns each use of the manual's text (`$ss_doc`, `$ln_doc`, in + either spelling) with whether the THEN branch of an + `if [ -n "$LANE_MANUAL" ]` holds it; each such section's first and last + line; and the frames still open at the end, which must be none for the + reading to be trusted.""" + stack, uses, blocks, heredoc = [], [], [], None + for n, raw in enumerate(lines): + if heredoc is not None: + if (raw.lstrip("\t") if heredoc[0] else raw) == heredoc[1]: + heredoc = None + continue + if raw.lstrip().startswith("#"): + continue + opened = heredoc_in(raw) + for cmd in shell_commands(raw): + word, _, rest = cmd.partition(" ") + rest = rest.strip() + if word in ("then", "do") and rest: + cmd = rest + word, _, rest = cmd.partition(" ") + rest = rest.strip() + if word == "if": + stack.append([bool(MANUAL_GUARD.match(cmd)), False, n]) + continue + if word in ("elif", "else"): + if stack: + stack[-1][1] = True + if word == "elif" or not rest: + continue + cmd = rest + if word == "fi": + if stack: + frame = stack.pop() + if frame[0]: + blocks.append((frame[2], n)) + continue + if MANUAL_USE.search(cmd): + uses.append((n, any(f[0] and not f[1] for f in stack))) + if opened: + heredoc = opened + return uses, blocks, stack + + 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()}") + """Every use of the manual's text in `tests/test_lane_helpers.sh` sits in + the THEN branch of an `if [ -n "$LANE_MANUAL" ]` guard. `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; fixed in `b50f197`). + + The nesting is FOLLOWED, not guessed from the nearest line spelled `fi`: + the first reading missed a `${ln_doc}` and an indented closing `fi` (the + review of `b50f197`, point 6; #193 item 7). The reading planted with both + is held by the test below.""" + uses, blocks, still_open = manual_reads( + SUITE.read_text(encoding="utf-8").splitlines()) + assert not still_open, ( + f"the suite's if/fi nesting could not be followed past line " + f"{still_open[0][2] + 1}, so where the manual is read cannot be vouched for") + assert len(uses) >= 16 and len(blocks) >= 3, ( + f"expected the manual's sixteen uses in three guarded sections, found " + f"{len(uses)} in {len(blocks)}") + unguarded = [n + 1 for n, guarded in uses if not guarded] 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)) + f"the manual's text is used outside its guard at line(s) {unguarded}, " + f"which aborts a run with no spec root under `set -u`") + + +@pytest.mark.parametrize("text, unguarded", [ + pytest.param( + 'if [ -n "$LANE_MANUAL" ]; then\n' + 'ln_doc="$(cat "$LANE_MANUAL" 2>/dev/null || :)"\n' + 'has "a" "$ln_doc" "x"\n' + 'fi\n' + 'has "b" "${ln_doc}" "y"\n', [5], id="braced-use-after-the-guard"), + pytest.param( + 'if [ -n "$LANE_MANUAL" ]; then\n' + ' ln_doc="$(cat "$LANE_MANUAL" 2>/dev/null || :)"\n' + ' fi\n' + 'has "c" "$ln_doc" "z"\n', [4], id="indented-closing-fi"), + pytest.param( + 'if [ -n "$LANE_MANUAL" ]; then\n' + 'ss_doc="$(cat "$LANE_MANUAL")"\n' + 'else\n' + ' has "d" "$ss_doc" "w"\n' + 'fi\n', [4], id="use-in-the-guards-else"), + pytest.param( + 'if [ -n "$LANE_MANUAL" ]; then\n' + 'if [ -r "$LANE_MANUAL" ]; then ok "r"\n' + 'else bad "r" "no readable file"; fi\n' + 'cat < str: + """The suite's own one-line assertion helpers, verbatim, and a stand-in + for the `excerpt` its `has` calls.""" + wanted = re.compile(r"^(ok|bad|skip|has|hasnt)\(\) *\{") + found = [line for line in SUITE.read_text(encoding="utf-8").splitlines() + if wanted.match(line)] + assert len(found) == 5, f"expected the suite's five helpers, found {found}" + return "\n".join(found + ["excerpt() { printf '%.200s' \"$1\"; }"]) + + +@WINDOWS_SKIP +@pytest.mark.skipif(shutil.which("bash") is None, reason="the suite is bash") +def test_the_lane_suites_manual_sections_fail_skip_or_pass_by_name(tmp_path): + """THE MISSING-MANUAL BRANCH, RUN (#193 item 3; until now only its text + was checked). The suite's three `$LANE_MANUAL` sections, cut out of it by + the reading above and run under its own `set -uo pipefail` and its own + helpers: + + * a manual that is NOT THERE is a FAIL naming the path, in each section + that reads it — never an empty string the quotes then misread; + * NO manual (no spec root) is three named skips, and the run reaches + its end — never `unbound variable`; + * the run's own manual, where it has one, passes every quote.""" + lines = SUITE.read_text(encoding="utf-8").splitlines() + _uses, blocks, _open = manual_reads(lines) + body = "\n".join("\n".join(lines[a:b + 1]) for a, b in blocks) + script = tmp_path / "manual-sections.sh" + script.write_text( + "set -uo pipefail\npass=0; fail=0; skipped=0\n" + suite_helpers() + + '\nLANE_MANUAL="$MANUAL_UNDER_TEST"\n' + body + + '\nprintf "END %s passed, %s failed, %s skipped\\n" ' + '"$pass" "$fail" "$skipped"\n', encoding="utf-8") + + def run(manual: str) -> str: + env = dict(os.environ, MANUAL_UNDER_TEST=manual) + proc = subprocess.run([shutil.which("bash") or "bash", str(script)], + env=env, capture_output=True, text=True, + timeout=60, check=False) + out = proc.stdout + proc.stderr + assert proc.returncode == 0 and "\nEND " in "\n" + out, ( + f"the manual sections did not run to their end with " + f"LANE_MANUAL={manual!r}:\n{out}") + assert "unbound variable" not in out, out + return out + + missing = tmp_path / "no-spec-leg" / "docs" / "README-lanes.md" + out = run(str(missing)) + named = out.count(f"no readable file at {missing}") + assert named == 2 and "\nFAIL " in out, ( + f"a missing manual must FAIL naming {missing} in both sections that " + f"check it; it was named {named} time(s):\n{out}") + out = run("") + assert "END 0 passed, 0 failed, 3 skipped" in out, out + assert out.count("no spec root in this run") == 3, out + manual = conftest.ROOTS.root_of("spec") + if manual is not None: + out = run(str(manual / "docs" / "README-lanes.md")) + assert " 0 failed, 0 skipped" in out, out + def test_a_redirected_run_names_its_roots_where_quiet_output_keeps_them(tmp_path): From 4266b7e2ae7b81242680362a82ba939a583b637e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:05:28 +0000 Subject: [PATCH 12/17] Read a quoted ~ in a root variable as the home directory in tests/run.sh, as conftest does, and keep LICENSE and .gitignore as the code leg's own (#193 item 7). tests/run.sh made a relative root absolute against the caller's directory, so a quoted ~/x reached pytest as $PWD/~/x. conftest's expanduser reads the same value as the home directory. The wrapper now expands ~ and ~/... to $HOME, and its test covers both forms. Roots.owner_of gives LICENSE and .gitignore to the code root. That stays, now with its reason. T006's code-0001 gives the code leg its own copy of each, and the leg's copy governs the leg's files, as with .gitattributes. Fixture code legs keep both, and the mounted-leg test holds where they are read from. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 4 ++++ tests/run.sh | 7 ++++++- tests/test_test_roots.py | 20 ++++++++++++++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index cf89a145..9683bb46 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,6 +67,10 @@ #: 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. +#: `LICENSE` and `.gitignore` are the code root's for the same kind of reason: +#: the placement table puts them at the assembly, but T006's `code-0001` gives +#: the code leg its own copy of each, and the leg's copy is the one that governs +#: the leg's files (#193 item 7). No test reads either today. GUIDANCE_DOCUMENTS = ("README.md", "AGENTS.md", "CLAUDE.md") SPEC_TREES = ("docs", "openspec", "specs", "ideation") diff --git a/tests/run.sh b/tests/run.sh index 585a343d..481e6995 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -79,9 +79,14 @@ prog="${0##*/}" # 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 +# A QUOTED `~` or `~/…` reaches here unexpanded, and conftest reads it as the +# home directory (`expanduser`); read here as relative it became `$PWD/~/…`, +# a directory nobody meant, so it is the home directory here too (#193 item 7). +abs_root() { # - absolute against the caller's directory; ~ is $HOME case "$1" in /*) printf '%s\n' "$1" ;; + '~') printf '%s\n' "$HOME" ;; + '~/'*) printf '%s/%s\n' "$HOME" "${1#??}" ;; *) printf '%s/%s\n' "$PWD" "$1" ;; esac } diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index ca375281..afcebdff 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -55,8 +55,10 @@ #: `.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") +#: it, as the leg is meant to be (Copilot on #190). `LICENSE` and `.gitignore` +#: likewise: T006's `code-0001` gives the code leg its own copy of each, so a +#: fixture leg keeps them too (#193 item 7). +ASSEMBLY_FILES = ("README.md", "AGENTS.md", "CLAUDE.md") SPEC_TREES = ("docs", "openspec", "specs", "ideation") INSTALL_LINE = ( @@ -801,6 +803,16 @@ def test_the_wrapper_passes_a_relative_root_through_as_an_absolute_one(tmp_path) 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 + # A QUOTED `~` is the home directory, as conftest's `expanduser` reads it, + # never a directory named `~` under the caller (#193 item 7). + env.update(OPENREPOTOOLS_SPEC_ROOT="~/spec", OPENREPOTOOLS_ASSEMBLY_ROOT="~") + 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_SPEC_ROOT"] == f"{tmp_path / 'home'}/spec", seen + assert seen["OPENREPOTOOLS_ASSEMBLY_ROOT"] == str(tmp_path / "home"), seen # --- the resolver, asked directly ------------------------------------------- @@ -833,6 +845,10 @@ def test_a_mounted_code_leg_finds_its_assembly_and_spec_leg(tmp_path): "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" + for own in ("LICENSE", ".gitignore"): + assert roots.path_for(own) == code.resolve() / own, ( + f"{own} is the code root's: the code leg carries its own (T006's " + f"code-0001), and it is the copy that governs the leg's files") assert [role for role, _ in roots.tracked_roots()] == ["code", "assembly", "spec"] From c7b27bca58fa1c262a3b43a95cc759842b814cf4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:06:47 +0000 Subject: [PATCH 13/17] Move the four-roots paragraph to the foot of AGENTS.md's testing section, below every line T006's assembly-0001 rewrites, so that patch still applies (#193 item 2). 6e63083 put the seven lines directly under the test block. assembly-0001 rewrites that block to re-point it at code/, so the patch stopped applying (patch failed: AGENTS.md:211; lane openRepoTools-3 saw :224 on the candidate). The cap of 347 at an assembly root then rested on a premise no patch met. At the foot of the section, the patch applies to this file and gives 347 lines, which is the cap. The text is unchanged except that it now names OPENREPOTOOLS_COMPOSED=1, and the cap entry says where the lines sit and why. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 14 +++++++------- tests/test_repo_hygiene.py | 15 ++++++++++----- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 08f9948d..31773233 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,13 +224,6 @@ git submodule update --init upstream/openRepoShape tests/run.sh # the suite, serialized — pass any pytest argument ``` -**FOUR ROOTS, NOT ONE TREE** (#186, T009): the suite reads each file from the -tree that owns it — the code leg, the assembly root (`README.md`, `AGENTS.md`, -`CLAUDE.md`) and the spec leg (`docs/`), with `upstream/openRepoShape` nested in -the code leg — found from the layout or named by `OPENREPOTOOLS_CODE_ROOT`, -`OPENREPOTOOLS_ASSEMBLY_ROOT` and `OPENREPOTOOLS_SPEC_ROOT`. Standalone, a root -a checkout lacks is SKIPPED by name; `OPENREPOTOOLS_COMPOSED=1` REFUSES instead. - Brett Heap's RULING of 2026-09-16 ("do all three", on the coordinator's three proposals — measured: `tests-macos` running 49-54 minutes per push against Linux's 17-22, pull requests drawing 6-12 Copilot review rounds, and this @@ -321,3 +314,10 @@ repository and reaches no network — every remote is a bare repository in a temporary directory, every `$HOME` is a temporary directory, and the one test that exercises a fetch answers it with a fake `gh` and a `curl` that refuses. Never test by creating a real GitHub repository. + +**FOUR ROOTS, NOT ONE TREE** (#186, T009): the suite reads each file from the +tree that owns it — the code leg, the assembly root (`README.md`, `AGENTS.md`, +`CLAUDE.md`) and the spec leg (`docs/`), with `upstream/openRepoShape` nested in +the code leg — found from the layout or named by `OPENREPOTOOLS_CODE_ROOT`, +`OPENREPOTOOLS_ASSEMBLY_ROOT` and `OPENREPOTOOLS_SPEC_ROOT`. Standalone, a root a +checkout lacks is SKIPPED by name; `OPENREPOTOOLS_COMPOSED=1` REFUSES instead. diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 2efdac06..8c788618 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -1778,14 +1778,19 @@ def test_agents_md_is_short_enough_to_be_read(): line of prose is bought on `main` by a file that is not here yet. 316 -> 323 HERE AND 340 -> 347 AT AN ASSEMBLY ROOT, on 2026-10-09, for the - same work (#186, T009; the #190 review). Seven lines in "Testing your - changes" — a blank and six — say what an agent running the suite now has to - know and would get wrong unaided: the suite reads each file from the root - that owns it, the three variables that name those roots + same work (#186, T009; the #190 review). Seven lines at the foot of + "Testing your changes" — a blank and six — say what an agent running the + suite now has to know and would get wrong unaided: the suite reads each + file from the root that owns it, the three variables that name those roots (`OPENREPOTOOLS_CODE_ROOT`, `OPENREPOTOOLS_ASSEMBLY_ROOT`, `OPENREPOTOOLS_SPEC_ROOT`), and that a root a standalone checkout lacks SKIPS by name while `OPENREPOTOOLS_COMPOSED=1` REFUSES instead. The - assembly's AGENTS.md carries the same seven, so its cap moves with them. + assembly's AGENTS.md is this file with T006's `assembly-0001` applied, so + it carries the same seven and its cap moves with them. They sit below the + last line that patch rewrites, so it still applies here and gives 347 + lines; placed under the test block, which the patch rewrites, they made it + fail at `AGENTS.md:224` (lane openRepoTools-3's candidate review; #193 + item 2). """ lines = doc("AGENTS.md").read_text().splitlines() cap = 323 if ROOTS.assembly is None else 347 From 804be8bbab0b24eeea80419c50a5b49fb8a8eb86 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:08:35 +0000 Subject: [PATCH 14/17] Restate the installer-source hygiene test so a fetching line must name an allowed repository, not merely contain $REPO, and read api.github.com URLs too (lane 3's review of #191, delta 5). The first reading asked only that each fetching line contain "$REPO". A line fetching from ${PAYLOAD_REPO:-$REPO} passed by spelling, whatever PAYLOAD_REPO held, and a curl of api.github.com/repos/... was never read. Lane openRepoTools-3's review of #191 (delta 5) gave the restatement to T009. The repository each fetching line names is now read out of it. It must be one of three things: - $REPO; - the pinned standard, for wip init; - a variable whose every assignment is empty, $REPO, or a variable that only a pin's source_repository: arm sets. That is the code repository an adopted assembly pins, which #191 introduces. The rule holds on main's installer and on #191's head 13cc752. Planted cases hold the three shapes the first reading let through and the two it must keep accepting. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/test_repo_hygiene.py | 132 +++++++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 11 deletions(-) diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 8c788618..2497ff2f 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -817,15 +817,95 @@ def test_the_suite_descriptions_run_no_command_of_their_own(): #: A line that actually FETCHES, as opposed to a line of the usage heredoc -#: that says the word. Both spellings take a quoted argument, which the prose -#: never does. +#: that says the word: a `gh api "…"`, a raw-content URL, or an +#: `api.github.com/repos/…` URL (a `curl` of the REST API, which the first +#: reading did not scan; lane openRepoTools-3's review of #191, delta 5). Each +#: takes a quoted or variable argument, which the prose never does. +def fetching_lines_of(code: str) -> list[str]: + return [line for line in code.splitlines() + if 'gh api "' in line or 'raw.githubusercontent.com/$' in line + or 'api.github.com/repos/' in line] + + def fetching_lines(path: Path) -> list[str]: - return [line for line in code_lines(path).splitlines() - if 'gh api "' in line or 'raw.githubusercontent.com/$' in line] + return fetching_lines_of(code_lines(path)) + + +#: The REPOSITORY a fetching line names: what follows `repos/` or the raw +#: host, read as one shell expansion (`$X`, or `${…}` whole). +FETCH_REPOSITORY = re.compile( + r"(?:repos/|raw\.githubusercontent\.com/)(\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)") +#: `$P`, `${P}`, or `${P:-$REPO}`: a variable standing for the repository. +PAYLOAD_SLOT = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)(?::-\$REPO)?\}?") + + +def assigned_values(code: str, name: str) -> list[tuple[str, str]]: + """Every `name=` in `code`, as (value with its quotes off, the + line it is on).""" + found = [] + pattern = re.compile(r"(?:^|[\s;(])" + re.escape(name) + + r"""=("[^"]*"|'[^']*'|[^\s;)]*)""") + for line in code.splitlines(): + for match in pattern.finditer(line): + found.append((match.group(1).strip("\"'"), line.strip())) + return found + + +def installer_source_violations(code: str) -> list[str]: + """What `test_the_installer_reaches_only_this_repository` refuses in + `code` (comment lines already dropped): each fetching line whose + REPOSITORY is not `$REPO`, the pinned standard, or a payload variable + that only ever holds `$REPO` or a pin's `source_repository:`.""" + def pin_named(var: str) -> bool: + values = assigned_values(code, var) + return bool(values) and all( + value == "" or "source_repository)" in line for value, line in values) + + def honest(var: str) -> bool: + values = assigned_values(code, var) + if not values: + return False + for value, _line in values: + if value in ("", "$REPO", "${REPO}"): + continue + inner = PAYLOAD_SLOT.fullmatch(value) + if inner and inner.group(0) == value and pin_named(inner.group(1)): + continue + return False + return True + + bad = [] + for line in fetching_lines_of(code): + slots = FETCH_REPOSITORY.findall(line) + if not slots: + bad.append(f"names no repository this test can read: {line.strip()}") + for slot in slots: + if slot in ("$REPO", "${REPO}", "$SHAPE_REPOSITORY", "${SHAPE_REPOSITORY}"): + continue + var = PAYLOAD_SLOT.fullmatch(slot) + if var and honest(var.group(1)): + continue + bad.append(f"fetches from {slot}, which is not $REPO, the pinned " + f"standard, or a variable holding only those or a pin's " + f"source_repository: {line.strip()}") + return bad def test_the_installer_reaches_only_this_repository(): - """`--install` fetches from `$OPENREPOTOOLS_REPO` and from nowhere else. + """`--install` fetches from `$OPENREPOTOOLS_REPO`, or from the code + repository the assembly it installs from PINS, and from nowhere else. + + RESTATED for the adopted layout (#191's payload source; lane + openRepoTools-3's review of #191, delta 5, T009's to restate). The first + reading asked only that a fetching line CONTAIN `$REPO`, so a line fetching + from `${PAYLOAD_REPO:-$REPO}` passed by spelling whatever `PAYLOAD_REPO` + held, and a `curl` of `api.github.com` was never read at all. Now the + REPOSITORY each fetching line names is read out of it, and it must be one + of three things: `$REPO`; the pinned standard (`wip init`, below); or a + variable whose every assignment is empty, `$REPO`, or a variable that only + a pin's `source_repository:` sets — the code repository an adopted + assembly's `contracts/code-pin.yaml` names, which is this repository's own + code, moved. An `--install` that reached into openRepoShape to complete itself would make this repository's installer depend on the standard at run time, which @@ -838,13 +918,43 @@ def test_the_installer_reaches_only_this_repository(): `templates/workspace-root/` — nine files this repository deliberately does not carry a copy of, because a copy is a second answer to what the template is — and it does it AT THE PINNED COMMIT, not at that repository's `main`. - The test below is the one that holds it to the pin. Every other fetch in - this file is still `$REPO`'s. + The test below is the one that holds it to the pin. """ - for line in fetching_lines(REPO / "openRepoTools"): - assert "$REPO" in line or "$SHAPE_REPOSITORY" in line, ( - f"the command fetches from a repository that is neither " - f"$OPENREPOTOOLS_REPO nor the pinned standard:\n {line.strip()}") + bad = installer_source_violations(code_lines(REPO / "openRepoTools")) + assert not bad, ("the command fetches from a repository it may not:\n " + + "\n ".join(bad)) + + +@pytest.mark.parametrize("code, refused", [ + pytest.param('OTHER="someone/else"\n' + 'gh api "repos/${OTHER:-$REPO}/contents/park?ref=$REF"\n', + True, id="passes-by-spelling"), + pytest.param('curl -fsSL "https://api.github.com/repos/$OTHER/commits/$REF"\n', + True, id="an-api-curl-the-first-reading-never-read"), + pytest.param('PAYLOAD_REPO="$FROM_THE_ENVIRONMENT"\n' + 'gh api "repos/${PAYLOAD_REPO:-$REPO}/contents/park"\n', + True, id="a-payload-variable-set-from-elsewhere"), + pytest.param('PIN_SOURCE_REPOSITORY=""\n' + 'case "$key" in\n' + ' source_repository) PIN_SOURCE_REPOSITORY="$PIN_SCALAR" ;;\n' + 'esac\n' + 'PAYLOAD_REPO=""\n' + 'PAYLOAD_REPO="$REPO" PAYLOAD_REV=""\n' + 'PAYLOAD_REPO="$PIN_SOURCE_REPOSITORY" PAYLOAD_REV="$PIN_COMMIT"\n' + 'gh api "repos/${PAYLOAD_REPO:-$REPO}/contents/$path"\n' + 'curl -fsSL "https://raw.githubusercontent.com/${PAYLOAD_REPO:-$REPO}/$rev/$path"\n' + 'curl -fsSL "https://api.github.com/repos/${PAYLOAD_REPO:-$REPO}/git/trees/$1"\n', + False, id="the-adopted-source-honestly"), + pytest.param('gh api "repos/$REPO/contents/park?ref=$REF"\n' + 'gh api "repos/$SHAPE_REPOSITORY/contents/$f?ref=$ref"\n', + False, id="todays-two-sources"), +]) +def test_the_installer_source_rule_is_not_met_by_spelling(code, refused): + """The restated rule against planted lines: the two shapes the first + reading let through, a payload variable fed from anywhere else, and the + two shapes it must keep accepting.""" + assert bool(installer_source_violations(code)) is refused, ( + installer_source_violations(code)) def test_the_template_is_fetched_from_the_pinned_standard_and_at_the_pin(): From e901b6dd3b27d5df631dbd5c10f831a3fadad3e2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:17:09 +0000 Subject: [PATCH 15/17] Trust a pin-named payload repository only when its source_repository arm assigns the scalar the pin reader parsed (Copilot round 1 on #195). The restated rule accepted any value assigned in a `source_repository)` case arm. So `source_repository) PIN_SOURCE_REPOSITORY="$FROM_THE_ENVIRONMENT"` fed the payload repository from elsewhere and still passed. Copilot's round 1 on #195 found it. That arm must now assign $PIN_SCALAR. Every non-empty PIN_SCALAR assignment must also sit inside pin_scalar(), which is how #191 binds a value it parsed out of a pin. Two planted cases hold this: an arm that assigns something else, and a scalar set outside the reader. Both were accepted at 804be8b. The rule still accepts main's installer and #191's head 13cc752 with no refusals. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/test_repo_hygiene.py | 54 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 2497ff2f..268a163d 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -851,15 +851,43 @@ def assigned_values(code: str, name: str) -> list[tuple[str, str]]: return found +#: The variable a pin reader binds each parsed value to, and the function +#: that alone may set it (#191's `pin_scalar`). +PIN_SCALAR_VALUES = ("$PIN_SCALAR", "${PIN_SCALAR}") + + +def scalar_set_only_by_the_pin_reader(code: str) -> bool: + """Every non-empty `PIN_SCALAR=` in `code` sits inside `pin_scalar() {` + … `}`, so `$PIN_SCALAR` is a value parsed out of a pin and nothing else.""" + inside = False + for line in code.splitlines(): + stripped = line.strip() + if re.match(r"pin_scalar\(\)", stripped): + inside = True + continue + if inside and stripped == "}": + inside = False + continue + if not inside and any(value for value, _ in assigned_values(line, "PIN_SCALAR")): + return False + return True + + def installer_source_violations(code: str) -> list[str]: """What `test_the_installer_reaches_only_this_repository` refuses in `code` (comment lines already dropped): each fetching line whose REPOSITORY is not `$REPO`, the pinned standard, or a payload variable - that only ever holds `$REPO` or a pin's `source_repository:`.""" + that only ever holds `$REPO` or a pin's `source_repository:` value.""" def pin_named(var: str) -> bool: + # Set only in a `source_repository)` arm, and only to the value the + # pin reader parsed: an arm that assigned anything else would make + # the payload repository come from elsewhere (Copilot on #195). values = assigned_values(code, var) return bool(values) and all( - value == "" or "source_repository)" in line for value, line in values) + value == "" or ("source_repository)" in line + and value in PIN_SCALAR_VALUES + and scalar_set_only_by_the_pin_reader(code)) + for value, line in values) def honest(var: str) -> bool: values = assigned_values(code, var) @@ -935,6 +963,28 @@ def test_the_installer_reaches_only_this_repository(): 'gh api "repos/${PAYLOAD_REPO:-$REPO}/contents/park"\n', True, id="a-payload-variable-set-from-elsewhere"), pytest.param('PIN_SOURCE_REPOSITORY=""\n' + 'case "$key" in\n' + ' source_repository) PIN_SOURCE_REPOSITORY="$FROM_THE_ENVIRONMENT" ;;\n' + 'esac\n' + 'PAYLOAD_REPO="$PIN_SOURCE_REPOSITORY"\n' + 'gh api "repos/${PAYLOAD_REPO:-$REPO}/contents/park"\n', + True, id="a-pin-arm-that-assigns-something-else"), + pytest.param('pin_scalar() {\n' + '\tPIN_SCALAR="${v%% #*}"\n' + '}\n' + 'PIN_SCALAR="$FROM_THE_ENVIRONMENT"\n' + 'case "$key" in\n' + ' source_repository) PIN_SOURCE_REPOSITORY="$PIN_SCALAR" ;;\n' + 'esac\n' + 'PAYLOAD_REPO="$PIN_SOURCE_REPOSITORY"\n' + 'gh api "repos/${PAYLOAD_REPO:-$REPO}/contents/park"\n', + True, id="a-scalar-set-outside-the-pin-reader"), + pytest.param('PIN_SCALAR=""\n' + 'pin_scalar() {\n' + '\tPIN_SCALAR="${rest%%"$q"*}"\n' + '\tPIN_SCALAR="${v%% #*}"\n' + '}\n' + 'PIN_SOURCE_REPOSITORY=""\n' 'case "$key" in\n' ' source_repository) PIN_SOURCE_REPOSITORY="$PIN_SCALAR" ;;\n' 'esac\n' From fb77bed095b0b9d95f85e19a307fd19cae1d05d4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:45:26 +0000 Subject: [PATCH 16/17] Skip a stale marker-bearing ancestor instead of ending discovery, accept a paired spec worktree only as a repository holding the pinned spec commit, and require the pin reader that sets PIN_SCALAR (Copilot round 2 on #195). Copilot's round 2 on #195 raised three findings. - discover_assembly stopped at the first ancestor that carried the assembly's two markers, even when it failed the rest. A stale project.yaml and contracts/code-pin.yaml under a feature directory therefore hid the real assembly above it. That ancestor is now skipped. Acceptance is still one of the two exact placements, so skipping cannot borrow an unrelated tree. - The feature's own spec worktree was taken on the strength of a docs/ directory alone. It now has to be a repository of its own that holds the spec commit the assembly pins, which is the proof the paired code worktree already met. Otherwise the mounted spec leg is read. - The installer-source rule's PIN_SCALAR check passed vacuously when there was no pin_scalar() reader, or when the reader never set the scalar. Then $PIN_SCALAR would be the environment's. The reader and a parsed assignment inside it are now both required. Each finding has a test that is red at e901b6d. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 41 +++++++++++++++++++++++++++----------- tests/test_repo_hygiene.py | 36 ++++++++++++++++++++++++++------- tests/test_test_roots.py | 27 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9683bb46..471dc274 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -172,6 +172,15 @@ def _mount(assembly: Path, role: str) -> str: "submodule_path") or role +def _own_repository_holding(root: Path, commit: str) -> bool: + """`root` is a repository's own top level, and its object store holds + `commit`: a worktree or clone of the leg the assembly pins, not a + directory that merely sits where one would.""" + top = _git_out(root, "rev-parse", "--show-toplevel") + return bool(top and _same(Path(top), root)) and _git_out( + root, "cat-file", "-e", f"{commit}^{{commit}}") is not None + + def _paired_with(assembly: Path, code: Path, recorded: str) -> bool: """Whether `code` is a paired feature worktree of `assembly`'s code leg: `/worktrees//`, a repository @@ -179,12 +188,9 @@ def _paired_with(assembly: Path, code: Path, recorded: str) -> bool: last check is what keeps a checkout that merely sits there from being taken for the leg.""" mount = _mount(assembly, "code") - if not (_same(code.parent.parent, assembly / PAIRED_WORKTREES) - and code.name == Path(mount).name): - return False - top = _git_out(code, "rev-parse", "--show-toplevel") - return bool(top and _same(Path(top), code)) and _git_out( - code, "cat-file", "-e", f"{recorded}^{{commit}}") is not None + return (_same(code.parent.parent, assembly / PAIRED_WORKTREES) + and code.name == Path(mount).name + and _own_repository_holding(code, recorded)) def discover_assembly(code: Path) -> Optional[Path]: @@ -193,7 +199,13 @@ def discover_assembly(code: Path) -> Optional[Path]: pin names, AND holds `code` in one of the two designed places: mounted at that gitlink, or as a paired feature worktree (`/worktrees//code`). Anything else is None, which is - today's layout and a standalone code clone alike.""" + today's layout and a standalone code clone alike. + + An ancestor that carries the markers but fails the rest is SKIPPED, not + the end of the search: a stale `project.yaml` and `contracts/code-pin.yaml` + under a feature directory must not hide the assembly above it (Copilot + round 2 on #195). Skipping cannot borrow an unrelated tree, because + acceptance is still one of the two exact placements.""" code = code.resolve() for candidate in code.parents: if not _carries(candidate, ASSEMBLY_MARKERS): @@ -202,10 +214,9 @@ def discover_assembly(code: Path) -> Optional[Path]: top = _git_out(candidate, "rev-parse", "--show-toplevel") recorded = _gitlink(candidate, mount) if not (top and _same(Path(top), candidate) and recorded): - return None + continue if _same(candidate / mount, code) or _paired_with(candidate, code, recorded): return candidate - return None return None @@ -452,10 +463,16 @@ def resolve_roots(environ: Mapping[str, str], suite_root: Path) -> Roots: f"carries none of {', '.join(SPEC_TREES)}") elif assembly is not None: # A paired feature's own spec worktree, beside its code worktree, is - # that feature's spec root; the assembly's mounted spec leg otherwise. - paired = code.parent / Path(_mount(assembly, "spec")).name + # that feature's spec root, held to the same proof as the paired code + # worktree: a repository of its own holding the spec commit the + # assembly pins. A stray `docs/` there is not it (Copilot round 2 on + # #195), and the assembly's mounted spec leg is read instead. + spec_mount = _mount(assembly, "spec") + paired = code.parent / Path(spec_mount).name + spec_pin = _gitlink(assembly, spec_mount) if (_same(code.parent.parent, assembly / PAIRED_WORKTREES) - and _carries_a_spec_tree(paired)): + and _carries_a_spec_tree(paired) and spec_pin + and _own_repository_holding(paired, spec_pin)): spec, how["spec"] = paired, "the paired spec worktree beside the code root" else: candidate = assembly / _mount(assembly, "spec") diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 268a163d..36e22d20 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -857,20 +857,27 @@ def assigned_values(code: str, name: str) -> list[tuple[str, str]]: def scalar_set_only_by_the_pin_reader(code: str) -> bool: - """Every non-empty `PIN_SCALAR=` in `code` sits inside `pin_scalar() {` - … `}`, so `$PIN_SCALAR` is a value parsed out of a pin and nothing else.""" - inside = False + """`code` has a `pin_scalar() {` … `}` reader that sets `PIN_SCALAR` to a + parsed value, and nothing else sets it to anything but empty. Without the + reader, or with a reader that never sets it, `$PIN_SCALAR` would be + whatever the environment held (Copilot round 2 on #195).""" + inside = reader = False + parsed = 0 for line in code.splitlines(): stripped = line.strip() if re.match(r"pin_scalar\(\)", stripped): - inside = True + inside = reader = True continue if inside and stripped == "}": inside = False continue - if not inside and any(value for value, _ in assigned_values(line, "PIN_SCALAR")): - return False - return True + for value, _ in assigned_values(line, "PIN_SCALAR"): + if not value: + continue + if not inside: + return False + parsed += 1 + return reader and parsed > 0 def installer_source_violations(code: str) -> list[str]: @@ -979,6 +986,21 @@ def test_the_installer_reaches_only_this_repository(): 'PAYLOAD_REPO="$PIN_SOURCE_REPOSITORY"\n' 'gh api "repos/${PAYLOAD_REPO:-$REPO}/contents/park"\n', True, id="a-scalar-set-outside-the-pin-reader"), + pytest.param('case "$key" in\n' + ' source_repository) PIN_SOURCE_REPOSITORY="$PIN_SCALAR" ;;\n' + 'esac\n' + 'PAYLOAD_REPO="$PIN_SOURCE_REPOSITORY"\n' + 'gh api "repos/${PAYLOAD_REPO:-$REPO}/contents/park"\n', + True, id="no-pin-reader-so-the-scalar-is-the-environments"), + pytest.param('pin_scalar() {\n' + '\tPIN_SCALAR=""\n' + '}\n' + 'case "$key" in\n' + ' source_repository) PIN_SOURCE_REPOSITORY="$PIN_SCALAR" ;;\n' + 'esac\n' + 'PAYLOAD_REPO="$PIN_SOURCE_REPOSITORY"\n' + 'gh api "repos/${PAYLOAD_REPO:-$REPO}/contents/park"\n', + True, id="a-pin-reader-that-never-sets-the-scalar"), pytest.param('PIN_SCALAR=""\n' 'pin_scalar() {\n' '\tPIN_SCALAR="${rest%%"$q"*}"\n' diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index afcebdff..940edbe6 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -926,6 +926,33 @@ def test_only_the_two_designed_places_are_an_assemblys_code(tmp_path): composed) +def test_a_stale_manifest_under_a_feature_does_not_hide_its_assembly(tmp_path): + """An ancestor carrying the assembly's two markers but failing the rest + (not a repository's top level, no gitlink) is skipped, not the end of the + search: discovery stopped there and found nothing (Copilot round 2 on + #195).""" + root, feature = paired_triad(tmp_path) + (feature / "contracts").mkdir() + (feature / "contracts" / "code-pin.yaml").write_text( + LEG_PIN.format(role="code", commit="0" * 40, digest="0" * 64), encoding="utf-8") + (feature / "project.yaml").write_text(PROJECT_YAML, encoding="utf-8") + roots = conftest.resolve_roots({}, feature / "code") + assert roots.assembly == root.resolve(), roots.describe() + + +def test_a_stray_spec_tree_beside_a_paired_code_worktree_is_not_its_spec(tmp_path): + """The feature's own spec worktree must be a repository holding the spec + commit the assembly pins, as the paired code worktree must. A plain `docs/` + there is not it, and the mounted spec leg is read instead (Copilot round 2 + on #195).""" + root, feature = paired_triad(tmp_path, spec_worktree=False) + stray = spec_leg(feature / "spec") # files only, no repository + assert (stray / "docs" / "README-lanes.md").is_file() + roots = conftest.resolve_roots({}, feature / "code") + assert roots.spec == (root / "spec").resolve(), roots.describe() + assert roots.how["spec"] == "the assembly's spec leg", roots.how + + def test_the_variables_name_the_roots(tmp_path): code = code_leg(tmp_path / "code") root = assembly(tmp_path / "elsewhere" / "asm") From e37887181e4c199606aa0702f6170b4b4990adb8 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:18:25 +0000 Subject: [PATCH 17/17] Hold AGENTS.md's pin rules to the path that works from where the file is read: code/upstream/openRepoShape and git -C code at an assembly root, today's spelling on the single tree (#193 item 14). T006's assembly-0001 re-roots the two strings test_agents_md_names_the_pin_rules asserts. At an assembly root the submodule is the code leg's, and the unprefixed command fails there ("pathspec 'upstream/openRepoShape' did not match any file(s) known to git", measured in the composed candidate). The composed r2 candidate was red on that test for this reason. The coordinator decided that the test gives and that the patch stays as it is. The test now reads the prefix from the assembly's code pin, through the new Roots.code_mount(). It asserts the path and the init command for that root, so each spelling fails where the other belongs. A nested test holds both directions against a fixture assembly. Today's single tree asserts exactly what it did before. Lane: openRepoTools-1 Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 7 +++++++ tests/test_repo_hygiene.py | 20 +++++++++++++++++--- tests/test_test_roots.py | 26 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 471dc274..2d8a2410 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -298,6 +298,13 @@ def need(self, role: str, rel: str): f"checkout has none ({why}); the composed run " f"({ENV_COMPOSED}=1 from the assembly root) reads it") + def code_mount(self) -> Optional[str]: + """Where this run's assembly mounts the code leg (its code pin's + `submodule_path:`), or None where there is no assembly root: the + prefix a path in the assembly's documents carries to reach the code + leg (#193 item 14).""" + return None if self.assembly is None else _mount(self.assembly, "code") + def tracked_roots(self) -> List[Tuple[str, Path]]: """Every distinct repository this run can read, code first.""" out: List[Tuple[str, Path]] = [] diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 36e22d20..b40b9188 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -1460,12 +1460,26 @@ def test_agents_md_names_the_pin_rules(): """The three rules an agent touching the submodule has to have read, and the reason they are in AGENTS.md rather than only in the pin's header: an assistant is told to read this file, and a procedure that lives outside it - is a procedure performed from memory.""" + is a procedure performed from memory. + + THE PATHS ARE THE ONES THAT WORK FROM WHERE THE FILE IS READ (#193 item + 14; the coordinator's decision of 2026-10-11). On today's single tree that + is `upstream/openRepoShape` and `git submodule update --init …`. At an + assembly root the submodule is the code leg's, so T006's `assembly-0001` + writes `code/upstream/openRepoShape` and `git -C code submodule update + --init …`: the unprefixed command fails there ("pathspec + 'upstream/openRepoShape' did not match any file(s) known to git", measured + in the composed candidate). The prefix is the assembly's code mount, read + from its code pin, so either spelling in the wrong place fails.""" text = doc("AGENTS.md").read_text(encoding="utf-8") - assert "Never edit anything under `upstream/openRepoShape` in place" in text + mount = ROOTS.code_mount() + where = f"{mount}/" if mount else "" + init = (f"git -C {mount} submodule update --init upstream/openRepoShape" + if mount else "git submodule update --init upstream/openRepoShape") + assert f"Never edit anything under `{where}upstream/openRepoShape` in place" in text assert "Never pin a commit that is not on that repository's `main`" in text assert "RECOMPUTED, never adjusted" in text - assert "git submodule update --init upstream/openRepoShape" in text + assert init in text, f"AGENTS.md does not give the command that works here: {init}" def test_agents_md_is_short_enough_to_be_read(): diff --git a/tests/test_test_roots.py b/tests/test_test_roots.py index 940edbe6..0b8ab198 100644 --- a/tests/test_test_roots.py +++ b/tests/test_test_roots.py @@ -333,6 +333,32 @@ def test_the_hygiene_suite_reads_the_lane_manual_from_the_spec_root(tmp_path): README_CAP_TEST = "test_readme_is_short_enough_to_be_read" +PIN_RULES_TEST = "test_agents_md_names_the_pin_rules" + + +@pytest.mark.parametrize("prefixed, outcome", [ + pytest.param(True, "passed", id="the-assemblys-code-prefixed-rules"), + pytest.param(False, "failure", id="todays-spelling-at-an-assembly-root"), +]) +def test_the_pin_rules_are_read_with_the_assemblys_code_prefix(tmp_path, prefixed, outcome): + """At an assembly root the pin rules name the submodule through the code + mount, `code/upstream/openRepoShape` and `git -C code submodule update + …`, as T006's `assembly-0001` writes them; today's spelling there names a + path the assembly does not have, and FAILS (#193 item 14). Red at + `fb77bed`, which held the assembly's AGENTS.md to today's spelling.""" + where, init = (("code/", "git -C code submodule update") + if prefixed else ("", "git submodule update")) + root = assembly(tmp_path / "asm") + (root / "AGENTS.md").write_text( + f"1. **Never edit anything under `{where}upstream/openRepoShape` in place.**\n" + "2. **Never pin a commit that is not on that repository's `main`.**\n" + "3. **The digest is RECOMPUTED, never adjusted.**\n" + f" {init} --init upstream/openRepoShape\n", encoding="utf-8") + run = nested(tmp_path, {"OPENREPOTOOLS_ASSEMBLY_ROOT": str(root)}, + PIN_RULES_TEST, "test_repo_hygiene.py") + assert run.outcome(PIN_RULES_TEST)[0] == outcome, run.output[-2000:] + + def test_the_length_caps_are_the_assembly_roots_where_one_is_named(tmp_path): """At an assembly root the two caps are 347 and 510 — this repository's 323 and 486 plus the twenty-four lines each that T006's root guidance adds