Files
llm/scripts/check_1c_patch_bundle.py
T

186 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Validate a 1C patch review bundle directory and optional zip archive."""
from __future__ import annotations
import argparse
import hashlib
import json
import zipfile
from pathlib import Path
from typing import Any
REQUIRED_FILES = {"manifest.json", "preflight.json", "preflight.md", "diff.json", "README.md"}
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8-sig"))
def write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def issue(severity: str, code: str, message: str, *, path: Path | str | None = None, detail: dict[str, Any] | None = None) -> dict[str, Any]:
result: dict[str, Any] = {"severity": severity, "code": code, "message": message}
if path is not None:
result["path"] = str(path)
if detail:
result["detail"] = detail
return result
def safe_relative(relative_path: str) -> Path:
path = Path(relative_path.replace("\\", "/"))
if path.is_absolute() or ".." in path.parts or not str(path):
raise ValueError(relative_path)
return path
def all_files(root: Path) -> set[str]:
if not root.exists():
return set()
return {str(path.relative_to(root)).replace("\\", "/") for path in root.rglob("*") if path.is_file()}
def expected_working_hash(record: dict[str, Any]) -> str | None:
sha = record.get("sha256") or {}
if isinstance(sha, dict):
return sha.get("working")
return None
def check_bundle(bundle_dir: Path, zip_path: Path | None = None) -> dict[str, Any]:
findings: list[dict[str, Any]] = []
file_checks: list[dict[str, Any]] = []
if not bundle_dir.exists() or not bundle_dir.is_dir():
findings.append(issue("error", "missing_bundle_dir", "Bundle directory is missing.", path=bundle_dir))
return build_result(bundle_dir, zip_path, findings, file_checks, None)
present = all_files(bundle_dir)
for required in sorted(REQUIRED_FILES):
if required not in present:
findings.append(issue("error", "missing_bundle_file", "Required bundle file is missing.", path=bundle_dir / required))
manifest_path = bundle_dir / "manifest.json"
if not manifest_path.exists():
return build_result(bundle_dir, zip_path, findings, file_checks, None)
manifest = load_json(manifest_path)
if manifest.get("schema") != "onec_patch_bundle.v1":
findings.append(issue("error", "invalid_bundle_schema", "Bundle manifest schema is not onec_patch_bundle.v1.", path=manifest_path, detail={"schema": manifest.get("schema")}))
preflight = load_json(bundle_dir / "preflight.json") if (bundle_dir / "preflight.json").exists() else {}
if preflight.get("status") != "ready_for_review":
findings.append(issue("error", "invalid_preflight_status", "Bundle preflight must be ready_for_review.", path=bundle_dir / "preflight.json", detail={"status": preflight.get("status")}))
if not preflight.get("passed"):
findings.append(issue("error", "preflight_not_passed", "Bundle preflight is not passed.", path=bundle_dir / "preflight.json"))
diff = load_json(bundle_dir / "diff.json") if (bundle_dir / "diff.json").exists() else {}
modified = [
str(item.get("relative_path") or "").replace("\\", "/")
for item in diff.get("files") or []
if item.get("status") == "modified"
]
manifest_relatives = [str(item.get("relative_path") or "").replace("\\", "/") for item in manifest.get("files") or []]
if sorted(modified) != sorted(manifest_relatives):
findings.append(issue("error", "bundle_diff_manifest_mismatch", "Modified diff files do not match bundle manifest files.", detail={"diff_modified": modified, "manifest_files": manifest_relatives}))
expected_bundle_files = set(REQUIRED_FILES)
for record in manifest.get("files") or []:
bundle_path_raw = str(record.get("bundle_path") or "")
try:
bundle_path = safe_relative(bundle_path_raw)
except ValueError:
findings.append(issue("error", "unsafe_bundle_path", "Unsafe bundle_path in manifest.", detail={"bundle_path": bundle_path_raw}))
continue
expected_bundle_files.add(str(bundle_path).replace("\\", "/"))
path = bundle_dir / bundle_path
check: dict[str, Any] = {
"relative_path": record.get("relative_path"),
"bundle_path": str(bundle_path).replace("\\", "/"),
"exists": path.exists(),
"expected_sha256": expected_working_hash(record),
}
if not path.exists():
findings.append(issue("error", "missing_modified_file", "Modified bundle file is missing.", path=path))
else:
actual = sha256_file(path)
check["sha256"] = actual
expected = expected_working_hash(record)
if expected and actual != expected:
findings.append(issue("error", "modified_file_hash_mismatch", "Modified bundle file hash does not match working hash.", path=path, detail={"expected": expected, "actual": actual}))
file_checks.append(check)
extra_files = sorted(present - expected_bundle_files)
for relative in extra_files:
findings.append(issue("warning", "extra_bundle_file", "Unexpected file in bundle directory.", path=bundle_dir / relative))
if zip_path is None:
candidate = bundle_dir.with_suffix(".zip")
zip_path = candidate if candidate.exists() else None
if zip_path is None:
findings.append(issue("warning", "missing_bundle_zip", "Bundle zip archive was not found."))
elif not zip_path.exists():
findings.append(issue("error", "missing_bundle_zip", "Bundle zip archive path does not exist.", path=zip_path))
else:
try:
with zipfile.ZipFile(zip_path, "r") as archive:
bad_member = archive.testzip()
if bad_member:
findings.append(issue("error", "invalid_bundle_zip_member", "Zip archive contains a corrupt member.", path=zip_path, detail={"member": bad_member}))
zip_files = {name.replace("\\", "/") for name in archive.namelist() if not name.endswith("/")}
if zip_files != present:
findings.append(issue("error", "bundle_zip_mismatch", "Zip contents differ from bundle directory files.", path=zip_path, detail={"missing_in_zip": sorted(present - zip_files), "extra_in_zip": sorted(zip_files - present)}))
except zipfile.BadZipFile as exc:
findings.append(issue("error", "invalid_bundle_zip", f"Invalid zip archive: {exc}", path=zip_path))
return build_result(bundle_dir, zip_path, findings, file_checks, manifest)
def build_result(bundle_dir: Path, zip_path: Path | None, findings: list[dict[str, Any]], file_checks: list[dict[str, Any]], manifest: dict[str, Any] | None) -> dict[str, Any]:
errors = [row for row in findings if row.get("severity") == "error"]
warnings = [row for row in findings if row.get("severity") == "warning"]
return {
"schema": "onec_patch_bundle_check.v1",
"bundle_dir": str(bundle_dir),
"zip_path": str(zip_path) if zip_path else None,
"bundle_schema": (manifest or {}).get("schema"),
"passed": not errors,
"findings": findings,
"file_checks": file_checks,
"counts": {
"files": len(file_checks),
"errors": len(errors),
"warnings": len(warnings),
},
}
def main() -> int:
parser = argparse.ArgumentParser(description="Validate a 1C patch review bundle.")
parser.add_argument("--bundle-dir", type=Path, required=True)
parser.add_argument("--zip", type=Path)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
result = check_bundle(args.bundle_dir, args.zip)
if args.output:
write_json(args.output, result)
print(json.dumps({"output": str(args.output) if args.output else None, "passed": result["passed"], "counts": result["counts"]}, ensure_ascii=False))
return 0 if result["passed"] else 2
if __name__ == "__main__":
raise SystemExit(main())