From fe82cb29a31bb0ebc4f6a2ee033bddf1abf68f98 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:11:25 +0100 Subject: [PATCH] Record browser receipts per commit so frontend PRs stop conflicting Every commit touching user-visible frontend source rewrote the single frontend-modern/browser-verification.json, so any two open frontend pull requests conflicted as soon as either merged. A merge could not repair the loser: concluding it staged main's frontend files as if the author had changed them, and the guard then demanded a receipt for files the author never ran. The only honest recovery was a rebuild on the new main and a fresh browser pass, as #2415 -> #2418 -> #2433 showed on 2-3 October. Each commit now adds its own receipt under frontend-modern/browser-verification/, named by the fingerprint of the content it verified. Receipts are only ever added, so git merges them unaided. The guard keeps every binding it had: the commit's own parent, the exact changed paths and their content digests, a desktop and a narrow viewport, non-empty routes, states and interactions, and the range check that each changed file's final content matches a receipt from a non-merge commit in the range. Concluding a merge no longer counts content a merged parent already carries as the commit's own change. Merged content that neither parent holds is flagged and still needs a follow-up receipt in range mode, and a merge commit may not record one. The shared file is retired: no commit may write it again. A commit that modified it while its own parent still carried it stays valid evidence in range mode, so a pull request that still rewrites it is repaired by merging main and taking main's copy rather than by a rebuild. The file is deleted in a change of its own, because deleting it here would conflict with every pull request that still rewrites it. --write-template writes the skeleton at the path the staged content requires. --prune deletes receipts already on main. A change that records a receipt may not delete one: git pairs the two as a rename, which conflicts with any other change deleting the same receipt. --- .gitleaks.toml | 4 +- .husky/pre-commit | 5 +- .../CANONICAL_DEVELOPMENT_PROTOCOL.md | 56 +- .../browser-verification/README.md | 25 + .../browser_verification_guard.py | 727 ++++++++++++++---- .../browser_verification_guard_test.py | 505 +++++++++++- 6 files changed, 1140 insertions(+), 182 deletions(-) create mode 100644 frontend-modern/browser-verification/README.md diff --git a/.gitleaks.toml b/.gitleaks.toml index 47fd4dce94..22ab7b190d 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -16,8 +16,10 @@ paths = [ '''_test\.go$''', '''_test\.ts$''', '''tests/integration/''', - # Generated receipt contains required SHA-256 content digests + # Generated receipts contain required SHA-256 content digests. The single + # shared receipt is retired but remains in history. '''^frontend-modern/browser-verification\.json$''', + '''^frontend-modern/browser-verification/[0-9a-f]{16}\.json$''', # tmp/ is gitignored but shows up in --no-git scans '''^tmp/''', ] diff --git a/.husky/pre-commit b/.husky/pre-commit index 3b10bd9594..dc6ea0c567 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -68,8 +68,9 @@ staged_files_match() { } # Normalize staged source before any guard hashes or audits it. The browser -# verification receipt binds to exact frontend bytes, so formatting after its -# guard can produce a commit that passed locally but correctly fails in CI. +# verification receipt binds to exact frontend bytes and is named by their +# fingerprint, so formatting after its guard can produce a commit that passed +# locally but correctly fails in CI. python3 scripts/release_control/format_staged_go.py python3 scripts/release_control/format_staged_frontend.py diff --git a/docs/release-control/v6/internal/CANONICAL_DEVELOPMENT_PROTOCOL.md b/docs/release-control/v6/internal/CANONICAL_DEVELOPMENT_PROTOCOL.md index 8b27e36192..d4b723cd58 100644 --- a/docs/release-control/v6/internal/CANONICAL_DEVELOPMENT_PROTOCOL.md +++ b/docs/release-control/v6/internal/CANONICAL_DEVELOPMENT_PROTOCOL.md @@ -231,10 +231,10 @@ misaligned, off-screen, or otherwise unusable fails this gate. Known browser defects in the changed journey remain part of the current task and block the commit unless the user explicitly narrows the requested scope. -The durable proof boundary is -`frontend-modern/browser-verification.json`. Every commit that changes +The durable proof boundary is one receipt file per commit under +`frontend-modern/browser-verification/`. Every commit that changes user-visible source under `frontend-modern/src/` or the frontend HTML entry -must update that receipt with: +must add its own receipt there with: 1. `base_sha`, equal to the parent revision whose current build was exercised 2. the exact changed user-visible frontend source paths and SHA-256 digests of @@ -245,13 +245,55 @@ must update that receipt with: 6. the interactions exercised 7. a UTC verification timestamp and `result: "passed"` +The receipt's file name is fixed by what it verified, not chosen: the first 16 +hexadecimal characters of the SHA-256 of its `content_sha256` map, serialized +as compact JSON with sorted keys, followed by `.json`. A commit records exactly +one receipt and never edits another commit's. + +Receipts are separate files because a shared one made every pair of open +frontend pull requests conflict. Until 2026-10-03 each frontend commit rewrote +`frontend-modern/browser-verification.json`, so the first pull request to merge +left every other one conflicting, and each conflict cost a rebuild on the new +`main` and a fresh browser pass. Files that are only ever added cannot collide. +The shared file is retired: the guard refuses any commit that writes it. Range +mode still accepts it as evidence from a commit that modified it, because that +commit was verified while its own parent still carried the file. A pull request +that still rewrites it is repaired by merging `main` and taking `main`'s copy of +the file, not by a rebuild. The file is deleted in a change of its own, because +deleting it conflicts with every pull request that still rewrites it. + `scripts/release_control/browser_verification_guard.py` enforces the receipt against the staged index locally and against the final integration range in canonical governance CI. Canonical contract completion remains checked per commit. A stale receipt, later source edit, incomplete path coverage, -missing responsive viewport, or omitted state/interaction evidence is a hard failure. Agents may -print a non-passing receipt skeleton with -`python3 scripts/release_control/browser_verification_guard.py --print-template` -after staging the intended frontend paths. +missing responsive viewport, or omitted state/interaction evidence is a hard failure. After +staging the intended frontend paths, agents may write a non-passing receipt +skeleton to the path the staged content requires with +`python3 scripts/release_control/browser_verification_guard.py --write-template`, +or print it with `--print-template`, which also names that path. Editing a +staged frontend file afterwards changes the fingerprint, so the receipt must be +re-verified and moved to the new name. + +Concluding a merge stages everything the other parent brought. The +staged-index guard does not count content a merged parent already carries as +the commit's own change, so merging current `main` into a branch needs no +receipt for the frontend work `main` brought and cannot be asked to vouch for +it. A merge commit may not record a receipt. Frontend content that differs from +every parent, a conflict resolution or two edits git combined, can be +committed, but it has had no browser pass: verify it and record its receipt in +a follow-up commit, which range mode requires. Because receipts no longer +conflict, two open pull requests that edit different parts of one frontend +file can both merge; range mode evaluates the merged result on the pull +request when its check runs and again on the push to `main`, where combined +content that no receipt verified fails. + +A receipt is read from the commit that recorded it, so once that commit is on +`main` its file is dead weight. +`python3 scripts/release_control/browser_verification_guard.py --prune` +deletes every receipt already on `origin/main`. A change that records a +receipt must not delete one: git reads a deleted receipt beside a newly added +one as a rename, and that rename conflicts with any other change that deletes +the same receipt. Pruning therefore lands only in changes that record no +receipt, and the guard enforces that per commit in both modes. An integration range that merges reviewed work onto a main that has moved is validated with `--base --commit `. The tip of such a diff --git a/frontend-modern/browser-verification/README.md b/frontend-modern/browser-verification/README.md new file mode 100644 index 0000000000..0bffc24e28 --- /dev/null +++ b/frontend-modern/browser-verification/README.md @@ -0,0 +1,25 @@ +# Browser verification receipts + +Each commit that changes user-visible frontend source adds one receipt here. +The file name is the fingerprint of the content the receipt verified, so no +two changes write the same file and open frontend pull requests cannot +conflict on their receipts. + +Create the skeleton for the staged change, then fill it in after the browser +pass: + +```bash +python3 scripts/release_control/browser_verification_guard.py --write-template +``` + +Do not edit, rename or delete another change's receipt in a commit that adds +one. A receipt is read from the commit that recorded it, so receipts whose +commits are already on `main` are dead weight; remove them in a change that +records none: + +```bash +python3 scripts/release_control/browser_verification_guard.py --prune +``` + +The rules live in the Frontend Browser Verification Gate section of +`docs/release-control/v6/internal/CANONICAL_DEVELOPMENT_PROTOCOL.md`. diff --git a/scripts/release_control/browser_verification_guard.py b/scripts/release_control/browser_verification_guard.py index 0c9fbe5fcd..1de3ed3f0a 100644 --- a/scripts/release_control/browser_verification_guard.py +++ b/scripts/release_control/browser_verification_guard.py @@ -1,5 +1,14 @@ #!/usr/bin/env python3 -"""Require fresh browser proof for staged user-visible frontend changes.""" +"""Require fresh browser proof for staged user-visible frontend changes. + +Every commit that changes user-visible frontend source records its browser +receipt in a file of its own under frontend-modern/browser-verification/, named +by the fingerprint of the content it verified. Until 2026-10-03 every such +commit rewrote one shared file, so any two open frontend pull requests +conflicted the moment either merged, and each conflict cost a rebuild and a +fresh browser pass. Receipts that are only ever added cannot collide, and git +merges them without help. +""" from __future__ import annotations @@ -8,6 +17,7 @@ from datetime import datetime import hashlib import json from pathlib import Path, PurePosixPath +import re import subprocess import sys from typing import Iterable, Sequence @@ -16,7 +26,17 @@ import format_staged_frontend REPO_ROOT = Path(__file__).resolve().parents[2] -RECEIPT_PATH = "frontend-modern/browser-verification.json" +RECEIPT_DIR = "frontend-modern/browser-verification" +# The single shared receipt every frontend commit rewrote before per-commit +# receipts. It is retired: nothing may write it again. Range mode still reads +# it from commits that modified it, because those commits were verified +# honestly against a tree that carried it and are still merging. The file +# itself is deleted in a change of its own, since deleting it conflicts with +# every open pull request that still rewrites it. +LEGACY_RECEIPT_PATH = "frontend-modern/browser-verification.json" +RECEIPT_FINGERPRINT_LENGTH = 16 +DEFAULT_LANDED_REF = "origin/main" +GUARD_COMMAND = "python3 scripts/release_control/browser_verification_guard.py" FRONTEND_SOURCE_PREFIX = "frontend-modern/src/" FRONTEND_ENTRY_FILES = {"frontend-modern/index.html"} FRONTEND_SOURCE_SUFFIXES = {".css", ".scss", ".ts", ".tsx"} @@ -33,6 +53,15 @@ def run_git(args: Sequence[str], *, repo_root: Path = REPO_ROOT) -> str: return result.stdout.strip() +def blocked(headline: str, details: Iterable[str] = (), advice: Iterable[str] = ()) -> int: + print(f"BLOCKED: {headline}", file=sys.stderr) + for detail in details: + print(f" - {detail}", file=sys.stderr) + for line in advice: + print(line, file=sys.stderr) + return 1 + + def git_blob_bytes(object_name: str, *, repo_root: Path = REPO_ROOT) -> bytes | None: result = subprocess.run( ["git", "show", object_name], @@ -139,12 +168,35 @@ def formatting_only_paths( return formatting_only +def is_receipt_path(path: str) -> bool: + pure_path = PurePosixPath(path) + return str(pure_path.parent) == RECEIPT_DIR and pure_path.suffix == ".json" + + +def receipt_fingerprint(digests: dict[str, str]) -> str: + canonical = json.dumps(digests, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:RECEIPT_FINGERPRINT_LENGTH] + + +def receipt_path_for(digests: dict[str, str]) -> str: + """Where the receipt verifying exactly this content must be stored. + + The name is derived from the verified content rather than chosen, so two + changes can only share a receipt file when they verify identical bytes, + and the guard knows which file a commit owes without searching. It does + not include the parent, so rebuilding a commit on a new parent edits + base_sha in place instead of renaming the file. + """ + return f"{RECEIPT_DIR}/{receipt_fingerprint(digests)}.json" + + def load_receipt_text( + path: str, *, commit: str | None, repo_root: Path = REPO_ROOT, ) -> str: - object_name = f"{commit}:{RECEIPT_PATH}" if commit else f":{RECEIPT_PATH}" + object_name = f"{commit}:{path}" if commit else f":{path}" return run_git(["show", object_name], repo_root=repo_root) @@ -171,9 +223,17 @@ def validate_receipt( payload: object, *, changed_paths: Sequence[str], - expected_base: str, + expected_base: str | None, expected_content_sha256: dict[str, str], + receipt_path: str | None = None, ) -> list[str]: + """Reasons the receipt is not evidence for exactly this content. + + expected_base None validates a receipt staged without frontend source + changes, where the commit's parent is not knowable from the index alone + (a follow-up proof commit names HEAD, an amended one names HEAD's parent); + range mode binds those to their real parent. + """ errors: list[str] = [] if not isinstance(payload, dict): return ["receipt root must be a JSON object"] @@ -182,9 +242,26 @@ def validate_receipt( errors.append("version must be 1") if payload.get("result") != "passed": errors.append('result must be "passed"') - if payload.get("base_sha") != expected_base: + if expected_base is None: + base_sha = payload.get("base_sha") + if not isinstance(base_sha, str) or not re.fullmatch(r"[0-9a-f]{40}", base_sha): + errors.append("base_sha must be the full commit id of the verified parent") + elif payload.get("base_sha") != expected_base: errors.append(f"base_sha must match the verified parent {expected_base}") + digests = payload.get("content_sha256") + if ( + receipt_path is not None + and is_receipt_path(receipt_path) + and isinstance(digests, dict) + and all(isinstance(key, str) and isinstance(value, str) for key, value in digests.items()) + and receipt_path != receipt_path_for(digests) + ): + errors.append( + f"receipt must be stored at {receipt_path_for(digests)}, " + "the fingerprint of its content_sha256" + ) + receipt_paths = payload.get("changed_paths") if not isinstance(receipt_paths, list) or not all( isinstance(path, str) and path for path in receipt_paths @@ -246,18 +323,110 @@ def validate_receipt( return errors +def blob_ids( + revision: str | None, + paths: Sequence[str], + *, + repo_root: Path = REPO_ROOT, +) -> dict[str, str]: + """Object ids under the given paths in a commit, or in the index when revision is None. + + A path that does not exist there is simply absent from the result. + """ + ids: dict[str, str] = {} + for start in range(0, len(paths), 200): + chunk = list(paths[start : start + 200]) + if revision is None: + command = ["ls-files", "--stage", "-z", "--", *chunk] + else: + command = ["ls-tree", "-r", "-z", revision, "--", *chunk] + output = run_git(["--literal-pathspecs", *command], repo_root=repo_root) + for entry in output.split("\0"): + meta, separator, path = entry.partition("\t") + if not separator: + continue + fields = meta.split() + ids[path] = fields[1] if revision is None else fields[2] + return ids + + +def merge_parents(*, commit: str | None, repo_root: Path = REPO_ROOT) -> list[str]: + """Parents beyond the first: the merge being concluded, or a merge commit's own.""" + if commit: + return run_git( + ["rev-list", "--parents", "-n", "1", commit], repo_root=repo_root + ).split()[2:] + merge_head = Path(run_git(["rev-parse", "--git-path", "MERGE_HEAD"], repo_root=repo_root)) + if not merge_head.is_absolute(): + merge_head = repo_root / merge_head + if not merge_head.exists(): + return [] + return merge_head.read_text(encoding="utf-8").split() + + +def merged_in_paths( + paths: Sequence[str], + *, + commit: str | None, + repo_root: Path = REPO_ROOT, +) -> set[str]: + """Paths whose new state is exactly what a merged parent already carries. + + Concluding a merge stages everything the other parent brought, so a plain + staged-file reading mistakes the other side's frontend work and receipts + for this commit's own. Demanding a receipt for them asked the author to + vouch for files they never ran, which made a merge unable to repair a + branch that fell behind main. Content a parent already holds was verified + in that parent's history and is not this commit's change. + """ + parents = merge_parents(commit=commit, repo_root=repo_root) + if not parents or not paths: + return set() + new = blob_ids(commit, paths, repo_root=repo_root) + inherited: set[str] = set() + for parent in parents: + theirs = blob_ids(parent, paths, repo_root=repo_root) + inherited.update(path for path in paths if new.get(path) == theirs.get(path)) + return inherited + + +def parse_receipt_changes(name_status: str) -> dict[str, str]: + changes: dict[str, str] = {} + for line in name_status.splitlines(): + status, _, path = line.partition("\t") + if path == LEGACY_RECEIPT_PATH or is_receipt_path(path): + changes[path] = status[:1] + return changes + + +def receipt_changes(*, commit: str | None, repo_root: Path = REPO_ROOT) -> dict[str, str]: + """Receipt files a commit, or the staged index, adds (A), modifies (M) or deletes (D). + + Read from git rather than from the caller's path list, and with rename + detection off: git reports a deleted receipt and a similar new one as a + single rename, which would hide the deletion. + """ + if commit: + command = ["diff", "--no-renames", "--name-status", f"{commit}^", commit] + else: + command = ["diff", "--cached", "--no-renames", "--name-status"] + return parse_receipt_changes( + run_git([*command, "--", RECEIPT_DIR, LEGACY_RECEIPT_PATH], repo_root=repo_root) + ) + + def receipt_commits_in_range( base: str, head: str, *, repo_root: Path = REPO_ROOT, ) -> list[str]: - """Non-merge commits in base..head that author a receipt version. + """Non-merge commits in base..head that touch a receipt. - Merge commits are excluded: a merge resolution of the shared receipt keeps - or combines other commits' records without a browser run of its own, so it - can never be evidence. --full-history keeps receipt commits on merged side - branches that default history simplification would prune. + Merge commits are excluded: a merge resolution keeps or combines other + commits' records without a browser run of its own, so it can never be + evidence. --full-history keeps receipt commits on merged side branches + that default history simplification would prune. """ output = run_git( [ @@ -267,55 +436,104 @@ def receipt_commits_in_range( "--full-history", f"{base}..{head}", "--", - RECEIPT_PATH, + RECEIPT_DIR, + LEGACY_RECEIPT_PATH, ], repo_root=repo_root, ) return [line.strip() for line in output.splitlines() if line.strip()] +PRUNE_SEPARATELY = ( + "Git reads a deleted receipt beside a newly added one as a rename, and that " + "rename conflicts with any other change that deletes the same receipt. Prune " + f"landed receipts in a change that records none: {GUARD_COMMAND} --prune" +) + + +RETIRED_RECEIPT = ( + f"{LEGACY_RECEIPT_PATH} is retired and must not be written. Each commit now " + f"records its receipt in its own file under {RECEIPT_DIR}/, so concurrent " + "frontend changes no longer conflict." +) + + def range_receipt_coverage( base: str, head: str, *, repo_root: Path = REPO_ROOT, -) -> tuple[dict[str, set[str]], list[str]]: +) -> tuple[dict[str, set[str]], list[str], list[str]]: """Content digests verified by valid receipts authored inside base..head. Each receipt is validated exactly as the per-commit guard validates it at the commit that recorded it: bound to that commit's own parent, and its changed_paths and content_sha256 matching that commit's tree. A receipt - that fails contributes no coverage and is reported. + that fails contributes no coverage and is reported as a diagnostic. + + Violations are returned separately because they block the range even when + coverage is complete: a commit that both records and deletes receipts. """ covered: dict[str, set[str]] = {} diagnostics: list[str] = [] + violations: list[str] = [] for commit in receipt_commits_in_range(base, head, repo_root=repo_root): try: - payload = json.loads(load_receipt_text(commit=commit, repo_root=repo_root)) + changes = receipt_changes(commit=commit, repo_root=repo_root) commit_base = expected_base_sha(commit=commit, repo_root=repo_root) - except (json.JSONDecodeError, subprocess.CalledProcessError) as exc: - diagnostics.append(f"{commit[:12]}: unable to load receipt: {exc}") + except subprocess.CalledProcessError as exc: + diagnostics.append(f"{commit[:12]}: unable to read receipt changes: {exc}") continue - listed = payload.get("changed_paths") if isinstance(payload, dict) else None - listed_paths = ( - [path for path in listed if isinstance(path, str) and path] - if isinstance(listed, list) - else [] + recorded = sorted( + path for path, status in changes.items() + if status != "D" and path != LEGACY_RECEIPT_PATH ) - errors = validate_receipt( - payload, - changed_paths=listed_paths, - expected_base=commit_base, - expected_content_sha256=content_sha256( - listed_paths, commit=commit, repo_root=repo_root - ), - ) - if errors: - diagnostics.append(f"{commit[:12]}: receipt is invalid: " + "; ".join(errors)) - continue - for path, digest in payload["content_sha256"].items(): - covered.setdefault(path, set()).add(digest) - return covered, diagnostics + legacy_status = changes.get(LEGACY_RECEIPT_PATH) + if legacy_status == "M": + # The commit's own parent still carried the shared receipt, so it + # was verified before per-commit receipts and is honest evidence. + recorded.append(LEGACY_RECEIPT_PATH) + elif legacy_status == "A": + diagnostics.append( + f"{commit[:12]}: recreates the retired {LEGACY_RECEIPT_PATH}, which is not " + f"evidence; record the receipt under {RECEIPT_DIR}/ instead" + ) + if recorded and any( + status == "D" and path != LEGACY_RECEIPT_PATH for path, status in changes.items() + ): + violations.append( + f"{commit[:12]}: records a receipt and deletes another. " + PRUNE_SEPARATELY + ) + for receipt_path in recorded: + label = f"{commit[:12]} {PurePosixPath(receipt_path).name}" + try: + payload = json.loads( + load_receipt_text(receipt_path, commit=commit, repo_root=repo_root) + ) + except (json.JSONDecodeError, subprocess.CalledProcessError) as exc: + diagnostics.append(f"{label}: unable to load receipt: {exc}") + continue + listed = payload.get("changed_paths") if isinstance(payload, dict) else None + listed_paths = ( + [path for path in listed if isinstance(path, str) and path] + if isinstance(listed, list) + else [] + ) + errors = validate_receipt( + payload, + changed_paths=listed_paths, + expected_base=commit_base, + expected_content_sha256=content_sha256( + listed_paths, commit=commit, repo_root=repo_root + ), + receipt_path=receipt_path, + ) + if errors: + diagnostics.append(f"{label}: receipt is invalid: " + "; ".join(errors)) + continue + for path, digest in payload["content_sha256"].items(): + covered.setdefault(path, set()).add(digest) + return covered, diagnostics, violations def range_coverage_errors( @@ -324,9 +542,12 @@ def range_coverage_errors( base: str, head: str, repo_root: Path = REPO_ROOT, -) -> list[str]: +) -> tuple[list[str], list[str]]: """Changed frontend paths whose final content no receipt in range verified. + Returned beside the range's receipt-rule violations, which block on their + own. + Integration merges reviewed commits onto a main that keeps moving, so the range tip is usually a merge and the range carries several independently verified changes. Binding one tip receipt to the tip's parent and to the @@ -335,17 +556,17 @@ def range_coverage_errors( pass verified: its digest at head must equal the digest recorded by a valid receipt from a non-merge commit in the range. """ - covered, diagnostics = range_receipt_coverage(base, head, repo_root=repo_root) + covered, diagnostics, violations = range_receipt_coverage(base, head, repo_root=repo_root) final = content_sha256(changed_paths, commit=head, repo_root=repo_root) uncovered = [path for path in changed_paths if final[path] not in covered.get(path, set())] if not uncovered: - return [] + return [], violations errors = [ f"{path} final content {final[path][:12]} is not verified by any valid receipt in the range" for path in uncovered ] errors.extend(diagnostics) - return errors + return errors, violations def build_template(paths: Sequence[str], *, repo_root: Path = REPO_ROOT) -> dict: @@ -367,6 +588,310 @@ def build_template(paths: Sequence[str], *, repo_root: Path = REPO_ROOT) -> dict } +def emit_template(paths: Sequence[str], *, write: bool, repo_root: Path = REPO_ROOT) -> int: + template = build_template(paths, repo_root=repo_root) + if not template["changed_paths"]: + if write: + return blocked( + "no user-visible frontend source is staged, so there is no receipt to write." + ) + print(json.dumps(template, indent=2)) + return 0 + receipt_path = receipt_path_for(template["content_sha256"]) + text = json.dumps(template, indent=2) + "\n" + if not write: + print(text, end="") + print(f"Save the completed receipt as {receipt_path} and stage it.", file=sys.stderr) + return 0 + target = repo_root / receipt_path + if target.exists(): + return blocked(f"{receipt_path} already exists; edit it rather than overwriting a receipt.") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + print(receipt_path) + return 0 + + +def prune_landed_receipts(landed_ref: str, *, repo_root: Path = REPO_ROOT) -> int: + """Delete receipts whose commits are already on landed_ref. + + A receipt is read from the commit that recorded it, so once that commit is + on main the file in the tree is dead weight. Only a branch that records no + receipt of its own may prune (see PRUNE_SEPARATELY). + """ + try: + merge_base = run_git(["merge-base", landed_ref, "HEAD"], repo_root=repo_root) + branch_changes = parse_receipt_changes( + run_git( + ["diff", "--no-renames", "--name-status", merge_base, "HEAD", "--", RECEIPT_DIR], + repo_root=repo_root, + ) + ) + staged_changes = receipt_changes(commit=None, repo_root=repo_root) + recorded = sorted( + path + for changes in (branch_changes, staged_changes) + for path, status in changes.items() + if status != "D" and path != LEGACY_RECEIPT_PATH + ) + if recorded: + return blocked( + f"this branch records {len(recorded)} receipt(s) of its own " + f"({', '.join(PurePosixPath(path).name for path in recorded)}).", + advice=[PRUNE_SEPARATELY], + ) + landed = blob_ids(landed_ref, [RECEIPT_DIR], repo_root=repo_root) + prunable = sorted( + path + for path, blob in blob_ids(None, [RECEIPT_DIR], repo_root=repo_root).items() + if is_receipt_path(path) and landed.get(path) == blob + ) + for start in range(0, len(prunable), 200): + run_git( + ["--literal-pathspecs", "rm", "--quiet", "--", *prunable[start : start + 200]], + repo_root=repo_root, + ) + except subprocess.CalledProcessError as exc: + return blocked(f"unable to prune browser verification receipts: {exc}") + if not prunable: + print(f"No receipts on {landed_ref} to prune.") + return 0 + print( + f"Pruned {len(prunable)} receipt(s) already on {landed_ref}. " + "Commit the staged deletions; they need no receipt." + ) + return 0 + + +def check_commit( + paths: Sequence[str], + *, + commit: str | None, + repo_root: Path = REPO_ROOT, +) -> int: + """Validate one commit, or the staged index, against its own receipt.""" + try: + merging = bool(merge_parents(commit=commit, repo_root=repo_root)) + changes = receipt_changes(commit=commit, repo_root=repo_root) + frontend_paths = frontend_runtime_paths(paths) + inherited = merged_in_paths( + sorted({*changes, *frontend_paths}), commit=commit, repo_root=repo_root + ) + except subprocess.CalledProcessError as exc: + return blocked(f"unable to inspect browser verification state: {exc}") + + changes = {path: status for path, status in changes.items() if path not in inherited} + if changes.get(LEGACY_RECEIPT_PATH, "D") != "D": + return blocked( + RETIRED_RECEIPT, + advice=[ + "While concluding a merge, take the merged parent's version: " + f"git checkout MERGE_HEAD -- {LEGACY_RECEIPT_PATH}" + if merging + else f"Unstage it and run: {GUARD_COMMAND} --write-template" + ], + ) + frontend_paths = [path for path in frontend_paths if path not in inherited] + recorded = sorted( + path for path, status in changes.items() if status != "D" and path != LEGACY_RECEIPT_PATH + ) + pruned = sorted( + path for path, status in changes.items() if status == "D" and path != LEGACY_RECEIPT_PATH + ) + + if frontend_paths: + reformatted = formatting_only_paths(frontend_paths, commit=commit, repo_root=repo_root) + if reformatted: + print( + f"Browser verification guard: {len(reformatted)} path(s) are prettier-only " + "reformats of their committed content, with no visual delta to verify." + ) + frontend_paths = [path for path in frontend_paths if path not in reformatted] + + if merging: + if recorded: + return blocked( + "a merge commit cannot record browser evidence.", + details=recorded, + advice=[ + "A merge resolution has no browser run behind it, so range validation " + "ignores receipts a merge commit writes.", + "Take the merged parent's version of each file (git checkout MERGE_HEAD -- " + ") or unstage it, conclude the merge, then record fresh evidence in a " + "follow-up commit.", + ], + ) + if frontend_paths: + print( + f"Browser verification guard: {len(frontend_paths)} frontend source file(s) " + "carry merged content that neither parent holds:", + file=sys.stderr, + ) + for path in frontend_paths: + print(f" - {path}", file=sys.stderr) + print( + "The merge may be concluded, but that content has had no browser pass. Verify " + "it, then record a receipt in a follow-up commit, or range validation will " + "block the change:\n" + f" printf '%s\\n' {' '.join(frontend_paths)} | {GUARD_COMMAND} " + "--files-from-stdin --write-template", + file=sys.stderr, + ) + return 0 + print( + "Browser verification guard skipped (the merge changes no user-visible frontend " + "source beyond what its parents already carry)." + ) + return 0 + + if recorded and pruned: + return blocked( + "a commit that records a receipt must not delete another.", + details=pruned, + advice=[PRUNE_SEPARATELY], + ) + + if not frontend_paths: + if not recorded: + print("Browser verification guard skipped (no user-visible frontend source changes).") + return 0 + # A proof commit that follows its source change. Everything except the + # parent binding is checkable here; range mode binds the parent. + failures: list[str] = [] + for receipt_path in recorded: + try: + payload = json.loads( + load_receipt_text(receipt_path, commit=commit, repo_root=repo_root) + ) + except (json.JSONDecodeError, subprocess.CalledProcessError) as exc: + failures.append(f"{receipt_path}: unable to load receipt: {exc}") + continue + listed = payload.get("changed_paths") if isinstance(payload, dict) else None + listed_paths = ( + [path for path in listed if isinstance(path, str) and path] + if isinstance(listed, list) + else [] + ) + failures.extend( + f"{receipt_path}: {error}" + for error in validate_receipt( + payload, + changed_paths=listed_paths, + expected_base=None, + expected_content_sha256=content_sha256( + listed_paths, commit=commit, repo_root=repo_root + ), + receipt_path=receipt_path, + ) + ) + if failures: + return blocked("browser verification receipt is invalid:", details=failures) + print( + f"Browser verification guard passed ({len(recorded)} receipt(s) recorded without " + "frontend source changes; range validation binds each to its parent)." + ) + return 0 + + try: + digests = content_sha256(frontend_paths, commit=commit, repo_root=repo_root) + expected_base = expected_base_sha(commit=commit, repo_root=repo_root) + except subprocess.CalledProcessError as exc: + return blocked(f"unable to load browser verification receipt: {exc}") + receipt_path = receipt_path_for(digests) + strays = [path for path in recorded if path != receipt_path] + if receipt_path not in recorded: + advice = [ + "Run the current build in a browser, exercise the complete interaction matrix, " + "then record routes, desktop and narrow viewports, states, and interactions.", + f"Start from a skeleton at that path with: {GUARD_COMMAND} --write-template", + ] + if strays: + advice.append( + "A receipt's file name is the fingerprint of the content it verified. " + f"{', '.join(strays)} verifies different content: the staged source changed " + "after it was written, or it was named by hand." + ) + return blocked( + f"{receipt_path} must be added and staged for user-visible frontend changes.", + advice=advice, + ) + if strays: + return blocked( + f"a commit records exactly one receipt, {receipt_path}.", + details=strays, + advice=["Unstage the others; one receipt covers every frontend file the commit changes."], + ) + + try: + payload = json.loads(load_receipt_text(receipt_path, commit=commit, repo_root=repo_root)) + except (json.JSONDecodeError, subprocess.CalledProcessError) as exc: + return blocked(f"unable to load browser verification receipt: {exc}") + + errors = validate_receipt( + payload, + changed_paths=frontend_paths, + expected_base=expected_base, + expected_content_sha256=digests, + receipt_path=receipt_path, + ) + if errors: + return blocked("browser verification receipt is invalid:", details=errors) + + print( + "Browser verification guard passed " + f"({len(frontend_paths)} frontend source file(s), " + f"{len(payload['routes'])} route(s), {len(payload['viewports'])} viewport(s))." + ) + return 0 + + +def check_range(paths: Sequence[str], *, base: str, head: str, repo_root: Path = REPO_ROOT) -> int: + """Validate base..head: violations block always, coverage when frontend source changed.""" + changed_frontend_paths = frontend_runtime_paths(paths) + if changed_frontend_paths: + reformatted = formatting_only_paths( + changed_frontend_paths, commit=head, base=base, repo_root=repo_root + ) + if reformatted: + print( + f"Browser verification guard: {len(reformatted)} path(s) are prettier-only " + "reformats of their committed content, with no visual delta to verify." + ) + changed_frontend_paths = [ + path for path in changed_frontend_paths if path not in reformatted + ] + try: + errors, violations = range_coverage_errors( + changed_frontend_paths, base=base, head=head, repo_root=repo_root + ) + except subprocess.CalledProcessError as exc: + return blocked(f"unable to evaluate browser verification range: {exc}") + if errors: + return blocked( + f"browser verification does not cover {base[:12]}..{head[:12]}:", + details=[*errors, *violations], + advice=[ + "Content changed after its browser pass (a correction, a conflict resolution, " + "or two changes to one file) needs a fresh receipt for the final content, " + "committed in its own non-merge commit and bound to that commit's parent." + ], + ) + if violations: + return blocked( + f"browser verification receipts in {base[:12]}..{head[:12]} break the receipt rules:", + details=violations, + ) + if not changed_frontend_paths: + print("Browser verification guard skipped (no user-visible frontend source changes).") + return 0 + print( + "Browser verification guard passed " + f"({len(changed_frontend_paths)} frontend source file(s) across " + f"{base[:12]}..{head[:12]} covered by in-range receipts)." + ) + return 0 + + def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -376,7 +901,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: ) parser.add_argument( "--commit", - help="Validate the receipt stored in this commit against that commit's parent.", + help="Validate the receipt recorded by this commit against that commit's parent.", ) parser.add_argument( "--base", @@ -391,6 +916,27 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: action="store_true", help="Print a non-passing receipt template for the current staged frontend paths.", ) + parser.add_argument( + "--write-template", + action="store_true", + help=( + "Write that template to the receipt file the staged frontend content " + "requires, and print its path." + ), + ) + parser.add_argument( + "--prune", + action="store_true", + help=( + "Delete and stage the removal of receipts whose commits are already on " + "--landed-on. Run it on a branch that records no receipt of its own." + ), + ) + parser.add_argument( + "--landed-on", + default=DEFAULT_LANDED_REF, + help=f"Ref that --prune treats as landed (default: {DEFAULT_LANDED_REF}).", + ) args = parser.parse_args(argv) if args.base and not args.commit: parser.error("--base requires --commit") @@ -399,6 +945,9 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: def main(argv: Sequence[str] | None = None) -> int: args = parse_args(argv) + if args.prune: + return prune_landed_receipts(args.landed_on, repo_root=REPO_ROOT) + if args.files_from_stdin: paths = stdin_files(sys.stdin) elif args.base: @@ -409,102 +958,14 @@ def main(argv: Sequence[str] | None = None) -> int: ).splitlines() ) else: - paths = staged_files() + paths = staged_files(repo_root=REPO_ROOT) - if args.print_template: - print(json.dumps(build_template(paths), indent=2)) - return 0 - - changed_frontend_paths = frontend_runtime_paths(paths) - if changed_frontend_paths: - reformatted = formatting_only_paths( - changed_frontend_paths, commit=args.commit, base=args.base, repo_root=REPO_ROOT - ) - if reformatted: - print( - f"Browser verification guard: {len(reformatted)} path(s) are prettier-only " - "reformats of their committed content, with no visual delta to verify." - ) - changed_frontend_paths = [ - path for path in changed_frontend_paths if path not in reformatted - ] - - if not changed_frontend_paths: - print("Browser verification guard skipped (no user-visible frontend source changes).") - return 0 + if args.print_template or args.write_template: + return emit_template(paths, write=args.write_template, repo_root=REPO_ROOT) if args.base: - try: - errors = range_coverage_errors( - changed_frontend_paths, - base=args.base, - head=args.commit, - repo_root=REPO_ROOT, - ) - except subprocess.CalledProcessError as exc: - print(f"BLOCKED: unable to evaluate browser verification range: {exc}", file=sys.stderr) - return 1 - if errors: - print( - f"BLOCKED: browser verification does not cover {args.base[:12]}..{args.commit[:12]}:", - file=sys.stderr, - ) - for error in errors: - print(f" - {error}", file=sys.stderr) - print( - "Content changed after its browser pass (a correction, a conflict resolution, " - "or two changes to one file) needs a fresh receipt for the final content, " - "committed in its own non-merge commit and bound to that commit's parent.", - file=sys.stderr, - ) - return 1 - print( - "Browser verification guard passed " - f"({len(changed_frontend_paths)} frontend source file(s) across " - f"{args.base[:12]}..{args.commit[:12]} covered by in-range receipts)." - ) - return 0 - - if RECEIPT_PATH not in paths: - print( - f"BLOCKED: {RECEIPT_PATH} must be updated and staged for user-visible frontend changes.", - file=sys.stderr, - ) - print( - "Run the current build in a browser, exercise the complete interaction matrix, " - "then record routes, desktop and narrow viewports, states, and interactions.", - file=sys.stderr, - ) - return 1 - - try: - payload = json.loads(load_receipt_text(commit=args.commit)) - expected_base = expected_base_sha(commit=args.commit) - except (json.JSONDecodeError, subprocess.CalledProcessError) as exc: - print(f"BLOCKED: unable to load browser verification receipt: {exc}", file=sys.stderr) - return 1 - - errors = validate_receipt( - payload, - changed_paths=changed_frontend_paths, - expected_base=expected_base, - expected_content_sha256=content_sha256( - changed_frontend_paths, - commit=args.commit, - ), - ) - if errors: - print("BLOCKED: browser verification receipt is invalid:", file=sys.stderr) - for error in errors: - print(f" - {error}", file=sys.stderr) - return 1 - - print( - "Browser verification guard passed " - f"({len(changed_frontend_paths)} frontend source file(s), " - f"{len(payload['routes'])} route(s), {len(payload['viewports'])} viewport(s))." - ) - return 0 + return check_range(paths, base=args.base, head=args.commit, repo_root=REPO_ROOT) + return check_commit(paths, commit=args.commit, repo_root=REPO_ROOT) if __name__ == "__main__": diff --git a/scripts/release_control/browser_verification_guard_test.py b/scripts/release_control/browser_verification_guard_test.py index b0076a6728..7244377fef 100644 --- a/scripts/release_control/browser_verification_guard_test.py +++ b/scripts/release_control/browser_verification_guard_test.py @@ -14,10 +14,13 @@ import unittest from unittest.mock import patch from browser_verification_guard import ( - RECEIPT_PATH, + LEGACY_RECEIPT_PATH, + RECEIPT_DIR, formatting_only_paths, frontend_runtime_paths, + is_receipt_path, main, + receipt_path_for, validate_receipt, ) from format_staged_frontend_test import REAL_PRETTIER @@ -26,6 +29,7 @@ from repo_file_io import strip_local_git_env BASE_SHA = "a" * 40 CHANGED_PATH = "frontend-modern/src/components/Example.tsx" +OTHER_PATH = "frontend-modern/src/components/Other.tsx" CONTENT_SHA = "c" * 64 REPO_ROOT = Path(__file__).resolve().parents[2] @@ -57,13 +61,6 @@ class BrowserVerificationGuardTest(unittest.TestCase): self.assertLess(formatter, guard) - def test_blocks_frontend_change_when_receipt_is_not_in_commit(self) -> None: - with ( - patch("sys.stdin", StringIO(CHANGED_PATH + "\n")), - patch("sys.stderr", new=StringIO()), - ): - self.assertEqual(main(["--files-from-stdin"]), 1) - def test_frontend_runtime_paths_exclude_tests_and_receipt(self) -> None: self.assertEqual( frontend_runtime_paths( @@ -71,7 +68,8 @@ class BrowserVerificationGuardTest(unittest.TestCase): CHANGED_PATH, "frontend-modern/src/components/Example.test.tsx", "frontend-modern/src/components/__tests__/Example.tsx", - RECEIPT_PATH, + LEGACY_RECEIPT_PATH, + receipt_path_for({CHANGED_PATH: CONTENT_SHA}), "frontend-modern/index.html", "internal/api/server.go", ] @@ -134,6 +132,52 @@ class BrowserVerificationGuardTest(unittest.TestCase): self.assertTrue(any("content_sha256" in error for error in errors)) + def test_receipt_name_is_the_fingerprint_of_the_content_it_verified(self) -> None: + digests = {CHANGED_PATH: CONTENT_SHA, OTHER_PATH: "d" * 64} + path = receipt_path_for(digests) + + self.assertTrue(is_receipt_path(path)) + self.assertEqual(path, receipt_path_for(dict(reversed(digests.items())))) + self.assertNotEqual(path, receipt_path_for({**digests, OTHER_PATH: "e" * 64})) + self.assertFalse(is_receipt_path(LEGACY_RECEIPT_PATH)) + self.assertFalse(is_receipt_path(f"{RECEIPT_DIR}/README.md")) + self.assertFalse(is_receipt_path(f"{RECEIPT_DIR}/nested/receipt.json")) + + def test_rejects_receipt_stored_under_a_hand_picked_name(self) -> None: + errors = validate_receipt( + valid_receipt(), + changed_paths=[CHANGED_PATH], + expected_base=BASE_SHA, + expected_content_sha256={CHANGED_PATH: CONTENT_SHA}, + receipt_path=f"{RECEIPT_DIR}/storage-fix.json", + ) + + self.assertEqual(len(errors), 1) + self.assertIn(receipt_path_for({CHANGED_PATH: CONTENT_SHA}), errors[0]) + + def test_proof_only_receipt_still_needs_a_real_parent_id(self) -> None: + receipt = valid_receipt() + receipt["base_sha"] = "HEAD" + + errors = validate_receipt( + receipt, + changed_paths=[CHANGED_PATH], + expected_base=None, + expected_content_sha256={CHANGED_PATH: CONTENT_SHA}, + ) + + self.assertTrue(any("base_sha" in error for error in errors)) + receipt["base_sha"] = BASE_SHA + self.assertEqual( + validate_receipt( + receipt, + changed_paths=[CHANGED_PATH], + expected_base=None, + expected_content_sha256={CHANGED_PATH: CONTENT_SHA}, + ), + [], + ) + class FormattingOnlyExemptionTest(unittest.TestCase): """A prettier sweep has no visual delta, but a real edit must still block.""" @@ -195,22 +239,15 @@ class FormattingOnlyExemptionTest(unittest.TestCase): self.assertEqual(self.resolve(repo_root), set()) -OTHER_PATH = "frontend-modern/src/components/Other.tsx" - - -class IntegrationRangeTest(unittest.TestCase): - """--base validates a merged integration range, not one tip receipt. - - The maintainer coordinator merges reviewed candidates onto a main that - keeps moving, so the range tip is a merge whose parent no lane receipt can - name. Each frontend file must still ship with content a browser pass - verified, recorded by a non-merge commit bound to its own parent. - """ +class GuardRepoTestCase(unittest.TestCase): + """A throwaway repository the guard runs against instead of this one.""" def setUp(self) -> None: self.tmpdir = tempfile.TemporaryDirectory() self.addCleanup(self.tmpdir.cleanup) self.repo_root = Path(self.tmpdir.name) + # Under the pre-commit hook, GIT_DIR and GIT_INDEX_FILE are exported + # and would point this temp repo's plumbing at the real repository. self.env = strip_local_git_env(os.environ.copy()) self.env["PULSE_PRETTIER_BIN"] = str(self.repo_root / "missing-prettier") self.git("init", "--quiet", "--initial-branch=main") @@ -238,7 +275,7 @@ class IntegrationRangeTest(unittest.TestCase): self.git("commit", "--quiet", "--allow-empty", "-m", message) return self.git("rev-parse", "HEAD") - def write_receipt(self, paths: list[str], *, base: str | None = None) -> None: + def build_receipt(self, paths: list[str], *, base: str | None = None) -> dict: receipt = valid_receipt() receipt["base_sha"] = base or self.git("rev-parse", "HEAD") receipt["changed_paths"] = paths @@ -246,23 +283,259 @@ class IntegrationRangeTest(unittest.TestCase): path: hashlib.sha256((self.repo_root / path).read_bytes()).hexdigest() for path in paths } - self.write(RECEIPT_PATH, json.dumps(receipt, indent=2) + "\n") + return receipt + + def write_receipt( + self, paths: list[str], *, base: str | None = None, at: str | None = None + ) -> str: + receipt = self.build_receipt(paths, base=base) + receipt_path = at or receipt_path_for(receipt["content_sha256"]) + self.write(receipt_path, json.dumps(receipt, indent=2) + "\n") + return receipt_path def verified_change(self, path: str, text: str, message: str) -> str: self.write(path, text) self.write_receipt([path]) return self.commit(message) - def run_range(self, *extra: str) -> tuple[int, str]: - stderr = StringIO() + def run_guard(self, *argv: str, stdin: str = "") -> tuple[int, str, str]: + stdout, stderr = StringIO(), StringIO() with ( patch.dict("os.environ", self.env, clear=True), patch("browser_verification_guard.REPO_ROOT", self.repo_root), - patch("sys.stdout", new=StringIO()), + patch("sys.stdin", StringIO(stdin)), + patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr), ): - status = main(["--base", self.base, "--commit", self.git("rev-parse", "HEAD"), *extra]) - return status, stderr.getvalue() + status = main(list(argv)) + return status, stdout.getvalue(), stderr.getvalue() + + def run_range(self, *extra: str, base: str | None = None) -> tuple[int, str]: + status, _, stderr = self.run_guard( + "--base", base or self.base, "--commit", self.git("rev-parse", "HEAD"), *extra + ) + return status, stderr + + def receipts_in_tree(self, revision: str = "HEAD") -> list[str]: + listing = self.git("ls-tree", "-r", "--name-only", revision, "--", RECEIPT_DIR) + return [line for line in listing.splitlines() if line] + + +class StagedIndexTest(GuardRepoTestCase): + """The pre-commit form: one commit owes exactly one receipt of its own.""" + + def stage_change(self) -> str: + self.write(CHANGED_PATH, "export const a = 1;\n") + self.git("add", CHANGED_PATH) + return receipt_path_for(self.build_receipt([CHANGED_PATH])["content_sha256"]) + + def test_blocks_frontend_change_until_its_receipt_is_staged(self) -> None: + expected = self.stage_change() + + status, _, stderr = self.run_guard() + + self.assertEqual(status, 1) + self.assertIn(f"{expected} must be added and staged", stderr) + self.assertIn("--write-template", stderr) + + def test_stdin_path_list_is_held_to_the_same_receipt(self) -> None: + self.assertEqual(self.run_guard("--files-from-stdin", stdin=CHANGED_PATH + "\n")[0], 1) + + def test_accepts_receipt_staged_at_its_fingerprint_path(self) -> None: + self.stage_change() + self.git("add", self.write_receipt([CHANGED_PATH])) + + status, stdout, stderr = self.run_guard() + + self.assertEqual((status, stderr), (0, "")) + self.assertIn("1 frontend source file(s)", stdout) + + def test_write_template_creates_the_owed_receipt_but_never_a_passing_one(self) -> None: + expected = self.stage_change() + + status, stdout, _ = self.run_guard("--write-template") + self.assertEqual((status, stdout.strip()), (0, expected)) + self.git("add", expected) + status, _, stderr = self.run_guard() + self.assertEqual(status, 1) + self.assertIn('result must be "passed"', stderr) + + # A completed receipt is never overwritten by a second template. + self.assertEqual(self.run_guard("--write-template")[0], 1) + self.git("add", self.write_receipt([CHANGED_PATH])) + self.assertEqual(self.run_guard()[0], 0) + + def test_print_template_names_the_file_to_save(self) -> None: + expected = self.stage_change() + + status, stdout, stderr = self.run_guard("--print-template") + + self.assertEqual(status, 0) + self.assertEqual(json.loads(stdout)["changed_paths"], [CHANGED_PATH]) + self.assertIn(expected, stderr) + self.assertFalse((self.repo_root / expected).exists()) + + def test_write_template_refuses_when_nothing_needs_verifying(self) -> None: + self.assertEqual(self.run_guard("--write-template")[0], 1) + self.assertEqual(self.receipts_in_tree(), []) + + def test_blocks_receipt_named_by_hand(self) -> None: + expected = self.stage_change() + self.git("add", self.write_receipt([CHANGED_PATH], at=f"{RECEIPT_DIR}/storage-fix.json")) + + status, _, stderr = self.run_guard() + + self.assertEqual(status, 1) + self.assertIn(f"{expected} must be added and staged", stderr) + self.assertIn("storage-fix.json verifies different content", stderr) + + def test_blocks_source_edited_after_its_receipt_was_written(self) -> None: + self.stage_change() + stale = self.write_receipt([CHANGED_PATH]) + self.write(CHANGED_PATH, "export const a = 2;\n") + self.git("add", CHANGED_PATH, stale) + + status, _, stderr = self.run_guard() + + self.assertEqual(status, 1) + self.assertIn("the staged source changed after it was written", stderr) + + def test_blocks_a_second_receipt_in_one_commit(self) -> None: + self.stage_change() + self.git("add", self.write_receipt([CHANGED_PATH])) + self.git("add", self.write_receipt([OTHER_PATH])) + + status, _, stderr = self.run_guard() + + self.assertEqual(status, 1) + self.assertIn("exactly one receipt", stderr) + + def test_blocks_writing_the_retired_shared_receipt(self) -> None: + self.stage_change() + self.git("add", self.write_receipt([CHANGED_PATH], at=LEGACY_RECEIPT_PATH)) + + status, _, stderr = self.run_guard() + + self.assertEqual(status, 1) + self.assertIn(f"{LEGACY_RECEIPT_PATH} is retired", stderr) + + def test_blocks_recording_and_pruning_in_one_commit(self) -> None: + landed = self.verified_change(OTHER_PATH, "export const b = 1;\n", "landed") + self.assertTrue(landed) + (old,) = self.receipts_in_tree() + self.stage_change() + self.git("add", self.write_receipt([CHANGED_PATH])) + self.git("rm", "--quiet", old) + + status, _, stderr = self.run_guard() + + self.assertEqual(status, 1) + self.assertIn("must not delete another", stderr) + self.assertIn("--prune", stderr) + + def test_proof_only_commit_is_checked_against_the_content_it_names(self) -> None: + self.write(CHANGED_PATH, "export const a = 1;\n") + self.commit("frontend change without proof") + receipt_path = self.write_receipt([CHANGED_PATH]) + self.git("add", receipt_path) + self.assertEqual(self.run_guard()[0], 0) + + payload = json.loads((self.repo_root / receipt_path).read_text()) + payload["viewports"] = [{"width": 1280, "height": 800}] + self.write(receipt_path, json.dumps(payload)) + self.git("add", receipt_path) + status, _, stderr = self.run_guard() + self.assertEqual(status, 1) + self.assertIn("narrow width", stderr) + + def test_pruning_alone_needs_no_receipt(self) -> None: + self.verified_change(OTHER_PATH, "export const b = 1;\n", "landed") + self.git("rm", "--quiet", *self.receipts_in_tree()) + + self.assertEqual(self.run_guard()[0], 0) + + +class ConcludingMergeTest(GuardRepoTestCase): + """A merge is not asked to vouch for what its other parent already verified. + + With one shared receipt, concluding `git merge main` staged main's + frontend files as if the author had changed them, so the guard demanded a + receipt for files the author never ran and a branch behind main could only + be rebuilt and re-verified. + """ + + def start_merge(self, *, ours: tuple[str, str], theirs: tuple[str, str]) -> None: + self.git("checkout", "--quiet", "-b", "topic", self.base) + self.verified_change(*ours, "topic change") + self.git("checkout", "--quiet", "main") + self.verified_change(*theirs, "main moves") + self.git("checkout", "--quiet", "topic") + subprocess.run( + ["git", "-c", "user.email=t@example.com", "-c", "user.name=t", + "merge", "--quiet", "--no-ff", "--no-commit", "main"], + cwd=self.repo_root, capture_output=True, env=self.env, + ) + + def test_passes_frontend_work_the_merged_parent_already_carries(self) -> None: + self.start_merge( + ours=(CHANGED_PATH, "export const a = 1;\n"), + theirs=(OTHER_PATH, "export const b = 1;\n"), + ) + self.assertIn(OTHER_PATH, self.git("diff", "--cached", "--name-only")) + + status, stdout, stderr = self.run_guard() + + self.assertEqual((status, stderr), (0, "")) + self.assertIn("beyond what its parents already carry", stdout) + self.commit("merge main") + self.assertEqual(self.run_range(base=self.git("rev-parse", "main"))[0], 0) + + def test_flags_merged_content_and_range_holds_it_to_a_follow_up_receipt(self) -> None: + self.start_merge( + ours=(CHANGED_PATH, "export const a = 1;\n"), + theirs=(CHANGED_PATH, "export const a = 2;\n"), + ) + self.write(CHANGED_PATH, "export const a = 3;\n") + self.git("add", CHANGED_PATH) + + status, _, stderr = self.run_guard() + self.assertEqual(status, 0) + self.assertIn(CHANGED_PATH, stderr) + self.assertIn("no browser pass", stderr) + + self.commit("merge main") + main_tip = self.git("rev-parse", "main") + status, stderr = self.run_range(base=main_tip) + self.assertEqual(status, 1) + self.assertIn(CHANGED_PATH, stderr) + + self.git("add", self.write_receipt([CHANGED_PATH])) + self.assertEqual(self.run_guard()[0], 0) + self.commit("record proof for the merged content") + self.assertEqual(self.run_range(base=main_tip)[0], 0) + + def test_blocks_a_receipt_written_inside_the_merge(self) -> None: + self.start_merge( + ours=(CHANGED_PATH, "export const a = 1;\n"), + theirs=(CHANGED_PATH, "export const a = 2;\n"), + ) + self.write(CHANGED_PATH, "export const a = 3;\n") + self.git("add", CHANGED_PATH, self.write_receipt([CHANGED_PATH])) + + status, _, stderr = self.run_guard() + + self.assertEqual(status, 1) + self.assertIn("a merge commit cannot record browser evidence", stderr) + + +class IntegrationRangeTest(GuardRepoTestCase): + """--base validates a merged integration range, not one tip receipt. + + The maintainer coordinator merges reviewed candidates onto a main that + keeps moving, so the range tip is a merge whose parent no lane receipt can + name. Each frontend file must still ship with content a browser pass + verified, recorded by a non-merge commit bound to its own parent. + """ def merge_advanced_main_into_candidate(self) -> None: # main advances with its own verified frontend change while the @@ -272,15 +545,11 @@ class IntegrationRangeTest(unittest.TestCase): self.verified_change(CHANGED_PATH, "export const a = 1;\n", "candidate") self.git("checkout", "--quiet", "main") self.verified_change(OTHER_PATH, "export const b = 1;\n", "upstream") - # Both sides edited the shared receipt; the resolution keeps one side. - subprocess.run( - ["git", "-c", "user.email=t@example.com", "-c", "user.name=t", - "merge", "--quiet", "--no-ff", "candidate", "-m", "integrate"], - cwd=self.repo_root, capture_output=True, env=self.env, - ) - self.git("checkout", "--quiet", "--theirs", RECEIPT_PATH) - self.commit("integrate candidate") + # Each side added a receipt file of its own, so git merges them + # unaided; self.git raises if the merge stops on a conflict. + self.git("merge", "--quiet", "--no-ff", "candidate", "-m", "integrate candidate") self.assertEqual(len(self.git("log", "-1", "--format=%P").split()), 2) + self.assertEqual(len(self.receipts_in_tree()), 2) def test_accepts_merge_of_verified_candidate_onto_advanced_main(self) -> None: self.merge_advanced_main_into_candidate() @@ -309,14 +578,15 @@ class IntegrationRangeTest(unittest.TestCase): def test_workflow_accepts_additive_receipt_correction_but_not_unverified_edit(self) -> None: self.write(CHANGED_PATH, "export const a = 1;\n") - self.write_receipt([CHANGED_PATH]) - payload = json.loads((self.repo_root / RECEIPT_PATH).read_text()) + receipt_path = self.write_receipt([CHANGED_PATH]) + payload = json.loads((self.repo_root / receipt_path).read_text()) payload["verified_at"] = "2026-10-02T13:00:00+00:00" - self.write(RECEIPT_PATH, json.dumps(payload)) + self.write(receipt_path, json.dumps(payload)) original = self.commit("frontend with malformed timestamp") self.assertNotEqual(self.run_workflow_browser_step().returncode, 0) - self.write_receipt([CHANGED_PATH]) + # Same verified content, so the correction edits the same file. + self.assertEqual(self.write_receipt([CHANGED_PATH]), receipt_path) self.commit("correct receipt without rewriting source history") corrected = self.run_workflow_browser_step() self.assertEqual(corrected.returncode, 0, corrected.stdout + corrected.stderr) @@ -382,6 +652,163 @@ class IntegrationRangeTest(unittest.TestCase): with patch("sys.stderr", new=StringIO()), self.assertRaises(SystemExit): main(["--base", self.base]) + def test_second_branch_merges_cleanly_after_the_first_and_its_range_passes(self) -> None: + # Two pull requests cut from the same main, touching different + # frontend files. With one shared receipt the second always conflicted + # once the first merged. + for branch, path in (("first", CHANGED_PATH), ("second", OTHER_PATH)): + self.git("checkout", "--quiet", "-b", branch, self.base) + self.verified_change(path, "export const changed = 1;\n", branch) + self.git("checkout", "--quiet", "main") + self.git("merge", "--quiet", "--no-ff", "first", "-m", "merge first") + main_after_first = self.git("rev-parse", "HEAD") + self.assertEqual(self.run_range()[0], 0) + + self.git("merge", "--quiet", "--no-ff", "second", "-m", "merge second") + + self.assertEqual(self.run_range(base=main_after_first), (0, "")) + self.assertEqual(len(self.receipts_in_tree()), 2) + + def test_edits_to_one_file_that_git_combines_still_need_their_own_proof(self) -> None: + # Receipts no longer conflict, so two pull requests editing different + # parts of one file both merge. Neither browser pass saw the combined + # content, and range validation of the landed merge must say so. + lines = ["export const a = 0;", "", "", "", "export const z = 0;", ""] + self.write(CHANGED_PATH, "\n".join(lines)) + seeded = self.commit("multi-line source") + for branch, index in (("first", 0), ("second", 4)): + self.git("checkout", "--quiet", "-b", branch, seeded) + edited = list(lines) + edited[index] = edited[index].replace("0", "1") + self.verified_change(CHANGED_PATH, "\n".join(edited), branch) + self.git("checkout", "--quiet", "main") + self.git("merge", "--quiet", "--no-ff", "first", "-m", "merge first") + main_after_first = self.git("rev-parse", "HEAD") + self.git("merge", "--quiet", "--no-ff", "second", "-m", "merge second") + + status, stderr = self.run_range(base=main_after_first) + + self.assertEqual(status, 1) + self.assertIn(f"{CHANGED_PATH} final content", stderr) + + self.write_receipt([CHANGED_PATH]) + self.commit("record proof for the combined content") + self.assertEqual(self.run_range(base=main_after_first)[0], 0) + + def test_receipt_named_by_hand_is_not_evidence(self) -> None: + self.write(CHANGED_PATH, "export const a = 1;\n") + self.write_receipt([CHANGED_PATH], at=f"{RECEIPT_DIR}/storage-fix.json") + self.commit("hand-named proof") + + status, stderr = self.run_range() + + self.assertEqual(status, 1) + self.assertIn("receipt must be stored at", stderr) + + def test_commit_that_records_and_prunes_blocks_a_fully_covered_range(self) -> None: + self.verified_change(OTHER_PATH, "export const b = 1;\n", "landed") + landed = self.git("rev-parse", "HEAD") + (old,) = self.receipts_in_tree() + self.write(CHANGED_PATH, "export const a = 1;\n") + self.write_receipt([CHANGED_PATH]) + self.git("rm", "--quiet", old) + self.commit("verified change that also prunes") + + status, stderr = self.run_range(base=landed) + + self.assertEqual(status, 1) + self.assertIn("records a receipt and deletes another", stderr) + self.assertNotIn("is not verified", stderr) + + +class RetiredSharedReceiptTest(GuardRepoTestCase): + """Work verified against the shared receipt keeps its evidence; nothing new may write it.""" + + def setUp(self) -> None: + super().setUp() + self.write_receipt([CHANGED_PATH], at=LEGACY_RECEIPT_PATH) + self.base = self.commit("main while the shared receipt was current") + + def shared_receipt_change(self, path: str, text: str, message: str) -> str: + self.write(path, text) + self.write_receipt([path], at=LEGACY_RECEIPT_PATH) + return self.commit(message) + + def test_in_flight_commit_is_repaired_by_merging_main(self) -> None: + self.git("checkout", "--quiet", "-b", "in-flight", self.base) + self.shared_receipt_change(CHANGED_PATH, "export const a = 1;\n", "in flight") + self.git("checkout", "--quiet", "main") + landed = self.shared_receipt_change(OTHER_PATH, "export const b = 1;\n", "landed first") + + self.git("checkout", "--quiet", "in-flight") + merge = subprocess.run( + ["git", "-c", "user.email=t@example.com", "-c", "user.name=t", + "merge", "--quiet", "--no-ff", "main"], + cwd=self.repo_root, capture_output=True, text=True, env=self.env, + ) + self.assertNotEqual(merge.returncode, 0, "both sides rewrote the shared receipt") + + # A hand-combined resolution is a new write of the retired file. + self.write(LEGACY_RECEIPT_PATH, "{}\n") + self.git("add", LEGACY_RECEIPT_PATH) + status, _, stderr = self.run_guard() + self.assertEqual(status, 1) + self.assertIn(f"{LEGACY_RECEIPT_PATH} is retired", stderr) + self.assertIn("git checkout MERGE_HEAD", stderr) + + self.git("checkout", "MERGE_HEAD", "--", LEGACY_RECEIPT_PATH) + self.assertEqual(self.run_guard()[0], 0) + self.commit("merge main") + self.assertEqual(self.run_range(base=landed), (0, "")) + + +class PruneTest(GuardRepoTestCase): + """Landed receipts are deleted only by changes that record none.""" + + def setUp(self) -> None: + super().setUp() + self.verified_change(CHANGED_PATH, "export const a = 1;\n", "landed change") + self.landed_receipts = self.receipts_in_tree() + + def test_prunes_receipts_already_on_the_landed_ref(self) -> None: + self.git("checkout", "--quiet", "-b", "chore/prune") + + status, stdout, _ = self.run_guard("--prune", "--landed-on", "main") + + self.assertEqual(status, 0) + self.assertIn("Pruned 1 receipt(s)", stdout) + self.assertEqual( + self.git("diff", "--cached", "--name-only", "--diff-filter=D").splitlines(), + self.landed_receipts, + ) + self.assertEqual(self.run_guard()[0], 0) + self.assertEqual(self.run_guard("--prune", "--landed-on", "main")[0], 0) + + def test_refuses_on_a_branch_that_records_a_receipt(self) -> None: + self.git("checkout", "--quiet", "-b", "fix/topic") + self.verified_change(OTHER_PATH, "export const b = 1;\n", "topic change") + + status, _, stderr = self.run_guard("--prune", "--landed-on", "main") + + self.assertEqual(status, 1) + self.assertIn("records 1 receipt(s) of its own", stderr) + self.assertEqual(self.git("diff", "--cached", "--name-only"), "") + + def test_pruning_never_conflicts_with_an_open_frontend_change(self) -> None: + self.git("checkout", "--quiet", "-b", "fix/topic") + self.verified_change(OTHER_PATH, "export const b = 1;\n", "topic change") + self.git("checkout", "--quiet", "-b", "chore/prune", "main") + self.assertEqual(self.run_guard("--prune", "--landed-on", "main")[0], 0) + self.commit("prune landed receipts") + + for first, second in (("chore/prune", "fix/topic"), ("fix/topic", "chore/prune")): + self.git("checkout", "--quiet", "-B", "trial", "main") + self.git("merge", "--quiet", "--no-ff", first, "-m", f"merge {first}") + before_second = self.git("rev-parse", "HEAD") + self.git("merge", "--quiet", "--no-ff", second, "-m", f"merge {second}") + self.assertEqual(self.run_range(base=before_second)[0], 0, (first, second)) + self.assertEqual(len(self.receipts_in_tree()), 1) + if __name__ == "__main__": unittest.main()