Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user