Files
yao-meta-skill/scripts/apply_adaptation.py
T
2026-06-15 22:58:21 +08:00

459 lines
18 KiB
Python

#!/usr/bin/env python3
"""Apply an approved adaptation patch with allowlisted targets and regression evidence."""
import argparse
import hashlib
import json
import os
import shlex
import subprocess
import sys
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
SCRIPT_INTERFACE = "cli"
SCRIPT_INTERFACE_REASON = "Approval-gated adaptive patch application with dry-run, allowlist, regression, and rollback evidence."
BLOCKED_PATH_PARTS = {".git", "__pycache__", ".pytest_cache", "dist"}
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def display_path(path: Path, skill_dir: Path) -> str:
try:
return str(path.resolve().relative_to(skill_dir.resolve()))
except ValueError:
return str(path)
def resolve_path(skill_dir: Path, value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else skill_dir / path
def load_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
return payload if isinstance(payload, dict) else {}
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def normalize_patch_path(raw: str) -> str | None:
token = raw.strip().split("\t", 1)[0].split(" ", 1)[0]
if token == "/dev/null":
return None
if token.startswith("a/") or token.startswith("b/"):
token = token[2:]
path = Path(token)
if path.is_absolute() or ".." in path.parts or any(part in BLOCKED_PATH_PARTS for part in path.parts):
raise ValueError(f"Unsafe patch path: {raw}")
if not token or token == ".":
raise ValueError(f"Empty patch path: {raw}")
return token
def patch_target_files(patch_text: str) -> list[str]:
targets: set[str] = set()
for line in patch_text.splitlines():
if line.startswith("--- ") or line.startswith("+++ "):
raw = line[4:].strip()
path = normalize_patch_path(raw)
if path:
targets.add(path)
return sorted(targets)
def approved_entries(ledger: dict[str, Any]) -> list[dict[str, Any]]:
entries = ledger.get("entries")
return [item for item in entries if isinstance(item, dict)] if isinstance(entries, list) else []
def find_proposal(proposals: dict[str, Any], proposal_id: str) -> dict[str, Any]:
for item in proposals.get("proposals", []):
if isinstance(item, dict) and item.get("proposal_id") == proposal_id:
return item
return {}
def find_approval(ledger: dict[str, Any], proposal_id: str) -> dict[str, Any]:
for item in approved_entries(ledger):
if item.get("proposal_id") == proposal_id and item.get("decision") == "approved":
return item
return {}
def parse_date(value: str) -> date | None:
if not value:
return None
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
def validate_approval(approval: dict[str, Any], today: date) -> list[str]:
failures = []
required = ["reviewer", "reason", "approved_at", "patch_sha256", "target_files", "verification_commands", "rollback_plan"]
for key in required:
if not approval.get(key):
failures.append(f"Approval entry missing required field: {key}")
expires_at = parse_date(str(approval.get("expires_at", "")))
if expires_at and expires_at < today:
failures.append(f"Approval entry is expired: {approval.get('expires_at')}")
if not isinstance(approval.get("target_files"), list):
failures.append("Approval target_files must be a list.")
if not isinstance(approval.get("verification_commands"), list):
failures.append("Approval verification_commands must be a list.")
return failures
def safe_command(command: str) -> tuple[bool, list[str], str]:
try:
parts = shlex.split(command)
except ValueError as exc:
return False, [], f"cannot parse command: {exc}"
if not parts:
return False, [], "empty command"
if parts[0] == "make" and len(parts) == 2 and all(ch.isalnum() or ch in {"-", "_"} for ch in parts[1]):
return True, parts, "make target"
if parts[0] in {"python3", sys.executable} and len(parts) >= 2:
script = Path(parts[1])
if not script.is_absolute() and script.parts and script.parts[0] in {"tests", "scripts"} and script.suffix == ".py":
return True, parts, "local python verifier"
return False, parts, "command is not in the safe regression allowlist"
def run_command(command: str, skill_dir: Path) -> dict[str, Any]:
allowed, parts, reason = safe_command(command)
if not allowed:
return {"command": command, "ok": False, "returncode": None, "stdout": "", "stderr": reason}
proc = subprocess.run(parts, cwd=skill_dir, capture_output=True, text=True)
return {
"command": command,
"ok": proc.returncode == 0,
"returncode": proc.returncode,
"stdout": proc.stdout[-4000:],
"stderr": proc.stderr[-4000:],
}
def git_apply_check(skill_dir: Path, patch_file: Path) -> dict[str, Any]:
env = dict(os.environ)
env["GIT_CEILING_DIRECTORIES"] = str(skill_dir.parent)
proc = subprocess.run(["git", "apply", "--check", str(patch_file)], cwd=skill_dir, capture_output=True, text=True, env=env)
return {"ok": proc.returncode == 0, "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}
def git_apply(skill_dir: Path, patch_file: Path) -> dict[str, Any]:
env = dict(os.environ)
env["GIT_CEILING_DIRECTORIES"] = str(skill_dir.parent)
proc = subprocess.run(["git", "apply", str(patch_file)], cwd=skill_dir, capture_output=True, text=True, env=env)
return {"ok": proc.returncode == 0, "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}
def git_apply_reverse(skill_dir: Path, patch_file: Path) -> dict[str, Any]:
env = dict(os.environ)
env["GIT_CEILING_DIRECTORIES"] = str(skill_dir.parent)
proc = subprocess.run(["git", "apply", "-R", str(patch_file)], cwd=skill_dir, capture_output=True, text=True, env=env)
return {"ok": proc.returncode == 0, "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}
def empty_approval_ledger(generated_at: str) -> dict[str, Any]:
return {
"schema_version": "1.0",
"ok": True,
"generated_at": generated_at,
"summary": {
"approval_count": 0,
"active_approval_count": 0,
"applied_count": 0,
"rollback_count": 0,
},
"approval_contract": {
"approval_required": True,
"patch_sha256_required": True,
"allowlisted_targets_required": True,
"dry_run_default": True,
"writes_repository_files_only_with_apply": True,
"rollback_required": True,
},
"entries": [],
}
def empty_regression_report(skill_dir: Path, generated_at: str) -> dict[str, Any]:
return {
"schema_version": "1.0",
"ok": True,
"generated_at": generated_at,
"skill_dir": display_path(skill_dir, skill_dir),
"summary": {
"apply_supported": True,
"attempt_count": 0,
"applied_count": 0,
"dry_run_count": 0,
"rollback_count": 0,
"regression_run_count": 0,
"regression_pass_count": 0,
"failure_count": 0,
},
"apply_contract": {
"approval_required": True,
"patch_sha256_required": True,
"allowlisted_targets_required": True,
"dry_run_default": True,
"writes_repository_files_only_with_apply": True,
"rollback_required": True,
"safe_regression_commands_only": True,
"rollback_on_failure_default": True,
},
"attempts": [],
"failures": [],
"artifacts": {
"json": "reports/adaptation_regression_report.json",
"approval_ledger": "reports/adaptation_approval_ledger.json",
},
}
def write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def render_markdown(report: dict[str, Any]) -> str:
summary = report["summary"]
lines = [
"# Adaptation Regression Report",
"",
f"- generated_at: `{report['generated_at']}`",
f"- apply_supported: `{str(summary['apply_supported']).lower()}`",
f"- attempts: `{summary['attempt_count']}`",
f"- applied: `{summary['applied_count']}`",
f"- dry runs: `{summary['dry_run_count']}`",
f"- rollbacks: `{summary.get('rollback_count', 0)}`",
f"- failures: `{summary['failure_count']}`",
"",
"This report proves the adaptive apply harness behavior. It does not count proposals as applied changes.",
"",
]
for attempt in report.get("attempts", []):
lines.extend(
[
f"## {attempt['proposal_id']}",
"",
f"- mode: `{attempt['mode']}`",
f"- status: `{attempt['status']}`",
f"- patch: `{attempt['patch']}`",
f"- patch sha256: `{attempt['patch_sha256']}`",
f"- targets: `{', '.join(attempt['target_files'])}`",
f"- rollback: {attempt['rollback']['plan']}",
]
)
if attempt.get("regression_runs"):
lines.append("- regression:")
lines.extend(f" - `{item['command']}`: `{str(item['ok']).lower()}`" for item in attempt["regression_runs"])
lines.append("")
if report.get("failures"):
lines.extend(["## Failures", ""])
lines.extend(f"- {failure}" for failure in report["failures"])
return "\n".join(lines).rstrip() + "\n"
def build_report(args: argparse.Namespace) -> dict[str, Any]:
skill_dir = Path(args.skill_dir).resolve()
generated_at = args.generated_at or utc_now()
ledger_path = resolve_path(skill_dir, args.approval_ledger)
output_json = resolve_path(skill_dir, args.output_json)
output_md = resolve_path(skill_dir, args.output_md)
if args.write_template:
ledger = empty_approval_ledger(generated_at)
report = empty_regression_report(skill_dir, generated_at)
write_json(ledger_path, ledger)
write_json(output_json, report)
output_md.parent.mkdir(parents=True, exist_ok=True)
output_md.write_text(render_markdown(report), encoding="utf-8")
return report
failures: list[str] = []
proposals_path = resolve_path(skill_dir, args.proposals_json)
patch_path = resolve_path(skill_dir, args.patch_file) if args.patch_file else Path("")
proposals = load_json(proposals_path)
ledger = load_json(ledger_path)
if not proposals:
failures.append(f"Proposal report missing or invalid: {display_path(proposals_path, skill_dir)}")
if not ledger:
failures.append(f"Approval ledger missing or invalid: {display_path(ledger_path, skill_dir)}")
if not args.proposal_id:
failures.append("--proposal-id is required unless --write-template is used.")
if not args.patch_file or not patch_path.exists():
failures.append("--patch-file must point to an existing unified diff.")
today = parse_date(str(args.today or date.today().isoformat()))
if today is None:
failures.append("--today must be an ISO date when provided.")
proposal = find_proposal(proposals, args.proposal_id) if not failures else {}
approval = find_approval(ledger, args.proposal_id) if not failures else {}
if not failures and not proposal:
failures.append(f"Proposal id is not present in proposal report: {args.proposal_id}")
if not failures and not approval:
failures.append(f"Proposal id is not approved in the ledger: {args.proposal_id}")
if approval:
failures.extend(validate_approval(approval, today or date.today()))
patch_sha = sha256_file(patch_path) if patch_path.exists() else ""
target_files: list[str] = []
if not failures:
try:
target_files = patch_target_files(patch_path.read_text(encoding="utf-8", errors="replace"))
except ValueError as exc:
failures.append(str(exc))
if not failures:
approved_targets = set(str(item) for item in approval.get("target_files", []))
proposal_targets = set(str(item) for item in proposal.get("target_files", []))
patch_targets = set(target_files)
if patch_sha != approval.get("patch_sha256"):
failures.append("Patch sha256 does not match approval ledger.")
if not patch_targets:
failures.append("Patch does not declare any target files.")
if not patch_targets <= approved_targets:
failures.append("Patch touches files outside approval target_files.")
if not patch_targets <= proposal_targets:
failures.append("Patch touches files outside proposal target_files.")
check = git_apply_check(skill_dir, patch_path) if not failures else {"ok": False, "returncode": None, "stdout": "", "stderr": ""}
if not failures and not check["ok"]:
failures.append(f"git apply --check failed: {check['stderr'].strip()}")
mode = "apply" if args.apply else "dry-run"
applied = False
apply_result = {"ok": False, "returncode": None, "stdout": "", "stderr": "not requested"}
if not failures and args.apply:
apply_result = git_apply(skill_dir, patch_path)
applied = bool(apply_result["ok"])
if not applied:
failures.append(f"git apply failed: {apply_result['stderr'].strip()}")
regression_runs: list[dict[str, Any]] = []
if not failures and args.run_verification:
for command in approval.get("verification_commands", []):
regression_runs.append(run_command(str(command), skill_dir))
if any(not item["ok"] for item in regression_runs):
failures.append("One or more regression commands failed or were not allowed.")
rollback_result = {"ok": None, "returncode": None, "stdout": "", "stderr": "not needed"}
rolled_back = False
if applied and failures and args.rollback_on_failure:
rollback_result = git_apply_reverse(skill_dir, patch_path)
rolled_back = bool(rollback_result["ok"])
if not rolled_back:
failures.append(f"Automatic rollback failed: {rollback_result['stderr'].strip()}")
attempt = {
"proposal_id": args.proposal_id or "",
"mode": mode,
"status": "failed-rolled-back" if rolled_back else ("failed" if failures else ("applied" if applied else "dry-run-pass")),
"patch": display_path(patch_path, skill_dir) if args.patch_file else "",
"patch_sha256": patch_sha,
"target_files": target_files,
"approval": {
"reviewer": approval.get("reviewer", ""),
"approved_at": approval.get("approved_at", ""),
"expires_at": approval.get("expires_at", ""),
},
"git_apply_check": check,
"git_apply": apply_result,
"regression_runs": regression_runs,
"rollback_result": rollback_result,
"rollback": {
"plan": approval.get("rollback_plan", ""),
"command": f"git apply -R {display_path(patch_path, skill_dir)}" if args.patch_file else "",
},
}
report = {
"schema_version": "1.0",
"ok": not failures,
"generated_at": generated_at,
"skill_dir": display_path(skill_dir, skill_dir),
"summary": {
"apply_supported": True,
"attempt_count": 1,
"applied_count": 1 if applied and not rolled_back else 0,
"dry_run_count": 0 if args.apply else 1,
"rollback_count": 1 if rolled_back else 0,
"regression_run_count": len(regression_runs),
"regression_pass_count": sum(1 for item in regression_runs if item["ok"]),
"failure_count": len(failures),
},
"apply_contract": {
"approval_required": True,
"patch_sha256_required": True,
"allowlisted_targets_required": True,
"dry_run_default": True,
"writes_repository_files_only_with_apply": True,
"rollback_required": True,
"safe_regression_commands_only": True,
"rollback_on_failure_default": True,
},
"attempts": [attempt],
"failures": failures,
"artifacts": {
"json": display_path(output_json, skill_dir),
"markdown": display_path(output_md, skill_dir),
"approval_ledger": display_path(ledger_path, skill_dir),
},
}
write_json(output_json, report)
output_md.parent.mkdir(parents=True, exist_ok=True)
output_md.write_text(render_markdown(report), encoding="utf-8")
return report
def main() -> None:
parser = argparse.ArgumentParser(description="Apply an approved adaptation patch with review and regression evidence.")
parser.add_argument("skill_dir", nargs="?", default=".")
parser.add_argument("--proposal-id")
parser.add_argument("--patch-file")
parser.add_argument("--proposals-json", default="reports/adaptation_proposals.json")
parser.add_argument("--approval-ledger", default="reports/adaptation_approval_ledger.json")
parser.add_argument("--output-json", default="reports/adaptation_regression_report.json")
parser.add_argument("--output-md", default="reports/adaptation_regression_report.md")
parser.add_argument("--generated-at")
parser.add_argument("--today")
parser.add_argument("--write-template", action="store_true")
parser.add_argument("--apply", action="store_true", help="Write the patch after every approval and allowlist check passes.")
parser.add_argument("--run-verification", action="store_true")
parser.add_argument(
"--no-rollback-on-failure",
dest="rollback_on_failure",
action="store_false",
help="Leave an applied patch in place if verification fails. Default is to reverse the patch.",
)
parser.set_defaults(rollback_on_failure=True)
args = parser.parse_args()
report = build_report(args)
print(json.dumps(report, ensure_ascii=False, indent=2))
raise SystemExit(0 if report["ok"] else 2)
if __name__ == "__main__":
main()