#!/usr/bin/env python3 """Validate that a 1C change proposal stays within the read-only/extension-first safety contract.""" from __future__ import annotations import argparse import json from pathlib import Path from typing import Any FORBIDDEN_PATH_PARTS = { "config", "configsave", "configcas", } def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8-sig")) def issue(severity: str, code: str, message: str, *, target: dict[str, Any] | None = None) -> dict[str, Any]: result = {"severity": severity, "code": code, "message": message} if target: result["target"] = target return result def path_text(target: dict[str, Any]) -> str: return str(target.get("path") or target.get("module_path") or "") def normalized_path_parts(path: str) -> list[str]: return [part.casefold() for part in path.replace("/", "\\").split("\\") if part] def is_extension_origin(target: dict[str, Any], preferred_extension: str | None) -> bool: origin = target.get("origin") or {} if origin.get("layer") != "extension": return False if preferred_extension and origin.get("extension") != preferred_extension: return False return True def is_extension_path(path: str, preferred_extension: str | None) -> bool: parts = normalized_path_parts(path) if "расширения" not in parts and "extensions" not in parts: return False if preferred_extension: lowered = preferred_extension.casefold() return lowered in parts return True def forbidden_path_reason(path: str) -> str | None: parts = normalized_path_parts(path) for part in parts: if part in FORBIDDEN_PATH_PARTS: return part if path.startswith("_") or "\\_" in path: return "sql_physical_name_like_path" return None def check_target_exists(target: dict[str, Any]) -> list[dict[str, Any]]: findings = [] path = path_text(target) if not path: findings.append(issue("error", "missing_path", "Target has no path.", target=target)) return findings if not Path(path).exists(): findings.append(issue("error", "path_not_found", f"Target path does not exist: {path}", target=target)) line = target.get("line") if line: try: line_int = int(line) if line_int < 1: findings.append(issue("error", "invalid_line", f"Invalid target line: {line}", target=target)) except (TypeError, ValueError): findings.append(issue("error", "invalid_line", f"Invalid target line: {line}", target=target)) return findings def check_write_candidate(target: dict[str, Any], preferred_extension: str | None) -> list[dict[str, Any]]: findings = [] path = path_text(target) findings.extend(check_target_exists(target)) if not is_extension_origin(target, preferred_extension): findings.append(issue("error", "write_candidate_not_preferred_extension_origin", "Write candidate is not in the preferred extension origin.", target=target)) if not is_extension_path(path, preferred_extension): findings.append(issue("error", "write_candidate_not_preferred_extension_path", "Write candidate path is not inside the preferred extension directory.", target=target)) forbidden = forbidden_path_reason(path) if forbidden: findings.append(issue("error", "forbidden_write_path", f"Write candidate path is forbidden: {forbidden}", target=target)) if target.get("kind") not in {"bsl_module", "form_xml"}: findings.append(issue("warning", "unusual_write_candidate_kind", f"Unexpected write candidate kind: {target.get('kind')}", target=target)) return findings def check_reference_target(target: dict[str, Any]) -> list[dict[str, Any]]: findings = check_target_exists(target) forbidden = forbidden_path_reason(path_text(target)) if forbidden: findings.append(issue("warning", "forbidden_reference_path", f"Reference path is forbidden for writes and must remain read-only: {forbidden}", target=target)) return findings def check_proposal(proposal: dict[str, Any]) -> dict[str, Any]: findings = [] strategy = proposal.get("write_strategy") or {} preferred_extension = strategy.get("preferred_extension") if strategy.get("mode") != "extension_first_proposal": findings.append(issue("error", "unsupported_write_strategy", f"Unsupported write strategy: {strategy.get('mode')}")) if not preferred_extension: findings.append(issue("warning", "missing_preferred_extension", "No preferred extension selected; patch generation should create/choose an extension explicitly.")) policy = proposal.get("target_policy") or {} write_candidates = policy.get("write_candidates") or [] references = policy.get("read_only_reference_files") or [] if not write_candidates: findings.append(issue("warning", "no_write_candidates", "No write candidates were selected.")) for target in write_candidates: findings.extend(check_write_candidate(target, preferred_extension)) for target in references: findings.extend(check_reference_target(target)) forbidden = set(strategy.get("forbidden") or []) required_forbidden = { "direct SQL metadata/data updates", "direct Config/ConfigSave/ConfigCAS writes", "automatic production Designer update/apply", } missing = sorted(required_forbidden - forbidden) if missing: findings.append(issue("error", "missing_forbidden_strategy_items", "Write strategy is missing forbidden items: " + ", ".join(missing))) errors = [row for row in findings if row.get("severity") == "error"] warnings = [row for row in findings if row.get("severity") == "warning"] return { "object": proposal.get("object"), "preferred_extension": preferred_extension, "passed": not errors, "findings": findings, "counts": { "errors": len(errors), "warnings": len(warnings), "write_candidates": len(write_candidates), "read_only_references": len(references), }, } def check(data: dict[str, Any]) -> dict[str, Any]: proposal_checks = [check_proposal(proposal) for proposal in data.get("proposals") or []] errors = sum(item.get("counts", {}).get("errors", 0) for item in proposal_checks) warnings = sum(item.get("counts", {}).get("warnings", 0) for item in proposal_checks) return { "schema": "onec_change_proposal_safety_check.v1", "source_schema": data.get("schema"), "task": data.get("task"), "passed": errors == 0, "proposal_checks": proposal_checks, "required_gates_before_real_write": [ "backup_gate", "round_trip_parser_gate", "designer_validation_gate", "saved_state_gate", "extension_packaging_gate", "diff_gate", "minimal_write_scope_gate", "recovery_test_gate", ], "counts": { "proposals": len(proposal_checks), "errors": errors, "warnings": warnings, }, } def main() -> int: parser = argparse.ArgumentParser(description="Check 1C proposal safety.") parser.add_argument("--proposal", type=Path, required=True) parser.add_argument("--output", type=Path) args = parser.parse_args() result = check(load_json(args.proposal)) output = json.dumps(result, ensure_ascii=False, indent=2) + "\n" if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(output, encoding="utf-8") print(json.dumps({"output": str(args.output), "passed": result["passed"], "counts": result["counts"]}, ensure_ascii=False)) else: print(output) return 0 if result["passed"] else 2 if __name__ == "__main__": raise SystemExit(main())