#!/usr/bin/env python3 """Compare two 1C saved-state object reports in 1C object terms.""" from __future__ import annotations import argparse import hashlib import importlib.util import json import sys from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parents[1] 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 object_map(report: dict[str, Any]) -> dict[str, dict[str, Any]]: items = ((report.get("agent_summary") or {}).get("object_changes") or []) result: dict[str, dict[str, Any]] = {} for item in items: if not isinstance(item, dict): continue full_name = str(item.get("full_name") or "") if full_name: result[full_name] = item return result def system_map(report: dict[str, Any]) -> dict[str, dict[str, Any]]: items = ((report.get("agent_summary") or {}).get("system_changes") or []) result: dict[str, dict[str, Any]] = {} for item in items: if not isinstance(item, dict): continue key = f"{item.get('layer')}::{item.get('extension')}::{item.get('name')}" result[key] = item return result def stable_fingerprint(value: Any) -> str: encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def comparable_object(item: dict[str, Any]) -> dict[str, Any]: return { "full_name": item.get("full_name"), "layer": item.get("layer"), "extension": item.get("extension"), "kind": item.get("kind"), "kind_ru": item.get("kind_ru"), "name": item.get("name"), "synonym": item.get("synonym"), "parts_count": item.get("parts_count"), "text_diff_parts": item.get("text_diff_parts"), "active_missing_parts": item.get("active_missing_parts"), "added_terms": item.get("added_terms") or [], "removed_terms": item.get("removed_terms") or [], "parts": [ { "file_name": part.get("file_name"), "payload_role": part.get("payload_role"), "active_exists": part.get("active_exists"), "summary": part.get("summary"), "delta_chars": part.get("delta_chars"), } for part in item.get("parts") or [] if isinstance(part, dict) ], } def terms_delta(before: list[Any], after: list[Any]) -> dict[str, list[Any]]: before_set = {str(value) for value in before} after_set = {str(value) for value in after} return { "added": [value for value in after if str(value) not in before_set], "removed": [value for value in before if str(value) not in after_set], } def parts_delta(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> dict[str, Any]: before_by_name = {str(part.get("file_name")): part for part in before if part.get("file_name")} after_by_name = {str(part.get("file_name")): part for part in after if part.get("file_name")} before_names = set(before_by_name) after_names = set(after_by_name) changed = [] for name in sorted(before_names & after_names): before_part = before_by_name[name] after_part = after_by_name[name] if stable_fingerprint(before_part) != stable_fingerprint(after_part): changed.append({ "file_name": name, "before": before_part, "after": after_part, }) return { "added": [after_by_name[name] for name in sorted(after_names - before_names)], "removed": [before_by_name[name] for name in sorted(before_names - after_names)], "changed": changed, } def object_delta(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]: before_cmp = comparable_object(before) after_cmp = comparable_object(after) return { "full_name": after.get("full_name") or before.get("full_name"), "before_fingerprint": stable_fingerprint(before_cmp), "after_fingerprint": stable_fingerprint(after_cmp), "before": before_cmp, "after": after_cmp, "term_delta": { "added_terms": terms_delta(before_cmp.get("added_terms") or [], after_cmp.get("added_terms") or []), "removed_terms": terms_delta(before_cmp.get("removed_terms") or [], after_cmp.get("removed_terms") or []), }, "part_delta": parts_delta(before_cmp.get("parts") or [], after_cmp.get("parts") or []), } def compact_object(item: dict[str, Any]) -> dict[str, Any]: comparable = comparable_object(item) comparable["fingerprint"] = stable_fingerprint(comparable) return comparable def compare_reports(before_path: Path, after_path: Path) -> dict[str, Any]: before_path = before_path.resolve() after_path = after_path.resolve() before = load_json(before_path) after = load_json(after_path) before_objects = object_map(before) after_objects = object_map(after) before_names = set(before_objects) after_names = set(after_objects) changed = [] unchanged = [] for name in sorted(before_names & after_names): before_fp = stable_fingerprint(comparable_object(before_objects[name])) after_fp = stable_fingerprint(comparable_object(after_objects[name])) if before_fp == after_fp: unchanged.append(name) else: changed.append(object_delta(before_objects[name], after_objects[name])) before_system = system_map(before) after_system = system_map(after) system_added = sorted(set(after_system) - set(before_system)) system_removed = sorted(set(before_system) - set(after_system)) system_changed = [ key for key in sorted(set(before_system) & set(after_system)) if stable_fingerprint(before_system[key]) != stable_fingerprint(after_system[key]) ] return { "schema": "onec_saved_state_object_report_delta.v1", "before_report": str(before_path), "after_report": str(after_path), "database": after.get("database") or before.get("database"), "objects": { "added": [compact_object(after_objects[name]) for name in sorted(after_names - before_names)], "removed": [compact_object(before_objects[name]) for name in sorted(before_names - after_names)], "changed": changed, "unchanged": unchanged, }, "system_changes": { "added": [after_system[key] for key in system_added], "removed": [before_system[key] for key in system_removed], "changed": [{"key": key, "before": before_system[key], "after": after_system[key]} for key in system_changed], }, "counts": { "objects_added": len(after_names - before_names), "objects_removed": len(before_names - after_names), "objects_changed": len(changed), "objects_unchanged": len(unchanged), "system_added": len(system_added), "system_removed": len(system_removed), "system_changed": len(system_changed), }, "safety": { "read_only": True, "sql_write_performed": False, "public_terms_are_1c_objects": True, }, } def render_markdown(delta: dict[str, Any]) -> str: module_path = REPO_ROOT / "scripts" / "render_1c_saved_state_object_report_delta_markdown.py" spec = importlib.util.spec_from_file_location("saved_state_delta_markdown", module_path) if spec is None or spec.loader is None: raise RuntimeError(f"Cannot load Markdown renderer: {module_path}") module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module.render(delta) def check_delta(delta_path: Path, check_output: Path) -> None: module_path = REPO_ROOT / "scripts" / "check_1c_saved_state_object_report_delta.py" spec = importlib.util.spec_from_file_location("saved_state_delta_check", module_path) if spec is None or spec.loader is None: raise RuntimeError(f"Cannot load delta checker: {module_path}") module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) result = module.check_delta(delta_path) module.write_json(check_output, result) if not result.get("passed"): raise RuntimeError(f"Saved-state delta check failed: {check_output}") def main() -> int: parser = argparse.ArgumentParser(description="Compare two 1C saved-state object reports.") parser.add_argument("--before", type=Path, required=True) parser.add_argument("--after", type=Path, required=True) parser.add_argument("--output", type=Path) parser.add_argument("--markdown-output", type=Path) parser.add_argument("--skip-markdown", action="store_true") parser.add_argument("--check-output", type=Path) parser.add_argument("--skip-check", action="store_true") args = parser.parse_args() result = compare_reports(args.before, args.after) markdown_output = args.markdown_output if markdown_output is None and args.output and not args.skip_markdown: markdown_output = args.output.with_suffix(".md") if markdown_output and not args.skip_markdown: markdown_output = markdown_output.resolve() result["markdown"] = str(markdown_output) check_output = args.check_output if check_output is None and args.output and not args.skip_check: check_output = args.output.with_name(f"{args.output.stem}-check.json") if check_output and not args.skip_check: check_output = check_output.resolve() result["check"] = str(check_output) if args.output: write_json(args.output, result) if markdown_output and not args.skip_markdown: markdown_output.parent.mkdir(parents=True, exist_ok=True) markdown_output.write_text(render_markdown(result), encoding="utf-8") if args.output: write_json(args.output, result) if check_output and not args.skip_check: if not args.output: raise SystemExit("Use --output when delta check output is enabled.") check_delta(args.output, check_output) print(json.dumps({"output": str(args.output) if args.output else None, "schema": result["schema"], "counts": result["counts"]}, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())