Raw branch/comment delete detection now scans only Cleanup mutations, Git ref mutations, and Worktree mutations ledger fields so narrative §28B citations and validation notes no longer false-positive. Authorized cleanup tool proof also applies when cleanup is claimed under Git ref or Worktree mutation ledgers while Cleanup mutations is none. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
359 lines
12 KiB
Python
359 lines
12 KiB
Python
"""MCP-native post-merge cleanup proof verifier (#517).
|
|
|
|
Post-merge cleanup of leases, comments, branches, and worktrees must be
|
|
proven through explicit MCP tools (or approved helpers), never raw scripts or
|
|
ad hoc git/API commands. Merger/reviewer sessions must hand cleanup to a
|
|
reconciler profile with the right capability proof.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
AUTHORIZED_CLEANUP_TOOLS = frozenset({
|
|
"gitea_cleanup_post_merge_moot_lease",
|
|
"gitea_reconcile_merged_cleanups",
|
|
"gitea_delete_branch",
|
|
"gitea_cleanup_merged_pr_branch",
|
|
"gitea_audit_worktree_cleanup",
|
|
"gitea_capture_branches_worktree_snapshot",
|
|
"gitea_assess_worktree_cleanup_integrity",
|
|
"gitea_authorize_reconciliation_cleanup_phase",
|
|
})
|
|
|
|
_RAW_BRANCH_DELETE_PATTERNS = (
|
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
|
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
|
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I),
|
|
)
|
|
|
|
_RAW_COMMENT_DELETE_PATTERNS = (
|
|
re.compile(
|
|
r"(?:curl|wget|httpie)\b[^\n\r]*\b(?:DELETE|delete)\b[^\n\r]*"
|
|
r"(?:/comments/|issues/\d+/comments)",
|
|
re.I,
|
|
),
|
|
re.compile(
|
|
r"\bDELETE\b[^\n\r]*/repos/[^\n\r]*/issues/\d+/comments/\d+",
|
|
re.I,
|
|
),
|
|
re.compile(
|
|
r"\b(?:delete_issue_comment|remove_issue_comment|purge_comments?)\b",
|
|
re.I,
|
|
),
|
|
re.compile(
|
|
r"\b(?:raw|ad hoc|adhoc)\b[^\n\r]{0,40}\bcomment\b[^\n\r]{0,40}\bdelete",
|
|
re.I,
|
|
),
|
|
)
|
|
|
|
_CLEANUP_MUTATIONS_RE = re.compile(
|
|
r"^\s*[-*]?\s*cleanup mutations\s*:\s*(.+)$",
|
|
re.IGNORECASE | re.MULTILINE,
|
|
)
|
|
_GIT_REF_MUTATIONS_RE = re.compile(
|
|
r"^\s*[-*]?\s*git ref mutations\s*:\s*(.+)$",
|
|
re.IGNORECASE | re.MULTILINE,
|
|
)
|
|
_WORKTREE_MUTATIONS_RE = re.compile(
|
|
r"^\s*[-*]?\s*worktree mutations\s*:\s*(.+)$",
|
|
re.IGNORECASE | re.MULTILINE,
|
|
)
|
|
_MERGE_MUTATIONS_RE = re.compile(
|
|
r"^\s*[-*]?\s*merge mutations\s*:\s*(.+)$",
|
|
re.IGNORECASE | re.MULTILINE,
|
|
)
|
|
_NONE_LEDGER_VALUES = frozenset({"", "none", "n/a"})
|
|
_MUTATION_LEDGER_PATTERNS = (
|
|
_CLEANUP_MUTATIONS_RE,
|
|
_GIT_REF_MUTATIONS_RE,
|
|
_WORKTREE_MUTATIONS_RE,
|
|
)
|
|
_RAW_WORKTREE_REMOVE_PATTERNS = (
|
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+worktree\s+remove\b", re.I),
|
|
)
|
|
_AUTHORIZED_TOOL_RE = re.compile(
|
|
r"\b(" + "|".join(re.escape(t) for t in sorted(AUTHORIZED_CLEANUP_TOOLS)) + r")\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_RECONCILER_CAPABILITY_RE = re.compile(
|
|
r"(?:reconciler|gitea\.branch\.delete|delete_branch|reconcile_merged_cleanups)",
|
|
re.IGNORECASE,
|
|
)
|
|
_MERGER_CLEANUP_ROLE_RE = re.compile(
|
|
r"(?:merger|reviewer).{0,80}(?:deleted|removed|cleaned).{0,80}"
|
|
r"(?:branch|worktree|comment|lease)",
|
|
re.IGNORECASE,
|
|
)
|
|
_HANDOFF_TO_RECONCILER_RE = re.compile(
|
|
r"(?:hand(?:ed)? off|defer(?:red)?|next actor).{0,60}reconciler",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _ledger_field_body(text: str, pattern: re.Pattern[str]) -> str | None:
|
|
match = pattern.search(text)
|
|
if not match:
|
|
return None
|
|
return (match.group(1) or "").strip()
|
|
|
|
|
|
def _is_none_ledger_value(value: str) -> bool:
|
|
return value.strip().lower() in _NONE_LEDGER_VALUES
|
|
|
|
|
|
def scoped_cleanup_mutation_ledger_text(text: str | None) -> str:
|
|
"""Return mutation-ledger bodies scoped to cleanup enforcement (#517)."""
|
|
text = text or ""
|
|
parts: list[str] = []
|
|
for pattern in _MUTATION_LEDGER_PATTERNS:
|
|
body = _ledger_field_body(text, pattern)
|
|
if body is not None and not _is_none_ledger_value(body):
|
|
parts.append(body)
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _git_ref_cleanup_claimed(body: str) -> bool:
|
|
return bool(raw_branch_delete_commands(body))
|
|
|
|
|
|
def _worktree_cleanup_claimed(body: str) -> bool:
|
|
for pattern in _RAW_WORKTREE_REMOVE_PATTERNS:
|
|
if pattern.search(body):
|
|
return True
|
|
return bool(
|
|
re.search(
|
|
r"\b(?:removed|deleted)\b[^\n]{0,40}\bworktree\b",
|
|
body,
|
|
re.IGNORECASE,
|
|
)
|
|
)
|
|
|
|
|
|
def _cleanup_claiming_ledger_fields(text: str) -> list[tuple[str, str]]:
|
|
claiming: list[tuple[str, str]] = []
|
|
cleanup_body = _ledger_field_body(text, _CLEANUP_MUTATIONS_RE)
|
|
if cleanup_body is not None and not _is_none_ledger_value(cleanup_body):
|
|
claiming.append(("Cleanup mutations", cleanup_body))
|
|
|
|
git_ref_body = _ledger_field_body(text, _GIT_REF_MUTATIONS_RE)
|
|
if git_ref_body is not None and not _is_none_ledger_value(git_ref_body):
|
|
if _git_ref_cleanup_claimed(git_ref_body):
|
|
claiming.append(("Git ref mutations", git_ref_body))
|
|
|
|
worktree_body = _ledger_field_body(text, _WORKTREE_MUTATIONS_RE)
|
|
if worktree_body is not None and not _is_none_ledger_value(worktree_body):
|
|
if _worktree_cleanup_claimed(worktree_body):
|
|
claiming.append(("Worktree mutations", worktree_body))
|
|
return claiming
|
|
|
|
|
|
def raw_branch_delete_commands(text: str | None) -> list[str]:
|
|
"""Return raw git branch-delete commands cited in *text*."""
|
|
if not text:
|
|
return []
|
|
commands: list[str] = []
|
|
for pattern in _RAW_BRANCH_DELETE_PATTERNS:
|
|
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
|
|
return list(dict.fromkeys(commands))
|
|
|
|
|
|
def raw_comment_delete_commands(text: str | None) -> list[str]:
|
|
"""Return raw comment-deletion commands/scripts cited in *text*."""
|
|
if not text:
|
|
return []
|
|
commands: list[str] = []
|
|
for pattern in _RAW_COMMENT_DELETE_PATTERNS:
|
|
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
|
|
return list(dict.fromkeys(commands))
|
|
|
|
|
|
def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]:
|
|
"""Fail closed when mutation ledgers cite raw git branch deletion."""
|
|
commands = raw_branch_delete_commands(scoped_cleanup_mutation_ledger_text(text))
|
|
reasons = [
|
|
(
|
|
"raw git branch deletion bypasses MCP branch.delete cleanup gates: "
|
|
f"{command}"
|
|
)
|
|
for command in commands
|
|
]
|
|
return {
|
|
"proven": not reasons,
|
|
"block": bool(reasons),
|
|
"commands": commands,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"use gitea_delete_branch, gitea_reconcile_merged_cleanups, or another "
|
|
"approved MCP cleanup helper with explicit branch.delete capability"
|
|
if reasons
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_raw_comment_delete_report(text: str | None) -> dict[str, Any]:
|
|
"""Fail closed when mutation ledgers cite raw comment deletion."""
|
|
commands = raw_comment_delete_commands(scoped_cleanup_mutation_ledger_text(text))
|
|
reasons = [
|
|
(
|
|
"raw comment deletion bypasses MCP lease cleanup gates: "
|
|
f"{command}"
|
|
)
|
|
for command in commands
|
|
]
|
|
return {
|
|
"proven": not reasons,
|
|
"block": bool(reasons),
|
|
"commands": commands,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"use gitea_cleanup_post_merge_moot_lease (append-only release comment) "
|
|
"or hand cleanup to a reconciler session; never delete lease comments"
|
|
if reasons
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_merge_cleanup_mutation_separation(text: str | None) -> dict[str, Any]:
|
|
"""Require merge mutations and cleanup mutations in separate ledger fields."""
|
|
text = text or ""
|
|
merge_match = _MERGE_MUTATIONS_RE.search(text)
|
|
cleanup_match = _CLEANUP_MUTATIONS_RE.search(text)
|
|
reasons: list[str] = []
|
|
|
|
if cleanup_match and not merge_match:
|
|
combined = re.search(
|
|
r"merge.{0,40}cleanup mutations|cleanup.{0,40}merge mutations",
|
|
text,
|
|
re.IGNORECASE,
|
|
)
|
|
if combined:
|
|
reasons.append(
|
|
"merge and cleanup mutations must use separate 'Merge mutations' "
|
|
"and 'Cleanup mutations' ledger fields"
|
|
)
|
|
|
|
if merge_match and cleanup_match:
|
|
merge_val = (merge_match.group(1) or "").strip().lower()
|
|
cleanup_val = (cleanup_match.group(1) or "").strip().lower()
|
|
if merge_val == cleanup_val and merge_val not in {"", "none"}:
|
|
reasons.append(
|
|
"merge mutations and cleanup mutations must not duplicate the "
|
|
"same ledger entry"
|
|
)
|
|
|
|
return {
|
|
"proven": not reasons,
|
|
"block": bool(reasons),
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"split merge mutations (gitea_merge_pr) from cleanup mutations "
|
|
"(reconciler MCP tools) in the controller handoff"
|
|
if reasons
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_authorized_reconciler_cleanup_path(text: str | None) -> dict[str, Any]:
|
|
"""Validate cleanup claims cite authorized MCP tools and reconciler capability."""
|
|
text = text or ""
|
|
claiming = _cleanup_claiming_ledger_fields(text)
|
|
if not claiming:
|
|
return {
|
|
"proven": True,
|
|
"block": False,
|
|
"reasons": [],
|
|
"safe_next_action": "proceed",
|
|
}
|
|
|
|
reasons: list[str] = []
|
|
for field_name, body in claiming:
|
|
if not _AUTHORIZED_TOOL_RE.search(body):
|
|
reasons.append(
|
|
f"{field_name} cleanup must name an authorized MCP cleanup tool "
|
|
f"({', '.join(sorted(AUTHORIZED_CLEANUP_TOOLS))})"
|
|
)
|
|
if not _RECONCILER_CAPABILITY_RE.search(text):
|
|
reasons.append(
|
|
"post-merge cleanup requires reconciler capability proof "
|
|
"(reconciler profile, gitea.branch.delete, or reconcile_merged_cleanups)"
|
|
)
|
|
|
|
return {
|
|
"proven": not reasons,
|
|
"block": bool(reasons),
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"hand cleanup to a prgs-reconciler session and cite the exact MCP tool "
|
|
"plus delete_branch/reconcile_merged_cleanups capability proof"
|
|
if reasons
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_merger_cleanup_handoff_guidance(text: str | None) -> dict[str, Any]:
|
|
"""Merger sessions that performed cleanup must hand off to reconciler."""
|
|
text = text or ""
|
|
if not _MERGER_CLEANUP_ROLE_RE.search(text):
|
|
return {
|
|
"proven": True,
|
|
"block": False,
|
|
"reasons": [],
|
|
"safe_next_action": "proceed",
|
|
}
|
|
if _HANDOFF_TO_RECONCILER_RE.search(text):
|
|
return {
|
|
"proven": True,
|
|
"block": False,
|
|
"reasons": [],
|
|
"safe_next_action": "proceed",
|
|
}
|
|
return {
|
|
"proven": False,
|
|
"block": True,
|
|
"reasons": [
|
|
"merger/reviewer session performed cleanup but did not hand off to "
|
|
"reconciler for MCP-native cleanup"
|
|
],
|
|
"safe_next_action": (
|
|
"merger sessions must end with cleanup handed to prgs-reconciler; "
|
|
"do not perform ad hoc branch/comment/worktree cleanup as merger"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_mcp_native_cleanup_proof(report_text: str | None) -> dict[str, Any]:
|
|
"""Composite #517 verifier for MCP-native post-merge cleanup proof."""
|
|
text = report_text or ""
|
|
checks = (
|
|
assess_raw_branch_delete_report(text),
|
|
assess_raw_comment_delete_report(text),
|
|
assess_merge_cleanup_mutation_separation(text),
|
|
assess_authorized_reconciler_cleanup_path(text),
|
|
assess_merger_cleanup_handoff_guidance(text),
|
|
)
|
|
reasons: list[str] = []
|
|
safe_next = "proceed"
|
|
for result in checks:
|
|
reasons.extend(result.get("reasons") or [])
|
|
if result.get("block") and result.get("safe_next_action"):
|
|
safe_next = result["safe_next_action"]
|
|
|
|
block = bool(reasons)
|
|
return {
|
|
"proven": not block,
|
|
"block": block,
|
|
"reasons": reasons,
|
|
"safe_next_action": safe_next,
|
|
"raw_branch_commands": raw_branch_delete_commands(
|
|
scoped_cleanup_mutation_ledger_text(text)
|
|
),
|
|
"raw_comment_commands": raw_comment_delete_commands(
|
|
scoped_cleanup_mutation_ledger_text(text)
|
|
),
|
|
} |