mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
Record browser receipts per commit so frontend PRs stop conflicting (#2436)
## Why
Every commit that touches user-visible frontend source had to rewrite
the single file `frontend-modern/browser-verification.json`. Any two
open frontend pull requests therefore conflicted the moment 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
demanded a receipt covering files the author never ran. The only honest
recovery was to rebuild on the new `main`, run the browser pass again
and open a superseding pull request. On 2 and 3 October one
storage-table fix went #2415 → #2418 → #2433 that way.
## What changes
- **One receipt file per commit** under
`frontend-modern/browser-verification/`. The file name is the first 16
hex characters of the SHA-256 of the receipt's `content_sha256` map, so
it is fixed by the verified content, never chosen, and two changes only
share a file if they verified identical bytes. Receipts are only ever
added, so git merges them unaided.
- **Every binding the guard had is kept**: the commit's own parent, the
exact changed paths and their content digests, a viewport of at least
1024 and one of at most 768, non-empty routes, states and interactions,
and the range check that each changed frontend file's final content in
`base..head` matches a receipt from a non-merge commit in the range. A
hand-named receipt is not evidence.
- **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; a merge commit may not record one.
- **`--write-template`** writes the skeleton at the path the staged
content requires. `--print-template` still prints it and now names that
path.
- **`--prune`** deletes receipts already on `origin/main`. A change that
records a receipt may not delete one, and the guard enforces that per
commit in both modes: git reads a deleted receipt beside a new one as a
rename, and that rename conflicts with any other change that deletes the
same receipt.
- **The shared file is retired**: no commit may write it again. It stays
in the tree, untouched, in this pull request, because deleting it would
conflict with every open pull request that still rewrites it. A
follow-up deletes it.
## Open pull requests that still rewrite the shared file
They keep working. A commit that modified the shared file while its own
parent still carried it remains valid evidence in range mode. The first
such pull request to merge lands untouched. Each later one still
conflicts on that file, and is now repaired without a rebuild or a new
browser pass:
```bash
git merge origin/main
git checkout MERGE_HEAD -- frontend-modern/browser-verification.json
git commit --no-edit
```
## What this does not change
Two open pull requests that edit different parts of the *same* frontend
file used to be stopped by the receipt conflict. They now both merge if
git can combine the edits. The range check still evaluates the merged
result, on the pull request when its check runs and on the push to
`main`; combined content that no receipt verified fails there and needs
a follow-up receipt. `browser_verification_guard_test.py` pins this.
## Proof
`scripts/release_control/browser_verification_guard_test.py` (45 tests)
covers the staged index, concluding merges, the integration range, the
retired file and pruning.
Run against a throwaway clone of this repository, with this change as
`main`: two branches cut from the same `main`, one editing
`components/shared/Table.tsx`, the other `utils/format.ts`. The first
merges; the second then merges with no conflict, and the workflow's own
"Validate final frontend browser evidence" step passes on that merge
ref.
```
### 3. Second PR against the moved main
git merge-tree main pr-b exit=0 (0 = no conflict)
merge ref 7e01f7cca9, parents ba458dded0 a7e11f39d6
pull_request range ba458dded0...7e01f7cca9:
Browser verification guard passed (1 frontend source file(s) across ba458dded03e..7e01f7cca90a covered by in-range receipts).
workflow step exit=0
### 4. Contrast: the same two edits under the shared receipt (74fd189fd0)
CONFLICT (content): Merge conflict in frontend-modern/browser-verification.json
git merge-tree exit=1 (1 = conflict)
```
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