Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ Semantic Versioning where the repository publishes a release.

### Fixed

- Keep the post-merge replay guard focused on lost protected-base evidence: a
feature-only source and its same-subject test may be removed together when
both are absent from the exact protected base, while protected-base test
deletion, test-only deletion, weakened retained tests, exact tree replay,
targeted base unmerge, and bulk regression remain fail-closed. Declare pip as
a development dependency because the materialized-lock regression invokes
its hash preflight inside the project-local uv environment.
- Forward `trigger_reviews=true` explicitly from the trusted OpenCode mention wrapper to the authoritative scheduler while retaining GitHub's ten-key dispatch limit. Source-comment identity remains bound in the verified invocation claim and durable ledger instead of occupying an unused scheduler field, so a successfully routed `@opencode-agent` request now dispatches review work rather than entering queue maintenance with reviews disabled.
- Allowed an allowlisted base repository's open fork-head PR to enter the central exact-head OpenCode review path. The scheduler and privileged reviewer still re-read the live PR, bind base/head refs and SHAs, reject malformed repository identities, keep fork source as untrusted data, preserve the existing maintainer-writable update rule, and reserve the final external-head merge for a maintainer.
- Confined OSV base and head repository checkouts to the same `source/` child directory, so a cross-fork head checkout can replace that repository without deleting the base-scan JSON held at the workspace root. Both scans retain identical source paths and the required base/head vulnerability comparison remains fail-closed.
Expand Down
70 changes: 70 additions & 0 deletions docs/doctoring/pr-head-replay-base-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# PR-head replay guard: protected-base alignment

## Observed failure

LineageWeave pull request 258 exact head
`6dc040c6b3ea0bfc4424bb7afb11b8afd7205d77` failed central OpenCode coverage
run `32528869351`. The replay guard found that five commits after the newest
protected-base-descended merge removed three files, including
`tests/test_lineage_contract.py`.

The exact protected base
`ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7` already omitted both that test and
its same-subject runtime module. The head also omitted both. No path had been
restored to pre-merge content, the head did not replay an earlier tree, and the
three-file delta stayed below the conservative bulk-replay threshold. The
test-regression signal nevertheless treated every post-merge test deletion as
protected-base evidence loss.

## Decision and security boundary

The guard now distinguishes one base-alignment case from an evidence
regression. A test absent from the exact protected base is not reported as
regressed only when a non-test file with the same logical subject and language
suffix is deleted in the same post-merge range and that source is also absent
from the protected base, with no same-subject source remaining in the exact
head. This admits removal of a feature-only source/test pair that aligns the
head with the protected branch without letting a same-named documentation or
sibling-source deletion excuse a code-test deletion.

The following cases remain blocking:

- a test or same-subject source present on the protected base is removed;
- a feature-only test is removed while its source remains;
- a retained test file loses declared test cases without a replacement test
file;
- the head exactly replays a pre-merge tree, restores a path to its pre-merge
content, or crosses the conservative bulk-deletion thresholds.

This boundary follows NIST SSDF's root-cause and verification practices while
preserving GitHub's guidance that privileged pull-request workflows must treat
pull-request state as untrusted input. The decision is based only on immutable
Git objects from the validated base, merge anchor, and head; no pull-request
code executes during classification.

## Verification

- A new Git-history regression reproduces a feature-only source/test pair,
merges the exact protected base, removes the pair, and requires a passing
replay decision.
- Existing regressions continue to require failure for protected-base source
and test removal, test-only deletion, weakened retained tests, exact replay,
targeted unmerge, and bulk deletion.
- The patched guard passes against the observed LineageWeave base/head pair and
reports no regressed protected-base test path.
- The focused replay-guard suite, complete central Python quality suite,
docstring coverage, workflow validation, and diff hygiene run on the final
tree.
- The project-local uv environment declares pip because the existing bounded
include regression invokes `python -m pip` for its hash preflight; verification
no longer depends on an unrecorded manual venv seed.

## References

GitHub. (n.d.). *Secure use reference*. GitHub Docs. Retrieved August 22, 2026,
from https://docs.github.com/en/actions/reference/security/secure-use

Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development
framework (SSDF) version 1.1: Recommendations for mitigating the risk of
software vulnerabilities* (NIST Special Publication 800-218). National
Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dependencies = []

[dependency-groups]
dev = [
"pip>=25.0",
"pytest>=8.0.0",
"pytest-cov>=7.1.0",
"interrogate>=1.7.0",
Expand Down
94 changes: 84 additions & 10 deletions scripts/ci/pr_head_replay_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
merge brought in (observed in appguardrail#297, where a stale snapshot
reverted an accessibility wrapper and deleted its regression tests in a
push far below the bulk thresholds);
- test regression without replacement: post-merge commits deleting test files
or reducing declared test cases while adding no replacement test file;
- test regression without replacement or protected-base alignment: post-merge
commits deleting test files or reducing declared test cases while adding no
replacement test file, except when a feature-only test and its same-subject,
same-language source are both retired to match the protected base;
- the conservative bulk-regression signature: at least five tracked files and
500 lines removed, with deletions at least four times additions.

Expand Down Expand Up @@ -91,7 +93,7 @@ def unmerges_base_work(self) -> bool:

@property
def suspicious_test_regression(self) -> bool:
"""Return whether test cases were lost with no replacement test file."""
"""Return whether non-base-aligned tests were lost without replacement."""
return bool(self.regressed_test_paths) and self.added_test_files == 0

@property
Expand Down Expand Up @@ -188,6 +190,20 @@ def is_test_path(path: str) -> bool:
)


