mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
+3
-1
@@ -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/''',
|
||||
]
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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 <range-base> --commit <range-tip>`. The tip of such a
|
||||
|
||||
@@ -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`.
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user