173 lines
7.8 KiB
Python
173 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
||
"""Check manual evidence files for a 1C extension validation plan."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
PENDING_MARKERS = ("status: pending", '"status": "pending"')
|
||
PASSED_STATUSES = {"passed", "success", "ok"}
|
||
STATUS_LINE_RE = re.compile(r"^\s*status\s*:\s*([A-Za-zА-Яа-я0-9_-]+)\s*$", re.IGNORECASE | re.MULTILINE)
|
||
SECRET_TEXT_RE = re.compile(r"(password|passwd|pwd|secret|token|парол|секрет|usr|user)\s*[:=]", re.IGNORECASE)
|
||
|
||
|
||
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 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 evidence_root(plan: dict[str, Any], override: Path | None = None) -> Path:
|
||
if override:
|
||
return override
|
||
configured = ((plan.get("runner_config") or {}).get("evidence_root")) or ((plan.get("evidence") or {}).get("root"))
|
||
if not configured:
|
||
raise SystemExit("Validation plan has no evidence root.")
|
||
return Path(str(configured))
|
||
|
||
|
||
def text_status(text: str) -> str | None:
|
||
match = STATUS_LINE_RE.search(text)
|
||
return match.group(1).casefold() if match else None
|
||
|
||
|
||
def check_json_evidence(path: Path, text: str, findings: list[dict[str, Any]]) -> tuple[bool, str | None]:
|
||
try:
|
||
payload = json.loads(text)
|
||
except json.JSONDecodeError as exc:
|
||
findings.append(issue("error", "invalid_json_evidence", f"Evidence JSON is invalid: {exc}", path=path))
|
||
return False, None
|
||
status = str(payload.get("status") or "").casefold()
|
||
if status not in PASSED_STATUSES:
|
||
findings.append(issue("error", "evidence_status_not_passed", "Evidence JSON status must be passed/success/ok.", path=path, detail={"status": status or None}))
|
||
return False, status or None
|
||
if path.name == "changed-objects-smoke.json":
|
||
objects = payload.get("objects")
|
||
if not isinstance(objects, list) or not objects:
|
||
findings.append(issue("error", "missing_smoke_objects", "changed-objects-smoke.json must contain a non-empty objects list.", path=path))
|
||
return False, status
|
||
failed = [
|
||
{"object_name": item.get("object_name"), "status": item.get("status")}
|
||
for item in objects
|
||
if not isinstance(item, dict) or str(item.get("status") or "").casefold() not in PASSED_STATUSES
|
||
]
|
||
if failed:
|
||
findings.append(issue("error", "smoke_object_status_not_passed", "Every changed object smoke record must have status passed/success/ok.", path=path, detail={"failed": failed}))
|
||
return False, status
|
||
return True, status
|
||
|
||
|
||
def check_text_evidence(path: Path, text: str, findings: list[dict[str, Any]]) -> tuple[bool, str | None]:
|
||
status = text_status(text)
|
||
if status not in PASSED_STATUSES:
|
||
findings.append(issue("error", "evidence_status_not_passed", "Evidence text must contain a Status: passed/success/ok line.", path=path, detail={"status": status}))
|
||
return False, status
|
||
return True, status
|
||
|
||
|
||
def check_evidence(plan_path: Path, output_root: Path | None = None) -> dict[str, Any]:
|
||
findings: list[dict[str, Any]] = []
|
||
file_checks: list[dict[str, Any]] = []
|
||
plan = load_json(plan_path)
|
||
if plan.get("schema") != "onec_extension_validation_plan.v1":
|
||
findings.append(issue("error", "invalid_plan_schema", "Validation plan schema is not onec_extension_validation_plan.v1.", path=plan_path, detail={"schema": plan.get("schema")}))
|
||
return build_result(plan_path, Path("."), findings, file_checks)
|
||
|
||
root = evidence_root(plan, output_root)
|
||
if not root.exists() or not root.is_dir():
|
||
findings.append(issue("error", "missing_evidence_root", "Evidence root is missing.", path=root))
|
||
return build_result(plan_path, root, findings, file_checks)
|
||
|
||
for name in (plan.get("evidence") or {}).get("required_files") or []:
|
||
relative = Path(str(name).replace("\\", "/"))
|
||
path = root / relative
|
||
check: dict[str, Any] = {
|
||
"relative_path": str(relative).replace("\\", "/"),
|
||
"path": str(path),
|
||
"exists": path.exists(),
|
||
"filled": False,
|
||
}
|
||
if not path.exists():
|
||
findings.append(issue("error", "missing_evidence_file", "Required evidence file is missing.", path=path))
|
||
else:
|
||
text = path.read_text(encoding="utf-8-sig", errors="replace")
|
||
stripped = text.strip()
|
||
check["size"] = len(text.encode("utf-8"))
|
||
if SECRET_TEXT_RE.search(text):
|
||
findings.append(issue("error", "secret_like_text_in_evidence", "Evidence file contains secret-like key/value text.", path=path))
|
||
pending = any(marker in text.casefold() for marker in PENDING_MARKERS)
|
||
if pending:
|
||
findings.append(issue("error", "pending_evidence_file", "Evidence file still contains a pending template.", path=path))
|
||
if not stripped:
|
||
findings.append(issue("error", "empty_evidence_file", "Evidence file is empty.", path=path))
|
||
elif path.suffix.casefold() == ".json":
|
||
passed, status = check_json_evidence(path, text, findings)
|
||
check["status"] = status
|
||
check["filled"] = passed and not pending
|
||
else:
|
||
passed, status = check_text_evidence(path, text, findings)
|
||
check["status"] = status
|
||
check["filled"] = passed and not pending
|
||
file_checks.append(check)
|
||
|
||
manifest_path = root / "_codex_validation_evidence_manifest.json"
|
||
if not manifest_path.exists():
|
||
findings.append(issue("warning", "missing_evidence_manifest", "Evidence manifest is missing.", path=manifest_path))
|
||
|
||
return build_result(plan_path, root, findings, file_checks)
|
||
|
||
|
||
def build_result(plan_path: Path, root: Path, findings: list[dict[str, Any]], file_checks: list[dict[str, Any]]) -> 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_extension_validation_evidence_check.v1",
|
||
"plan_path": str(plan_path),
|
||
"evidence_root": str(root),
|
||
"passed": not errors,
|
||
"status": "validated" if not errors else "pending_or_blocked",
|
||
"findings": findings,
|
||
"file_checks": file_checks,
|
||
"counts": {
|
||
"files": len(file_checks),
|
||
"filled": sum(1 for item in file_checks if item.get("filled")),
|
||
"errors": len(errors),
|
||
"warnings": len(warnings),
|
||
},
|
||
}
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="Check manual evidence files for a 1C extension validation plan.")
|
||
parser.add_argument("--plan", type=Path, required=True)
|
||
parser.add_argument("--evidence-root", type=Path)
|
||
parser.add_argument("--output", type=Path)
|
||
args = parser.parse_args()
|
||
|
||
result = check_evidence(args.plan, args.evidence_root)
|
||
if args.output:
|
||
write_json(args.output, result)
|
||
print(json.dumps({"output": str(args.output) if args.output else None, "passed": result["passed"], "status": result["status"], "counts": result["counts"]}, ensure_ascii=False))
|
||
return 0 if result["passed"] else 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|