diff --git a/tests/conftest.py b/tests/conftest.py index 0f53a612..28274597 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,376 @@ 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 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 " + 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 +428,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..53e5d3c1 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,14 @@ 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 +# 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)" 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 +3756,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 +6239,12 @@ 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 +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)" has "…and the one that made \`lane-start \` list and suggest" "$ln_doc" \ @@ -6240,6 +6259,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 ` 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_lane_helpers_suite.py b/tests/test_lane_helpers_suite.py index bc3635c8..602c03dc 100644 --- a/tests/test_lane_helpers_suite.py +++ b/tests/test_lane_helpers_suite.py @@ -35,7 +35,7 @@ import pytest -from conftest import REPO, WINDOWS_SKIP +from conftest import REPO, ROOTS, WINDOWS_SKIP SUITE = REPO / "tests" / "test_lane_helpers.sh" @@ -170,6 +170,16 @@ def test_the_lane_helper_suite_passes(): "LANES_REPOS_TSV_SHIPPED", "AGENT_PROTOCOL_ROOT", "OPENREPOTOOLS_BIN_DIR", "PROJECTS_ROOT", "CLAUDE_NO_LANE"): env.pop(name, None) + # THE LANE MANUAL IS THE SPEC LEG'S (opensoft/openRepoTools#186, T009): + # the suite quotes `docs/README-lanes.md` back against the code, and in the + # triad that file is not beside the code. Named from this run's roots - the + # code root itself on today's layout - and EMPTY where the run has no spec + # root (a standalone code leg), which the suite reads as "skip the quotes, + # naming why". A composed run without one never gets here: conftest + # refuses it before the first test. + spec = ROOTS.root_of("spec") + env["OPENREPOTOOLS_LANE_MANUAL"] = ( + "" if spec is None else str(spec / "docs" / "README-lanes.md")) # `errors="replace"`, because the thing this wrapper exists to print is the # TRANSCRIPT, and a decode that raises loses all of it. The suite renders diff --git a/tests/test_openrepotools_command.py b/tests/test_openrepotools_command.py index fe8f1472..29622a84 100644 --- a/tests/test_openrepotools_command.py +++ b/tests/test_openrepotools_command.py @@ -39,7 +39,7 @@ import pytest -from conftest import REPO, WINDOWS_SKIP +from conftest import REPO, ROOTS, WINDOWS_SKIP COMMAND = REPO / "openRepoTools" @@ -1395,7 +1395,10 @@ def test_the_help_and_the_readme_say_the_mode_the_receipt_is_born_at(): paragraph and neither carries a 0644 there. """ help_text = run_cmd("--help").stdout - readme = (REPO / "README.md").read_text(encoding="utf-8") + # The README is the ASSEMBLY root's in the triad (#186, T009): read from + # the root that owns it - a skip in a standalone code leg, and in the + # composed run a failure, never a skip, if that root is absent. + readme = ROOTS.path_for("README.md").read_text(encoding="utf-8") for where, text, start in (("--help", help_text, "It then writes a RECEIPT"), ("README.md", readme, "**And it writes down what it placed.**")): assert start in text, f"{where} no longer has the receipt paragraph" diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 10846205..407f697e 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -7,6 +7,13 @@ below) and the documents beside them, so they run in a clone made without `--recurse-submodules` and they run on Windows, which is what the Windows job is for. + +EACH FILE IS READ FROM THE ROOT THAT OWNS IT (opensoft/openRepoTools#186, +T009). A command, a test or a pin is the CODE root's (`REPO`); `README.md`, +`AGENTS.md` and `CLAUDE.md` are the ASSEMBLY root's and `docs/` the SPEC +leg's, read through `doc()` below. On today's layout the three are one tree, +so nothing here changes; in a standalone code leg a document test skips naming +the root it needs, and in the composed run it fails rather than skip. """ from __future__ import annotations @@ -19,7 +26,34 @@ import pytest -from conftest import REPO, WINDOWS_SKIP +from conftest import REPO, ROOTS, WINDOWS_SKIP + + +def doc(rel: str) -> 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 +2669,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 +2694,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 +2726,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 +3126,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..9377536d --- /dev/null +++ b/tests/test_test_roots.py @@ -0,0 +1,676 @@ +# 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 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 = ( + "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:] + + +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_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 + 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_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 + 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}")