diff --git a/AGENTS.md b/AGENTS.md index 443880a6..31773233 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -314,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/conftest.py b/tests/conftest.py index 28274597..2d8a2410 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") @@ -88,6 +92,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") @@ -143,7 +154,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: @@ -156,19 +172,51 @@ 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 + 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") + 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]: """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. + + 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): 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): + continue + if _same(candidate / mount, code) or _paired_with(candidate, code, recorded): return candidate - return None return None @@ -177,7 +225,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 +234,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 +275,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") @@ -240,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]] = [] @@ -263,6 +328,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 " @@ -312,6 +392,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: " @@ -336,6 +422,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,12 +447,16 @@ 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: 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, "") @@ -374,14 +465,28 @@ 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: - 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, 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 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") + 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 @@ -415,6 +520,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/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_repo_hygiene.py b/tests/test_repo_hygiene.py index 407f697e..b40b9188 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -817,15 +817,130 @@ 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 + + +#: 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: + """`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 = reader = True + continue + if inside and stripped == "}": + inside = False + continue + 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]: + """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:` 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 + 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) + 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 +953,80 @@ 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="$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('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' + '\tPIN_SCALAR="${v%% #*}"\n' + '}\n' + '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(): @@ -972,6 +1154,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 +1178,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 +1202,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 +1236,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 +1431,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 +1448,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") @@ -1276,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(): @@ -1774,9 +1972,24 @@ 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 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 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 = 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 +2652,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 +2663,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 +2679,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 +3339,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 +3359,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 9377536d..0b8ab198 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 @@ -54,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 = ( @@ -102,8 +105,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", @@ -330,8 +333,34 @@ 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 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 +368,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 +378,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, ( @@ -379,33 +408,261 @@ 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): + """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): @@ -430,6 +687,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 @@ -537,6 +829,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 ------------------------------------------- @@ -569,6 +871,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"] @@ -580,6 +886,99 @@ 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_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") @@ -613,6 +1012,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() @@ -662,6 +1090,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