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.
This commit is contained in:
rcourtman
2026-10-03 11:11:25 +01:00
parent 74fd189fd0
commit fe82cb29a3
6 changed files with 1140 additions and 182 deletions
@@ -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 -- "
"<path>) 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__":
@@ -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()