Pulse/scripts/release_control/browser_verification_guard.py
rcourtman 4e67b8117d fix(governance): exempt pure prettier reformats from browser proof
A `make format` sweep re-lays-out already-committed files without
changing a single token, so it cannot change what renders. The browser
verification guard still demanded a fresh receipt for it, which would
mean recording routes, viewports, states and interactions nobody
exercised in order to describe a diff with no visual delta. A guard that
can only be satisfied by an untrue receipt teaches people to write
untrue receipts.

Exempt a path only when its new content is byte-identical to prettier's
output for its committed content. That is provable, not a judgement
call: if the two match, the sole difference from HEAD is layout.

Fails closed everywhere else -- added or deleted files, unreadable
blobs, prettier missing, or any non-identical output all fall through
and still require the receipt. A reformat that also changes a value is
covered by a test and still blocks.
2026-08-06 21:53:44 +01:00

352 lines
12 KiB
Python

#!/usr/bin/env python3
"""Require fresh browser proof for staged user-visible frontend changes."""
from __future__ import annotations
import argparse
from datetime import datetime
import hashlib
import json
from pathlib import Path, PurePosixPath
import subprocess
import sys
from typing import Iterable, Sequence
import format_staged_frontend
REPO_ROOT = Path(__file__).resolve().parents[2]
RECEIPT_PATH = "frontend-modern/browser-verification.json"
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 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,
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.
"""
prettier = format_staged_frontend.prettier_bin()
if prettier is None:
return set()
base_revision = 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 load_receipt_text(
*,
commit: str | None,
repo_root: Path = REPO_ROOT,
) -> str:
object_name = f"{commit}:{RECEIPT_PATH}" if commit else f":{RECEIPT_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,
expected_content_sha256: dict[str, str],
) -> list[str]:
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 payload.get("base_sha") != expected_base:
errors.append(f"base_sha must match the verified parent {expected_base}")
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 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 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 stored in this commit against that commit's parent.",
)
parser.add_argument(
"--print-template",
action="store_true",
help="Print a non-passing receipt template for the current staged frontend paths.",
)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
paths = stdin_files(sys.stdin) if args.files_from_stdin else staged_files()
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)
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 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
if __name__ == "__main__":
raise SystemExit(main())