mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
fe82cb29a3
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.
973 lines
38 KiB
Python
973 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""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
|
|
|
|
import argparse
|
|
from datetime import datetime
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from typing import Iterable, Sequence
|
|
|
|
import format_staged_frontend
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
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"}
|
|
|
|
|
|
def run_git(args: Sequence[str], *, repo_root: Path = REPO_ROOT) -> str:
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=repo_root,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
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],
|
|
cwd=repo_root,
|
|
check=False,
|
|
capture_output=True,
|
|
)
|
|
return result.stdout if result.returncode == 0 else None
|
|
|
|
|
|
def staged_files(*, repo_root: Path = REPO_ROOT) -> list[str]:
|
|
output = run_git(
|
|
["diff", "--cached", "--name-only", "--diff-filter=ACMRD"],
|
|
repo_root=repo_root,
|
|
)
|
|
return [line.strip() for line in output.splitlines() if line.strip()]
|
|
|
|
|
|
def stdin_files(lines: Iterable[str]) -> list[str]:
|
|
return [line.strip() for line in lines if line.strip()]
|
|
|
|
|
|
def is_frontend_test_or_fixture(path: str) -> bool:
|
|
pure_path = PurePosixPath(path)
|
|
name = pure_path.name
|
|
return (
|
|
"__tests__" in pure_path.parts
|
|
or "__fixtures__" in pure_path.parts
|
|
or ".test." in name
|
|
or ".spec." in name
|
|
or ".stories." in name
|
|
)
|
|
|
|
|
|
def is_user_visible_frontend_source(path: str) -> bool:
|
|
if path in FRONTEND_ENTRY_FILES:
|
|
return True
|
|
if not path.startswith(FRONTEND_SOURCE_PREFIX):
|
|
return False
|
|
if is_frontend_test_or_fixture(path):
|
|
return False
|
|
return PurePosixPath(path).suffix in FRONTEND_SOURCE_SUFFIXES
|
|
|
|
|
|
def frontend_runtime_paths(paths: Iterable[str]) -> list[str]:
|
|
return sorted({path for path in paths if is_user_visible_frontend_source(path)})
|
|
|
|
|
|
def prettier_format(
|
|
prettier: Path,
|
|
path: str,
|
|
content: bytes,
|
|
*,
|
|
repo_root: Path = REPO_ROOT,
|
|
) -> bytes | None:
|
|
# --stdin-filepath drives parser selection and config resolution, so this
|
|
# matches what `prettier --write <path>` would produce.
|
|
try:
|
|
result = subprocess.run(
|
|
[str(prettier), "--stdin-filepath", str(repo_root / path)],
|
|
cwd=repo_root / "frontend-modern",
|
|
check=True,
|
|
capture_output=True,
|
|
input=content,
|
|
)
|
|
except (subprocess.CalledProcessError, OSError):
|
|
return None
|
|
return result.stdout
|
|
|
|
|
|
def formatting_only_paths(
|
|
paths: Sequence[str],
|
|
*,
|
|
commit: str | None,
|
|
base: str | None = None,
|
|
repo_root: Path = REPO_ROOT,
|
|
) -> set[str]:
|
|
"""Paths whose new content is exactly prettier's output for the old content.
|
|
|
|
A `make format` sweep re-lays-out already-committed files without changing
|
|
a single token, so it cannot change what renders. Demanding fresh browser
|
|
proof for that would mean recording a receipt describing an interaction
|
|
matrix nobody exercised, for a diff with no visual delta.
|
|
|
|
This fails closed: an added or deleted file, an unreadable blob, a prettier
|
|
that will not run, or any output that is not byte-identical all fall
|
|
through and still require the receipt. `base` compares against an
|
|
explicit range base instead of the commit's parent.
|
|
"""
|
|
prettier = format_staged_frontend.prettier_bin()
|
|
if prettier is None:
|
|
return set()
|
|
|
|
base_revision = base or (f"{commit}^" if commit else "HEAD")
|
|
formatting_only: set[str] = set()
|
|
for path in paths:
|
|
new_object = f"{commit}:{path}" if commit else f":{path}"
|
|
new_content = git_blob_bytes(new_object, repo_root=repo_root)
|
|
old_content = git_blob_bytes(f"{base_revision}:{path}", repo_root=repo_root)
|
|
if new_content is None or old_content is None or new_content == old_content:
|
|
continue
|
|
if prettier_format(prettier, path, old_content, repo_root=repo_root) == new_content:
|
|
formatting_only.add(path)
|
|
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}:{path}" if commit else f":{path}"
|
|
return run_git(["show", object_name], repo_root=repo_root)
|
|
|
|
|
|
def expected_base_sha(*, commit: str | None, repo_root: Path = REPO_ROOT) -> str:
|
|
revision = f"{commit}^" if commit else "HEAD"
|
|
return run_git(["rev-parse", revision], repo_root=repo_root)
|
|
|
|
|
|
def content_sha256(
|
|
paths: Sequence[str],
|
|
*,
|
|
commit: str | None,
|
|
repo_root: Path = REPO_ROOT,
|
|
) -> dict[str, str]:
|
|
digests: dict[str, str] = {}
|
|
for path in paths:
|
|
object_name = f"{commit}:{path}" if commit else f":{path}"
|
|
content = git_blob_bytes(object_name, repo_root=repo_root)
|
|
digests[path] = "deleted" if content is None else hashlib.sha256(content).hexdigest()
|
|
return digests
|
|
|
|
|
|
def validate_receipt(
|
|
payload: object,
|
|
*,
|
|
changed_paths: Sequence[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"]
|
|
|
|
if payload.get("version") != 1:
|
|
errors.append("version must be 1")
|
|
if payload.get("result") != "passed":
|
|
errors.append('result must be "passed"')
|
|
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
|
|
):
|
|
errors.append("changed_paths must be a non-empty string array")
|
|
elif sorted(set(receipt_paths)) != sorted(set(changed_paths)):
|
|
errors.append("changed_paths must exactly match staged user-visible frontend source files")
|
|
|
|
if payload.get("content_sha256") != expected_content_sha256:
|
|
errors.append("content_sha256 must exactly match the final staged frontend source content")
|
|
|
|
routes = payload.get("routes")
|
|
if not isinstance(routes, list) or not routes or not all(
|
|
isinstance(route, str) and route.strip() for route in routes
|
|
):
|
|
errors.append("routes must be a non-empty string array")
|
|
|
|
states = payload.get("states")
|
|
if not isinstance(states, list) or not states or not all(
|
|
isinstance(state, str) and state.strip() for state in states
|
|
):
|
|
errors.append("states must be a non-empty string array")
|
|
|
|
interactions = payload.get("interactions")
|
|
if not isinstance(interactions, list) or not interactions or not all(
|
|
isinstance(interaction, str) and interaction.strip() for interaction in interactions
|
|
):
|
|
errors.append("interactions must be a non-empty string array")
|
|
|
|
viewports = payload.get("viewports")
|
|
valid_viewports: list[dict] = []
|
|
if isinstance(viewports, list):
|
|
valid_viewports = [
|
|
viewport
|
|
for viewport in viewports
|
|
if isinstance(viewport, dict)
|
|
and isinstance(viewport.get("width"), int)
|
|
and viewport["width"] > 0
|
|
and isinstance(viewport.get("height"), int)
|
|
and viewport["height"] > 0
|
|
]
|
|
if len(valid_viewports) != len(viewports or []) or not valid_viewports:
|
|
errors.append("viewports must contain valid positive integer width/height objects")
|
|
else:
|
|
if not any(viewport["width"] >= 1024 for viewport in valid_viewports):
|
|
errors.append("viewports must include a desktop width of at least 1024 pixels")
|
|
if not any(viewport["width"] <= 768 for viewport in valid_viewports):
|
|
errors.append("viewports must include a narrow width of at most 768 pixels")
|
|
|
|
verified_at = payload.get("verified_at")
|
|
if not isinstance(verified_at, str) or not verified_at.endswith("Z"):
|
|
errors.append("verified_at must be an ISO-8601 UTC timestamp ending in Z")
|
|
else:
|
|
try:
|
|
datetime.fromisoformat(verified_at.removesuffix("Z") + "+00:00")
|
|
except ValueError:
|
|
errors.append("verified_at must be a valid ISO-8601 UTC timestamp")
|
|
|
|
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 touch a receipt.
|
|
|
|
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(
|
|
[
|
|
"rev-list",
|
|
"--reverse",
|
|
"--no-merges",
|
|
"--full-history",
|
|
f"{base}..{head}",
|
|
"--",
|
|
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], 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 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:
|
|
changes = receipt_changes(commit=commit, repo_root=repo_root)
|
|
commit_base = expected_base_sha(commit=commit, repo_root=repo_root)
|
|
except subprocess.CalledProcessError as exc:
|
|
diagnostics.append(f"{commit[:12]}: unable to read receipt changes: {exc}")
|
|
continue
|
|
recorded = sorted(
|
|
path for path, status in changes.items()
|
|
if status != "D" and path != LEGACY_RECEIPT_PATH
|
|
)
|
|
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(
|
|
changed_paths: Sequence[str],
|
|
*,
|
|
base: str,
|
|
head: str,
|
|
repo_root: Path = REPO_ROOT,
|
|
) -> 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
|
|
whole cumulative delta is invalid by construction there. What must hold is
|
|
that every user-visible frontend file ships with content a real browser
|
|
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, 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 [], 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, violations
|
|
|
|
|
|
def build_template(paths: Sequence[str], *, repo_root: Path = REPO_ROOT) -> dict:
|
|
changed_paths = frontend_runtime_paths(paths)
|
|
return {
|
|
"version": 1,
|
|
"base_sha": expected_base_sha(commit=None, repo_root=repo_root),
|
|
"verified_at": "YYYY-MM-DDTHH:MM:SSZ",
|
|
"result": "replace-with-passed-after-verification",
|
|
"changed_paths": changed_paths,
|
|
"content_sha256": content_sha256(changed_paths, commit=None, repo_root=repo_root),
|
|
"routes": ["/replace-with-verified-route"],
|
|
"viewports": [
|
|
{"width": 1280, "height": 800},
|
|
{"width": 390, "height": 844},
|
|
],
|
|
"states": ["replace with every state inspected"],
|
|
"interactions": ["replace with every interaction exercised"],
|
|
}
|
|
|
|
|
|
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(
|
|
"--files-from-stdin",
|
|
action="store_true",
|
|
help="Read the changed path list from stdin instead of the staged index.",
|
|
)
|
|
parser.add_argument(
|
|
"--commit",
|
|
help="Validate the receipt recorded by this commit against that commit's parent.",
|
|
)
|
|
parser.add_argument(
|
|
"--base",
|
|
help=(
|
|
"Validate the range base..--commit: every changed user-visible frontend "
|
|
"file's final content must match a valid receipt recorded by a non-merge "
|
|
"commit in the range, bound to that commit's own parent."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--print-template",
|
|
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")
|
|
return args
|
|
|
|
|
|
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:
|
|
paths = stdin_files(
|
|
run_git(
|
|
["diff", "--name-only", "--diff-filter=ACMRD", args.base, args.commit],
|
|
repo_root=REPO_ROOT,
|
|
).splitlines()
|
|
)
|
|
else:
|
|
paths = staged_files(repo_root=REPO_ROOT)
|
|
|
|
if args.print_template or args.write_template:
|
|
return emit_template(paths, write=args.write_template, repo_root=REPO_ROOT)
|
|
|
|
if args.base:
|
|
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__":
|
|
raise SystemExit(main())
|