def logical_test_subject(path: str) -> str:
"""Return the source-like stem represented by a common test filename."""
name = Path(path).name
for marker in (".test.", ".spec."):
if marker in name:
return name.split(marker, 1)[0]
stem = Path(name).stem
if stem.startswith("test_"):
return stem.removeprefix("test_")
if stem.endswith("_test"):
return stem.removesuffix("_test")
return stem


def changed_paths(repo_root: Path, start: str, end: str) -> set[str]:
"""Return the set of paths whose content differs between two commits."""
output = git_output(repo_root, ["diff", "--name-only", start, end])
Expand Down Expand Up @@ -234,16 +250,64 @@ def test_case_count(
return len(pattern.findall(source)) if pattern is not None else None


def test_file_changes(repo_root: Path, start: str, end: str) -> tuple[tuple[str, ...], int]:
"""Return deleted or test-case-reducing paths and the added-test count."""
def test_file_changes(
repo_root: Path,
start: str,
end: str,
protected_revision: str,
) -> tuple[tuple[str, ...], int]:
"""Return non-base-aligned test regressions and the added-test count."""
protected_paths = set(
git_output(
repo_root,
["ls-tree", "-r", "--name-only", protected_revision],
).splitlines()
)
head_source_subjects: set[tuple[str, str]] = set()
for path in git_output(
repo_root,
["ls-tree", "-r", "--name-only", end],
).splitlines():
if not is_test_path(path):
head_source_subjects.add(
(logical_test_subject(path), Path(path).suffix.lower())
)
regressed: set[str] = set()
added = 0
for line in git_output(repo_root, ["diff", "--name-status", start, end]).splitlines():
name_status_lines = git_output(
repo_root,
["diff", "--name-status", start, end],
).splitlines()
deleted_paths: set[str] = set()
removed_feature_sources: set[tuple[str, str]] = set()
for line in name_status_lines:
fields = line.split("\t")
if len(fields) < 2 or not fields[0].startswith("D"):
continue
deleted_paths.add(fields[-1])
source_subject = (
logical_test_subject(fields[-1]),
Path(fields[-1]).suffix.lower(),
)
if (
not is_test_path(fields[-1])
and fields[-1] not in protected_paths
and source_subject not in head_source_subjects
):
removed_feature_sources.add(source_subject)
Comment thread
seonghobae marked this conversation as resolved.
for line in name_status_lines:
fields = line.split("\t")
if len(fields) < 2 or not is_test_path(fields[-1]):
continue
status = fields[0][:1]
if status == "D":
if status == "D" and (
fields[-1] in protected_paths
or (
logical_test_subject(fields[-1]),
Path(fields[-1]).suffix.lower(),
)
not in removed_feature_sources
Comment thread
seonghobae marked this conversation as resolved.
):
regressed.add(fields[-1])
elif status == "A":
added += 1
Comment thread
seonghobae marked this conversation as resolved.
Expand All @@ -252,7 +316,11 @@ def test_file_changes(repo_root: Path, start: str, end: str) -> tuple[tuple[str,
if len(fields) < 3 or not fields[0].isdigit() or not fields[1].isdigit():
continue
path = fields[2]
if not is_test_path(path) or int(fields[1]) <= int(fields[0]):
if (
not is_test_path(path)
or path in deleted_paths
or int(fields[1]) <= int(fields[0])
):
continue
Comment thread
seonghobae marked this conversation as resolved.
before_count = test_case_count(repo_root, start, path)
after_count = test_case_count(repo_root, end, path)
Comment thread
seonghobae marked this conversation as resolved.
Expand Down Expand Up @@ -293,7 +361,12 @@ def collect_evidence(repo_root: Path, base_sha: str, head_sha: str) -> ReplayEvi
head_sha,
)
unmerged = unmerged_base_paths(repo_root, merge_anchor, head_sha)
regressed_tests, added_tests = test_file_changes(repo_root, merge_anchor, head_sha)
regressed_tests, added_tests = test_file_changes(
repo_root,
merge_anchor,
head_sha,
base_sha,
)
return ReplayEvidence(
base_sha=base_sha,
head_sha=head_sha,
Expand Down Expand Up @@ -336,7 +409,8 @@ def format_report(evidence: ReplayEvidence) -> str:
)
if evidence.suspicious_test_regression:
reasons.append(
"post-merge commits deleted test files or reduced declared test cases "
"post-merge commits deleted protected-base or unpaired feature test files, "
"or reduced declared test cases "
"without adding any replacement test file: "
f"{summarize_paths(evidence.regressed_test_paths)}."
)
Expand Down
84 changes: 82 additions & 2 deletions tests/test_pr_head_replay_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,73 @@ def test_test_refactor_with_replacement_passes(tmp_path):
assert not evidence.blocked


def test_removing_feature_only_test_to_match_current_base_passes(tmp_path):
"""Deleting a test absent from the protected base is not a base replay."""
repo = tmp_path / "repo"
repo.mkdir()
git(repo, "init", "-b", "main")
git(repo, "config", "user.name", "Test")
git(repo, "config", "user.email", "test@example.com")
write(repo, "README.md", "base\n")
commit(repo, "base")

git(repo, "checkout", "-b", "feature")
write(repo, "src/legacy.py", "def legacy():\n return True\n")
write(repo, "tests/test_legacy.py", "def test_legacy():\n assert True\n")
commit(repo, "feature adds legacy code")

git(repo, "checkout", "main")
write(repo, "base-change.txt", "current base\n")
current_base = commit(repo, "advance base without legacy code")

git(repo, "checkout", "feature")
git(repo, "merge", "--no-ff", "--no-edit", "main")
(repo / "src/legacy.py").unlink()
(repo / "tests/test_legacy.py").unlink()
git(repo, "add", "-A")
head = commit(repo, "align feature with protected base")

evidence = guard.collect_evidence(repo, current_base, head)

assert evidence.regressed_test_paths == ()
assert evidence.unmerged_paths == ()
assert not evidence.blocked


def test_removing_same_named_sibling_source_does_not_excuse_test_loss(tmp_path):
"""A retained same-subject source keeps its feature test protected."""
repo = tmp_path / "repo"
repo.mkdir()
git(repo, "init", "-b", "main")
git(repo, "config", "user.name", "Test")
git(repo, "config", "user.email", "test@example.com")
write(repo, "README.md", "base\n")
commit(repo, "base")

git(repo, "checkout", "-b", "feature")
write(repo, "src/primary/legacy.py", "def legacy():\n return True\n")
write(repo, "src/obsolete/legacy.py", "def legacy():\n return False\n")
write(repo, "tests/test_legacy.py", "def test_legacy():\n assert True\n")
commit(repo, "feature adds same-subject sources")

git(repo, "checkout", "main")
write(repo, "base-change.txt", "current base\n")
current_base = commit(repo, "advance base")

git(repo, "checkout", "feature")
git(repo, "merge", "--no-ff", "--no-edit", "main")
(repo / "src/obsolete/legacy.py").unlink()
(repo / "tests/test_legacy.py").unlink()
git(repo, "add", "-A")
head = commit(repo, "remove sibling and test")

evidence = guard.collect_evidence(repo, current_base, head)

assert evidence.regressed_test_paths == ("tests/test_legacy.py",)
assert evidence.suspicious_test_regression
assert evidence.blocked


def test_is_test_path_covers_common_layouts():
"""Test-path detection recognizes directories, prefixes, suffixes, and spec names."""
assert guard.is_test_path("tests/test_guard.py")
Expand All @@ -267,6 +334,11 @@ def test_is_test_path_covers_common_layouts():
assert guard.is_test_path("tests\\test_windows.py")
assert not guard.is_test_path("scripts/ci/guard.py")
assert not guard.is_test_path("docs/testing.md")
assert guard.logical_test_subject("tests/test_guard.py") == "guard"
assert guard.logical_test_subject("pkg/guard_test.go") == "guard"
assert guard.logical_test_subject("app/button.test.tsx") == "button"
assert guard.logical_test_subject("app/button.spec.ts") == "button"
assert guard.logical_test_subject("tests/README.md") == "README"


def test_test_case_count_fails_closed_and_supports_known_formats(
Expand Down Expand Up @@ -343,6 +415,7 @@ def test_test_file_changes_parses_status_and_numstat(monkeypatch, tmp_path):
"D\ttests/test_gone.py",
"A\ttests/test_new.py",
"M\ttests/test_kept.py",
"D\tdocs/gone.md",
"D\tsrc/app_old.py",
"badline",
]
Expand All @@ -355,15 +428,22 @@ def test_test_file_changes_parses_status_and_numstat(monkeypatch, tmp_path):
"2\t9\tsrc/big.py",
]
)
outputs = iter([name_status, numstat])
outputs = iter(
[
"tests/test_shrunk.py",
"src/kept.py",
name_status,
numstat,
]
)
monkeypatch.setattr(guard, "git_output", lambda _root, _args: next(outputs))
monkeypatch.setattr(
guard,
"test_case_count",
lambda _root, revision, _path: 2 if revision == "a" else 1,
)

regressed, added = guard.test_file_changes(tmp_path, "a", "b")
regressed, added = guard.test_file_changes(tmp_path, "a", "b", "protected")

assert regressed == ("tests/test_gone.py", "tests/test_shrunk.py")
assert added == 1
Expand Down
Loading