Compare commits
6
Commits
aa1baf8fe9
...
74f2db4d6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74f2db4d6a | ||
|
|
9b5f03fa40 | ||
|
|
1a1e679246 | ||
|
|
9fde4f3e76 | ||
|
|
af7131abf1 | ||
|
|
71ccbca10f |
@@ -11,6 +11,7 @@ import inspect
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||
from review_proofs import (
|
||||
HANDOFF_HEADING,
|
||||
assess_controller_handoff,
|
||||
@@ -456,6 +457,40 @@ def _rule_reviewer_git_fetch_readonly(
|
||||
return []
|
||||
|
||||
|
||||
def _rule_reviewer_validation_failure_history(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
from reviewer_validation_failure_history import (
|
||||
assess_validation_failure_history_report,
|
||||
)
|
||||
|
||||
session = validation_session or {}
|
||||
observed = session.get("observed_failures") or []
|
||||
if not observed:
|
||||
return []
|
||||
|
||||
result = assess_validation_failure_history_report(
|
||||
report_text,
|
||||
validation_session=session,
|
||||
)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
severity = "block" if result.get("violations") else "downgrade"
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.validation_failure_history",
|
||||
severity,
|
||||
"Validation failure history",
|
||||
reason,
|
||||
result.get("safe_next_action")
|
||||
or "document every observed validation failure before claiming pass",
|
||||
)
|
||||
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _BARE_PYTEST_RE.search(text):
|
||||
@@ -855,6 +890,20 @@ def _rule_reviewer_review_mutation(
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
|
||||
result = assess_post_merge_cleanup_proof(report_text)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.post_merge_cleanup_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Cleanup status",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "report CLEANUP_SKIPPED with blocker or full cleanup checklist",
|
||||
)
|
||||
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_pr": [
|
||||
_rule_shared_controller_handoff,
|
||||
@@ -864,6 +913,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_mutation_categories,
|
||||
_rule_reviewer_git_fetch_readonly,
|
||||
_rule_reviewer_validation_command,
|
||||
_rule_reviewer_validation_failure_history,
|
||||
_rule_reviewer_validation_structured,
|
||||
_rule_reviewer_linked_issue,
|
||||
_rule_reviewer_baseline_on_failure,
|
||||
@@ -874,6 +924,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_target_branch_freshness,
|
||||
_rule_reviewer_mutation_ledger,
|
||||
_rule_reviewer_review_mutation,
|
||||
_rule_reviewer_post_merge_cleanup_proof,
|
||||
],
|
||||
"reconcile_already_landed": [
|
||||
_rule_reconcile_controller_handoff,
|
||||
@@ -970,6 +1021,7 @@ def assess_final_report_validator(
|
||||
local_edits: bool = False,
|
||||
issue_filing_lock: dict | None = None,
|
||||
session_pr_opened: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate final-report text against task-specific proof rules (#327).
|
||||
|
||||
@@ -1025,6 +1077,7 @@ def assess_final_report_validator(
|
||||
"mutations_observed": mutations_observed,
|
||||
"local_edits": local_edits,
|
||||
"session_pr_opened": session_pr_opened,
|
||||
"validation_session": validation_session,
|
||||
}
|
||||
|
||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||
|
||||
+21
-3
@@ -1245,7 +1245,7 @@ def gitea_create_pr(
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote)
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
|
||||
# ── Issue Lock Validation (Issue #194 / #196) ──
|
||||
@@ -3464,16 +3464,32 @@ def gitea_delete_branch(
|
||||
repo: Override the repository name.
|
||||
|
||||
Returns:
|
||||
dict with 'success' and 'message'.
|
||||
dict with 'success' and 'message'; on a permission block,
|
||||
'success'/'performed' False, 'reasons', and a structured
|
||||
'permission_report' (#142) with no API call made.
|
||||
"""
|
||||
gate_reasons = _profile_operation_gate("gitea.branch.delete")
|
||||
if gate_reasons:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": gate_reasons,
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
|
||||
verify_preflight_purity(remote)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
import urllib.parse
|
||||
encoded_branch = urllib.parse.quote(branch, safe="")
|
||||
url = f"{repo_api_url(h, o, r)}/branches/{encoded_branch}"
|
||||
request_metadata = {
|
||||
"branch": branch,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
}
|
||||
with _audited("delete_branch", host=h, remote=remote, org=o, repo=r,
|
||||
target_branch=branch, request_metadata={"branch": branch}):
|
||||
target_branch=branch, request_metadata=request_metadata):
|
||||
api_request("DELETE", url, auth)
|
||||
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
|
||||
|
||||
@@ -5510,6 +5526,7 @@ def gitea_validate_review_final_report(
|
||||
action_log: list[dict] | None = None,
|
||||
mutations_observed: bool = False,
|
||||
local_edits: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: machine-validate reviewer final report schema before output (#391).
|
||||
|
||||
@@ -5527,6 +5544,7 @@ def gitea_validate_review_final_report(
|
||||
action_log=action_log,
|
||||
mutations_observed=mutations_observed,
|
||||
local_edits=local_edits,
|
||||
validation_session=validation_session,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
|
||||
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
|
||||
|
||||
_CLEANUP_SECTION_HINT = re.compile(
|
||||
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
|
||||
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
|
||||
_CLEANUP_BLOCKER_RE = re.compile(
|
||||
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REMOTE_DELETE_CLAIM_RE = re.compile(
|
||||
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
|
||||
r"deleted remote branch|delete_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
|
||||
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
|
||||
r"worktree cleanup performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
|
||||
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
|
||||
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_TASK_RE = re.compile(
|
||||
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"merge result\s*:\s*(?:merged|success|performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_HEAD_BRANCH_RE = re.compile(
|
||||
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCH_NOT_PROTECTED_RE = re.compile(
|
||||
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OPEN_PR_INVENTORY_RE = re.compile(
|
||||
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
|
||||
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
|
||||
r"no other open pr references branch",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACTIVE_CLAIM_LEASE_RE = re.compile(
|
||||
r"(?:no active (?:heartbeat|claim|lease)|"
|
||||
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SESSION_OWNED_WORKTREE_RE = re.compile(
|
||||
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
|
||||
_CLEAN_TRACKED_RE = re.compile(
|
||||
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEAN_UNTRACKED_RE = re.compile(
|
||||
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_LIST_AFTER_RE = re.compile(
|
||||
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WRONG_BRANCH_RE = re.compile(
|
||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _claims_remote_delete(text: str) -> bool:
|
||||
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _claims_worktree_remove(text: str) -> bool:
|
||||
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _branch_safety_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
missing.append("delete-branch capability resolved (gitea.branch.delete)")
|
||||
if not _DELETE_TASK_RE.search(text):
|
||||
missing.append("delete-branch task named (delete_branch or cleanup)")
|
||||
if not _MERGE_RESULT_RE.search(text):
|
||||
missing.append("merge result: merged")
|
||||
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||
missing.append("merge commit SHA")
|
||||
if not _PR_HEAD_BRANCH_RE.search(text):
|
||||
missing.append("merged PR head branch / deleted branch name")
|
||||
if not _BRANCH_NOT_PROTECTED_RE.search(text):
|
||||
missing.append("branch not protected proof")
|
||||
if not _OPEN_PR_INVENTORY_RE.search(text):
|
||||
missing.append("open PR inventory proof (no other PR references branch)")
|
||||
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
|
||||
missing.append("no active heartbeat/claim/lease proof")
|
||||
return missing
|
||||
|
||||
|
||||
def _worktree_cleanup_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
match = _SESSION_OWNED_WORKTREE_RE.search(text)
|
||||
path = match.group(1).strip() if match else ""
|
||||
if not path:
|
||||
missing.append("session-owned worktree path")
|
||||
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
|
||||
missing.append("worktree path under branches/")
|
||||
if not _CLEAN_TRACKED_RE.search(text):
|
||||
missing.append("pre-removal tracked state: clean")
|
||||
if not _CLEAN_UNTRACKED_RE.search(text):
|
||||
missing.append("pre-removal untracked state: clean")
|
||||
if not _WORKTREE_LIST_AFTER_RE.search(text):
|
||||
missing.append("git worktree list after removal")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_post_merge_cleanup_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
cleanup_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
|
||||
text = report_text or ""
|
||||
session = dict(cleanup_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
|
||||
blocker = (session.get("blocker") or "").strip()
|
||||
if not blocker:
|
||||
match = _CLEANUP_BLOCKER_RE.search(text)
|
||||
blocker = match.group(1).strip() if match else ""
|
||||
if blocker.upper() == CLEANUP_SKIPPED:
|
||||
blocker = ""
|
||||
if not blocker:
|
||||
reasons.append(
|
||||
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"proven": not reasons,
|
||||
"outcome": CLEANUP_SKIPPED,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
|
||||
return {
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"outcome": None,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
remote_delete = bool(
|
||||
session.get("remote_delete_claimed") or _claims_remote_delete(text)
|
||||
)
|
||||
worktree_remove = bool(
|
||||
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
|
||||
)
|
||||
|
||||
if _WRONG_BRANCH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup report claims deleted branch that is not the merged PR head branch"
|
||||
)
|
||||
|
||||
if remote_delete:
|
||||
reasons.extend(
|
||||
f"remote branch deletion missing {field}"
|
||||
for field in _branch_safety_fields_present(text)
|
||||
)
|
||||
|
||||
if worktree_remove:
|
||||
reasons.extend(
|
||||
f"worktree removal missing {field}"
|
||||
for field in _worktree_cleanup_fields_present(text)
|
||||
)
|
||||
|
||||
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
|
||||
pass
|
||||
|
||||
if not remote_delete and not worktree_remove:
|
||||
cleanup_mutations = re.search(
|
||||
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if cleanup_mutations:
|
||||
reasons.append(
|
||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||
)
|
||||
|
||||
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
|
||||
if remote_delete or worktree_remove:
|
||||
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"proven": not block,
|
||||
"outcome": outcome,
|
||||
"remote_delete_claimed": remote_delete,
|
||||
"worktree_remove_claimed": worktree_remove,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
|
||||
"checklist before claiming remote delete or worktree removal"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -301,6 +301,7 @@ def assess_review_final_report_schema(
|
||||
action_log: list[dict] | None = None,
|
||||
mutations_observed: bool = False,
|
||||
local_edits: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate reviewer final report text before session completion (#391)."""
|
||||
base = assess_final_report_validator(
|
||||
@@ -312,6 +313,7 @@ def assess_review_final_report_schema(
|
||||
action_log=action_log,
|
||||
mutations_observed=mutations_observed,
|
||||
local_edits=local_edits,
|
||||
validation_session=validation_session,
|
||||
)
|
||||
extra: list[dict[str, str]] = []
|
||||
for rule in _SCHEMA_RULES:
|
||||
|
||||
@@ -5503,6 +5503,15 @@ def assess_validation_integrity_report(report_text, **kwargs):
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
|
||||
def assess_validation_failure_history_report(report_text, **kwargs):
|
||||
"""#396: final reports must account for transient validation failures."""
|
||||
from reviewer_validation_failure_history import (
|
||||
assess_validation_failure_history_report as _assess,
|
||||
)
|
||||
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
|
||||
def assess_already_landed_classification_report(report_text, **kwargs):
|
||||
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
||||
from reviewer_already_landed_classification import (
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Transient validation failure history verifier (#396).
|
||||
|
||||
Reviewer sessions may observe validation failures that later pass on rerun.
|
||||
Final reports must document every failure observed during the session, not
|
||||
only the last passing result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_SECTION_RE = re.compile(
|
||||
r"validation failure history",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FAILURE_ENTRY_RE = re.compile(
|
||||
r"(?:failure\s*(?:#|entry)?\s*\d+|validation failure)\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_COMMAND_RE = re.compile(
|
||||
r"(?:command|validation command)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FAILING_TEST_RE = re.compile(
|
||||
r"(?:failing test|failure|error)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CAUSE_RE = re.compile(
|
||||
r"(?:suspected cause|cause)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REPRODUCED_RE = re.compile(
|
||||
r"(?:reproduced|reproduces)\s*:\s*(yes|no|unknown)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BASELINE_RE = re.compile(
|
||||
r"(?:on baseline master|baseline master|exists on baseline)\s*:\s*(yes|no|unknown|not checked)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_CAUSED_RE = re.compile(
|
||||
r"(?:pr[- ]caused|pr caused)\s*:\s*(yes|no|unknown|not proven)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TRANSIENT_STATUS_RE = re.compile(
|
||||
r"(?:passed after transient failure investigation|transient failure investigation)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PLAIN_PASS_RE = re.compile(
|
||||
r"validation\s*:\s*(?:pass|passed|strong|ok|green)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ENV_CLEANUP_RE = re.compile(
|
||||
r"(?:environmental cleanup|state cleaned|what changed between runs)\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_UNKNOWN_CAUSE_RE = re.compile(
|
||||
r"(?:suspected cause|cause)\s*:\s*(?:unknown|unexplained|not determined)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _failure_documented_in_text(text: str, failure: dict[str, Any]) -> bool:
|
||||
"""Return True when *failure* appears documented in free-form report text."""
|
||||
command = (failure.get("command") or "").strip()
|
||||
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
|
||||
if command and command not in text:
|
||||
return False
|
||||
if failing and failing not in text:
|
||||
return False
|
||||
return bool(command or failing)
|
||||
|
||||
|
||||
def _section_has_structured_fields(text: str) -> bool:
|
||||
if not _SECTION_RE.search(text):
|
||||
return False
|
||||
section_start = _SECTION_RE.search(text).start()
|
||||
section = text[section_start:]
|
||||
has_command = bool(_COMMAND_RE.search(section))
|
||||
has_failure = bool(_FAILING_TEST_RE.search(section))
|
||||
return has_command and has_failure
|
||||
|
||||
|
||||
def assess_validation_failure_history_report(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Require final reports to account for transient validation failures (#396)."""
|
||||
text = report_text or ""
|
||||
session = dict(validation_session or {})
|
||||
reasons: list[str] = []
|
||||
violations: list[str] = []
|
||||
|
||||
observed = list(session.get("observed_failures") or [])
|
||||
final_status = (session.get("final_validation_status") or "").strip().lower()
|
||||
cause_unknown = any(
|
||||
(f.get("suspected_cause") or "").strip().lower() in {
|
||||
"unknown", "unexplained", "not determined", ""
|
||||
}
|
||||
and not (f.get("pr_caused") or "").strip().lower() in {"yes", "no"}
|
||||
for f in observed
|
||||
) or bool(session.get("cause_unknown"))
|
||||
|
||||
if not observed:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"violations": [],
|
||||
"observed_failure_count": 0,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
documented = _section_has_structured_fields(text)
|
||||
if not documented:
|
||||
for failure in observed:
|
||||
if _failure_documented_in_text(text, failure):
|
||||
documented = True
|
||||
break
|
||||
|
||||
if not documented:
|
||||
violations.append(
|
||||
"session observed validation failure(s) but final report omits "
|
||||
"Validation failure history"
|
||||
)
|
||||
reasons.append(
|
||||
"every validation failure observed during the session must appear "
|
||||
"in a Validation failure history section"
|
||||
)
|
||||
|
||||
if documented and not _SECTION_RE.search(text):
|
||||
reasons.append(
|
||||
"failure details must appear under an explicit "
|
||||
"'Validation failure history' heading"
|
||||
)
|
||||
|
||||
for idx, failure in enumerate(observed, start=1):
|
||||
prefix = f"failure #{idx}"
|
||||
if not _failure_documented_in_text(text, failure) and documented:
|
||||
reasons.append(
|
||||
f"{prefix}: command and failing test/error not documented in report"
|
||||
)
|
||||
command = (failure.get("command") or "").strip()
|
||||
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
|
||||
if not command:
|
||||
reasons.append(f"{prefix}: missing command in session failure record")
|
||||
if not failing:
|
||||
reasons.append(
|
||||
f"{prefix}: missing failing test or error in session failure record"
|
||||
)
|
||||
|
||||
plain_pass = bool(_PLAIN_PASS_RE.search(text))
|
||||
transient_wording = bool(_TRANSIENT_STATUS_RE.search(text))
|
||||
final_passed = final_status in {
|
||||
"passed",
|
||||
"pass",
|
||||
"passed_after_transient_failure_investigation",
|
||||
}
|
||||
|
||||
if (final_passed or plain_pass) and observed:
|
||||
if cause_unknown and not transient_wording:
|
||||
violations.append(
|
||||
"unknown transient failure cause cannot be erased as plain 'passed'"
|
||||
)
|
||||
reasons.append(
|
||||
"when cause is unknown, use status "
|
||||
"'passed after transient failure investigation'"
|
||||
)
|
||||
elif not transient_wording and final_status != "passed_after_transient_failure_investigation":
|
||||
if not _ENV_CLEANUP_RE.search(text):
|
||||
reasons.append(
|
||||
"later passing rerun must document what changed between runs "
|
||||
"or environmental cleanup performed"
|
||||
)
|
||||
|
||||
if session.get("environmental_contamination") and not _ENV_CLEANUP_RE.search(text):
|
||||
reasons.append(
|
||||
"environmental /tmp state contamination must document cleanup or "
|
||||
"what changed before the passing rerun"
|
||||
)
|
||||
|
||||
proven = not reasons and not violations
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": bool(violations) or not proven,
|
||||
"reasons": reasons,
|
||||
"violations": violations,
|
||||
"observed_failure_count": len(observed),
|
||||
"documented": documented,
|
||||
"safe_next_action": (
|
||||
"add Validation failure history with command, failing test, cause, "
|
||||
"reproduction, baseline comparison, and PR-caused evidence; use "
|
||||
"'passed after transient failure investigation' when appropriate"
|
||||
if not proven
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -538,6 +538,36 @@ Official validation integrity status must be one of:
|
||||
|
||||
Do not invent a softer status.
|
||||
|
||||
## 21A. Validation failure history rule
|
||||
|
||||
Every validation failure observed during the review session must appear in the
|
||||
final report, even if a later rerun passes.
|
||||
|
||||
Before claiming `Validation: passed`, list every failed validation command from
|
||||
the session under `Validation failure history`.
|
||||
|
||||
For each failure, report:
|
||||
|
||||
* command
|
||||
* failing test or error
|
||||
* suspected cause
|
||||
* whether it reproduced
|
||||
* whether it exists on baseline master
|
||||
* evidence for whether it is or is not PR-caused
|
||||
|
||||
If a later rerun passes, also report:
|
||||
|
||||
* what changed between runs
|
||||
* whether environmental state was cleaned (for example `/tmp` lock files)
|
||||
* why the pass is trustworthy
|
||||
|
||||
If the cause is unknown, do not erase the earlier failure with plain
|
||||
`Validation: passed`. Use a status such as
|
||||
`passed after transient failure investigation`.
|
||||
|
||||
`gitea_validate_review_final_report` rejects reports that omit known earlier
|
||||
validation failures when `validation_session.observed_failures` is supplied.
|
||||
|
||||
## 22. Baseline validation rule
|
||||
|
||||
Do not run tests in the main checkout.
|
||||
@@ -767,6 +797,40 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc
|
||||
|
||||
If any local artifact is created after final cleanup, run and report a new final status check.
|
||||
|
||||
## 28A. Post-merge cleanup proof checklist (#402)
|
||||
|
||||
Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed.
|
||||
|
||||
### Remote branch deletion checklist
|
||||
|
||||
When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report:
|
||||
|
||||
* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call
|
||||
* Merge result: merged
|
||||
* Merge commit SHA: full 40-character SHA
|
||||
* Merged PR head branch / deleted branch: exact branch name (must match)
|
||||
* Branch protection: none / branch is not protected
|
||||
* Open PR inventory proof: no other open PR references the branch
|
||||
* Active heartbeat/claim/lease: none
|
||||
|
||||
### Local worktree removal checklist
|
||||
|
||||
When removing session-owned review/simulation worktrees under `branches/`, report:
|
||||
|
||||
* Session-owned worktree path: exact path under `branches/`
|
||||
* Pre-removal tracked state: clean
|
||||
* Pre-removal untracked state: clean
|
||||
* Git worktree list after removal: command output or equivalent proof
|
||||
|
||||
### Skipped cleanup
|
||||
|
||||
If any gate fails, report:
|
||||
|
||||
* Cleanup outcome: `CLEANUP_SKIPPED`
|
||||
* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`)
|
||||
|
||||
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
|
||||
|
||||
## 29. Recovery handoff rules
|
||||
|
||||
If blocked, produce a recovery handoff with:
|
||||
@@ -1096,6 +1160,7 @@ Controller Handoff:
|
||||
* Baseline worktree path:
|
||||
* Files reviewed:
|
||||
* Validation:
|
||||
* Validation failure history:
|
||||
* Official validation integrity status:
|
||||
* Terminal review mutation:
|
||||
* Review decision:
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for gitea_delete_branch capability gate (Issue #408).
|
||||
|
||||
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
|
||||
without it the delete fails closed (no preflight, no auth lookup, no API call,
|
||||
structured permission report). With it, deletion proceeds through existing
|
||||
preflight and audit unchanged.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server
|
||||
from mcp_server import gitea_delete_branch
|
||||
|
||||
FAKE_AUTH = "token fake"
|
||||
|
||||
AUTHOR_NO_DELETE = {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.pr.create", "gitea.pr.comment",
|
||||
"gitea.branch.push", "gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
AUTHOR_WITH_DELETE = {
|
||||
"profile_name": "prgs-author-deleter",
|
||||
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"] + [
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
"audit_label": "prgs-author-deleter",
|
||||
}
|
||||
|
||||
CONFIG = {
|
||||
"version": 2,
|
||||
"contexts": {
|
||||
"ctx": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"author-no-delete": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "author-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"],
|
||||
"forbidden_operations": [],
|
||||
"execution_profile": "author-no-delete",
|
||||
},
|
||||
"author-with-delete": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "deleter-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": AUTHOR_WITH_DELETE["allowed_operations"],
|
||||
"forbidden_operations": [],
|
||||
"execution_profile": "author-with-delete",
|
||||
},
|
||||
"reviewer-profile": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "reviewer",
|
||||
"username": "reviewer-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.pr.review", "gitea.pr.merge",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.branch.delete", "gitea.branch.push", "gitea.pr.create",
|
||||
],
|
||||
"execution_profile": "reviewer-profile",
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
}
|
||||
|
||||
|
||||
class TestDeleteBranchToolGate(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._preflight_snapshot = (
|
||||
mcp_server._preflight_whoami_called,
|
||||
mcp_server._preflight_capability_called,
|
||||
)
|
||||
mcp_server._preflight_whoami_called = False
|
||||
mcp_server._preflight_capability_called = False
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||
"repo": "Example-Repo"},
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
patch("gitea_config.load_config", return_value={}).start()
|
||||
patch(
|
||||
"gitea_config.is_runtime_switching_enabled", return_value=False
|
||||
).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch(
|
||||
"mcp_server.get_auth_header", return_value=FAKE_AUTH
|
||||
).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
|
||||
self._preflight_snapshot
|
||||
)
|
||||
|
||||
def _set_profile(self, profile):
|
||||
patch("mcp_server.get_profile", return_value=profile).start()
|
||||
|
||||
def test_blocked_without_delete_capability(self):
|
||||
self._set_profile(AUTHOR_NO_DELETE)
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["required_permission"], "gitea.branch.delete")
|
||||
self.assertTrue(res["reasons"])
|
||||
self.assertEqual(
|
||||
res["permission_report"]["missing_permission"],
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
self.mock_api.assert_not_called()
|
||||
self.mock_auth.assert_not_called()
|
||||
|
||||
def test_allowed_delete_proceeds(self):
|
||||
self._set_profile(AUTHOR_WITH_DELETE)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertIn("deleted", res["message"])
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertTrue(delete_calls)
|
||||
|
||||
def test_allowed_delete_audited_with_capability_proof(self):
|
||||
self._set_profile(AUTHOR_WITH_DELETE)
|
||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||
mock_write = patch("gitea_audit.write_event").start()
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
self.mock_api.return_value = {}
|
||||
gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
mock_write.assert_called()
|
||||
event = mock_write.call_args[0][0]
|
||||
self.assertEqual(event["action"], "delete_branch")
|
||||
self.assertEqual(
|
||||
event["request_metadata"]["required_permission"],
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
|
||||
|
||||
class TestDeleteBranchResolverParity(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||
"repo": "Example-Repo"},
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
patch(
|
||||
"gitea_config.is_runtime_switching_enabled", return_value=False
|
||||
).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir.cleanup()
|
||||
|
||||
def _env(self, profile: str) -> dict:
|
||||
return {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": profile,
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||
}
|
||||
|
||||
def test_reviewer_resolver_denial_blocks_raw_tool(self):
|
||||
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
|
||||
resolve = mcp_server.gitea_resolve_task_capability(
|
||||
task="delete_branch", remote="prgs")
|
||||
self.assertFalse(resolve["allowed_in_current_session"])
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(
|
||||
res["permission_report"]["missing_permission"],
|
||||
resolve["required_operation_permission"],
|
||||
)
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertFalse(delete_calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -207,6 +207,12 @@ def test_validation_integrity_verifier_exported():
|
||||
assert callable(assess_validation_integrity_report)
|
||||
|
||||
|
||||
def test_validation_failure_history_verifier_exported():
|
||||
from review_proofs import assess_validation_failure_history_report
|
||||
|
||||
assert callable(assess_validation_failure_history_report)
|
||||
|
||||
|
||||
def test_prior_blocker_skip_verifier_exported():
|
||||
from review_proofs import assess_prior_blocker_skip_proof
|
||||
|
||||
|
||||
@@ -1005,9 +1005,19 @@ class TestReviewPR(unittest.TestCase):
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestDeleteBranch(unittest.TestCase):
|
||||
|
||||
DELETE_PROFILE = {
|
||||
"profile_name": "test-deleter",
|
||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "test-deleter",
|
||||
}
|
||||
|
||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_delete_branch(self, _auth, mock_api):
|
||||
def test_delete_branch(self, _auth, mock_api, _profile):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
mock_api.return_value = {}
|
||||
result = gitea_delete_branch(branch="feat/branch")
|
||||
self.assertTrue(result["success"])
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for post-merge cleanup proof enforcement (#402)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
from post_merge_cleanup_proof import ( # noqa: E402
|
||||
CLEANUP_SKIPPED,
|
||||
assess_post_merge_cleanup_proof,
|
||||
)
|
||||
|
||||
MERGE_SHA = "a" * 40
|
||||
HEAD_BRANCH = "feat/issue-274-branches-only-worktrees"
|
||||
WORKTREE = "branches/review-pr374-conflicts"
|
||||
|
||||
|
||||
def _full_remote_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Task": "review PR #374",
|
||||
"Merge result": "merged",
|
||||
"Merge commit SHA": MERGE_SHA,
|
||||
"Cleanup status": "remote branch deleted",
|
||||
"Delete-branch capability resolved": "gitea.branch.delete allowed via delete_branch task",
|
||||
"Merged PR head branch": HEAD_BRANCH,
|
||||
"Deleted branch": HEAD_BRANCH,
|
||||
"Branch protection": "none",
|
||||
"Open PR inventory proof": "no other open PR references branch (inventory complete)",
|
||||
"Active heartbeat/claim/lease": "none",
|
||||
"Cleanup mutations": "gitea_delete_branch on remote head branch",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _full_worktree_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Task": "review PR #374",
|
||||
"Merge result": "merged",
|
||||
"Cleanup status": "local worktree removed",
|
||||
"Removed worktree path": WORKTREE,
|
||||
"Pre-removal tracked state": "clean",
|
||||
"Pre-removal untracked state": "clean",
|
||||
"Git worktree list after removal": "only main checkout listed",
|
||||
"Cleanup mutations": "git worktree remove on session-owned review worktree",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TestPostMergeCleanupProof(unittest.TestCase):
|
||||
def test_no_cleanup_claim_passes(self):
|
||||
report = "## Controller Handoff\n- Task: review PR #1\n- Merge result: merged"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_missing_capability_proof_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Delete-branch capability resolved": "delete succeeded"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("capability" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_unmerged_pr_blocked(self):
|
||||
report = _full_remote_cleanup_report(**{"Merge result": "not merged"})
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("merge result" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_wrong_branch_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Deleted branch": "feat/other-branch"}
|
||||
)
|
||||
report += "\nDeleted branch does not match merged PR head branch"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_protected_branch_blocked(self):
|
||||
report = _full_remote_cleanup_report(**{"Branch protection": "enabled"})
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_open_pr_reference_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Open PR inventory proof": "PR #999 still references branch"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_active_claim_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Active heartbeat/claim/lease": "status:in-progress on linked issue"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_dirty_worktree_blocked(self):
|
||||
report = _full_worktree_cleanup_report(
|
||||
**{"Pre-removal tracked state": "dirty"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("tracked" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_foreign_worktree_blocked(self):
|
||||
report = _full_worktree_cleanup_report(
|
||||
**{"Removed worktree path": "/tmp/foreign-worktree"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("branches/" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_cleanup_skipped_with_blocker_passes(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
||||
"- Cleanup blocker: gitea.branch.delete capability not resolved in active profile",
|
||||
])
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["outcome"], CLEANUP_SKIPPED)
|
||||
|
||||
def test_cleanup_skipped_without_blocker_blocked(self):
|
||||
report = "## Controller Handoff\n- Cleanup outcome: CLEANUP_SKIPPED"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_full_remote_cleanup_passes(self):
|
||||
result = assess_post_merge_cleanup_proof(_full_remote_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["remote_delete_claimed"])
|
||||
|
||||
def test_full_worktree_cleanup_passes(self):
|
||||
result = assess_post_merge_cleanup_proof(_full_worktree_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["worktree_remove_claimed"])
|
||||
|
||||
def test_validator_integration_blocks_unproven_delete(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Cleanup mutations: gitea_delete_branch deleted remote branch\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.post_merge_cleanup_proof", rule_ids)
|
||||
|
||||
def test_validator_integration_allows_skipped(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
||||
"- Cleanup blocker: branch still referenced by open PR #414",
|
||||
])
|
||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
||||
cleanup_findings = [
|
||||
f for f in result["findings"]
|
||||
if f["rule_id"] == "reviewer.post_merge_cleanup_proof"
|
||||
]
|
||||
self.assertEqual(cleanup_findings, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests for transient validation failure history verifier (#396)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
from reviewer_validation_failure_history import ( # noqa: E402
|
||||
assess_validation_failure_history_report,
|
||||
)
|
||||
|
||||
|
||||
def _full_history_report() -> str:
|
||||
return "\n".join([
|
||||
"Validation failure history",
|
||||
"Failure #1:",
|
||||
"Command: venv/bin/python -m pytest tests/test_worktrees.py -q",
|
||||
"Failing test: tests/test_worktrees.py::TestWorktreeStart::test_rejects_untraceable_branches",
|
||||
"Suspected cause: stale /tmp/gitea_issue_lock.json from prior session",
|
||||
"Reproduced: no",
|
||||
"On baseline master: no",
|
||||
"PR-caused: no",
|
||||
"What changed between runs: removed /tmp/gitea_issue_lock.json",
|
||||
"Environmental cleanup: lock file removed before rerun",
|
||||
"Validation: passed after transient failure investigation",
|
||||
"Final validation command: venv/bin/python -m pytest tests/ -q",
|
||||
"Final result: 1497 passed, 6 skipped",
|
||||
])
|
||||
|
||||
|
||||
class TestValidationFailureHistory(unittest.TestCase):
|
||||
def test_no_failures_observed_passes(self):
|
||||
result = assess_validation_failure_history_report(
|
||||
"Validation: passed",
|
||||
validation_session={"observed_failures": []},
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_omitted_failure_blocks(self):
|
||||
result = assess_validation_failure_history_report(
|
||||
"Validation: passed\n1497 passed, 6 skipped",
|
||||
validation_session={
|
||||
"observed_failures": [{
|
||||
"command": "pytest tests/test_worktrees.py -q",
|
||||
"failing_test": (
|
||||
"tests/test_worktrees.py::TestWorktreeStart::"
|
||||
"test_rejects_untraceable_branches"
|
||||
),
|
||||
}],
|
||||
"final_validation_status": "passed",
|
||||
},
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_full_history_with_transient_investigation_passes(self):
|
||||
result = assess_validation_failure_history_report(
|
||||
_full_history_report(),
|
||||
validation_session={
|
||||
"observed_failures": [{
|
||||
"command": (
|
||||
"venv/bin/python -m pytest tests/test_worktrees.py -q"
|
||||
),
|
||||
"failing_test": (
|
||||
"tests/test_worktrees.py::TestWorktreeStart::"
|
||||
"test_rejects_untraceable_branches"
|
||||
),
|
||||
"suspected_cause": "stale /tmp/gitea_issue_lock.json",
|
||||
"reproduced": "no",
|
||||
"on_baseline_master": "no",
|
||||
"pr_caused": "no",
|
||||
}],
|
||||
"final_validation_status": "passed_after_transient_failure_investigation",
|
||||
"environmental_contamination": True,
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_baseline_equivalent_failure_documented(self):
|
||||
report = "\n".join([
|
||||
"Validation failure history",
|
||||
"Command: pytest tests/test_example.py -q",
|
||||
"Failing test: tests/test_example.py::test_flaky",
|
||||
"Suspected cause: pre-existing master failure",
|
||||
"On baseline master: yes",
|
||||
"PR-caused: no",
|
||||
"What changed between runs: none; failure matches baseline",
|
||||
"Validation: passed after transient failure investigation",
|
||||
])
|
||||
result = assess_validation_failure_history_report(
|
||||
report,
|
||||
validation_session={
|
||||
"observed_failures": [{
|
||||
"command": "pytest tests/test_example.py -q",
|
||||
"failing_test": "tests/test_example.py::test_flaky",
|
||||
"on_baseline_master": "yes",
|
||||
"pr_caused": "no",
|
||||
}],
|
||||
"final_validation_status": "passed_after_transient_failure_investigation",
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_unknown_cause_plain_pass_blocks(self):
|
||||
result = assess_validation_failure_history_report(
|
||||
"Validation: passed",
|
||||
validation_session={
|
||||
"observed_failures": [{
|
||||
"command": "pytest tests/ -q",
|
||||
"failing_test": "tests/test_foo.py::test_bar",
|
||||
"suspected_cause": "unknown",
|
||||
}],
|
||||
"final_validation_status": "passed",
|
||||
"cause_unknown": True,
|
||||
},
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("transient" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_final_report_validator_rejects_omitted_failure(self):
|
||||
result = assess_final_report_validator(
|
||||
"Validation: passed",
|
||||
"review_pr",
|
||||
validation_session={
|
||||
"observed_failures": [{
|
||||
"command": "pytest tests/ -q",
|
||||
"failing_test": "tests/test_foo.py::test_bar",
|
||||
}],
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["blocked"] or result["downgraded"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f.get("rule_id") == "reviewer.validation_failure_history"
|
||||
for f in result.get("findings") or []
|
||||
)
|
||||
)
|
||||
|
||||
def test_exported_from_review_proofs(self):
|
||||
from review_proofs import assess_validation_failure_history_report as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user