#!/usr/bin/env python3 """Create a safe local patch workspace from a passed 1C change proposal.""" from __future__ import annotations import argparse import hashlib import json import re import shutil from datetime import datetime, timezone from pathlib import Path from typing import Any from check_1c_change_proposal_safety import check, load_json def slugify(value: str) -> str: translit = { "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "h", "ц": "c", "ч": "ch", "ш": "sh", "щ": "sch", "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya", } chars = [] for char in value.casefold(): chars.append(translit.get(char, char)) slug = re.sub(r"[^a-z0-9]+", "-", "".join(chars)).strip("-") return slug[:80] or "onec-task" 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 unique_targets(proposal: dict[str, Any]) -> list[dict[str, Any]]: targets = [] seen = set() for proposal_item in proposal.get("proposals") or []: for target in ((proposal_item.get("target_policy") or {}).get("write_candidates") or []): path = str(target.get("path") or target.get("module_path") or "") if not path or path in seen: continue seen.add(path) targets.append(target) return targets def common_extension_root(paths: list[Path], preferred_extension: str | None) -> Path | None: if not preferred_extension: return None for path in paths: parts = list(path.parts) lowered = [part.casefold() for part in parts] if preferred_extension.casefold() not in lowered: continue index = lowered.index(preferred_extension.casefold()) return Path(*parts[: index + 1]) return None def relative_target_path(path: Path, root: Path | None) -> Path: if root: try: return path.relative_to(root) except ValueError: pass return Path(path.name) 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 copy_targets(targets: list[dict[str, Any]], workspace: Path, root: Path | None) -> list[dict[str, Any]]: records = [] for target in targets: src = Path(str(target.get("path") or target.get("module_path"))) rel = relative_target_path(src, root) original_dst = workspace / "original" / rel working_dst = workspace / "working" / rel original_dst.parent.mkdir(parents=True, exist_ok=True) working_dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, original_dst) shutil.copy2(src, working_dst) records.append( { "source_path": str(src), "relative_path": str(rel).replace("\\", "/"), "original_path": str(original_dst), "working_path": str(working_dst), "kind": target.get("kind"), "name": target.get("name"), "origin": target.get("origin"), "sha256": sha256_file(src), "size": src.stat().st_size, } ) return records def render_readme(proposal: dict[str, Any], safety: dict[str, Any], manifest: dict[str, Any]) -> str: task = ((proposal.get("task") or {}).get("text") or "").strip() lines = [ "# 1C Patch Workspace", "", f"Task: {task}", f"Safety passed: `{safety.get('passed')}`", "", "## Rules", "", "- Edit files only under `working/`.", "- Keep `original/` unchanged; it is used for diff generation.", "- Do not write SQL, Config, ConfigSave, ConfigCAS, or production Designer state.", "- Validate extension packaging/loading in a disposable base before any production action.", "", "## Files", "", ] for item in manifest.get("files") or []: lines.append(f"- `{item.get('relative_path')}` ({item.get('kind')}, {item.get('origin')})") lines.extend( [ "", "## Next Commands", "", "Check workspace integrity:", "", "```powershell", "python scripts/check_1c_patch_workspace_integrity.py --workspace --output ", "```", "", "Check source freshness before packaging/apply:", "", "```powershell", "python scripts/check_1c_patch_source_freshness.py --workspace --output ", "```", "", "Validate BSL/Form.xml semantics:", "", "```powershell", "python scripts/validate_1c_patch_workspace_semantics.py --workspace --output ", "```", "", "Append, replace, or upsert one BSL routine under `working/`:", "", "```powershell", "python scripts/edit_1c_bsl_routine.py --workspace --relative-path --operation upsert --routine-text-b64 --output ", "```", "", "Append, replace, or upsert one Form.xml command under `working/`:", "", "```powershell", "python scripts/edit_1c_form_command.py --workspace --relative-path --operation upsert --name --title --action --output ", "```", "", "Append, replace, or upsert one visible Form.xml button under `working/`:", "", "```powershell", "python scripts/edit_1c_form_button.py --workspace --relative-path --operation upsert --parent-name --name --title --command-name --output ", "```", "", "Preferred atomic workflow for adding a visible form button:", "", "```powershell", "python scripts/add_1c_form_button_workflow.py --workspace --form-relative-path --bsl-relative-path --operation upsert --routine-text-b64 --command-name --command-title --command-action --button-parent-name --button-name --button-title --output ", "```", "", "Generate diff after editing:", "", "```powershell", "python scripts/diff_1c_patch_workspace.py --workspace --output ", "```", "", "Create review bundle after preflight status is `ready_for_review`:", "", "```powershell", "python scripts/create_1c_patch_bundle.py --workspace --slug --output ", "```", "", "Validate a created review bundle:", "", "```powershell", "python scripts/check_1c_patch_bundle.py --bundle-dir --output ", "```", "", "Create disposable extension XML staging copy from a valid bundle:", "", "```powershell", "python scripts/create_1c_extension_staging_from_bundle.py --bundle-dir --slug --output ", "```", "", "Validate a disposable extension XML staging copy:", "", "```powershell", "python scripts/check_1c_extension_staging.py --staging-dir --output ", "```", "", "Validate runner config for disposable 1C validation:", "", "```powershell", "python scripts/check_1c_extension_runner_config.py --config --output ", "```", "", "Create a disposable-base validation plan for staging:", "", "```powershell", "python scripts/create_1c_extension_validation_plan.py --staging-dir --runner-config --output --markdown-output ", "```", "", "Create pending manual evidence templates from validation plan:", "", "```powershell", "python scripts/create_1c_extension_validation_evidence.py --plan --output ", "```", "", "Check filled validation evidence:", "", "```powershell", "python scripts/check_1c_extension_validation_evidence.py --plan --output ", "```", "", "Aggregate final validation gates for human review:", "", "```powershell", "python scripts/check_1c_extension_validation_release.py --plan --output ", "```", "", "Render final validation report for human review:", "", "```powershell", "python scripts/render_1c_extension_validation_release_markdown.py --release-check --output ", "```", "", ] ) return "\n".join(lines) def create_workspace(proposal_path: Path, output_root: Path, slug: str | None, force: bool) -> dict[str, Any]: proposal = load_json(proposal_path) safety = check(proposal) if not safety.get("passed"): raise SystemExit("Proposal safety check failed; workspace not created.") task_text = ((proposal.get("task") or {}).get("text") or "").strip() workspace_slug = slug or slugify(task_text) workspace = output_root / workspace_slug if workspace.exists() and not force: raise SystemExit(f"Workspace already exists: {workspace}. Use --force to replace.") if workspace.exists() and force: shutil.rmtree(workspace) workspace.mkdir(parents=True, exist_ok=True) preferred_extension = None for proposal_item in proposal.get("proposals") or []: preferred_extension = (proposal_item.get("write_strategy") or {}).get("preferred_extension") if preferred_extension: break targets = unique_targets(proposal) source_paths = [Path(str(target.get("path") or target.get("module_path"))) for target in targets] extension_root = common_extension_root(source_paths, preferred_extension) files = copy_targets(targets, workspace, extension_root) manifest = { "schema": "onec_patch_workspace_manifest.v1", "created_at_utc": datetime.now(timezone.utc).isoformat(), "task": proposal.get("task"), "source_proposal": str(proposal_path), "preferred_extension": preferred_extension, "extension_root": str(extension_root) if extension_root else None, "workspace": str(workspace), "files": files, "safety": { "schema": safety.get("schema"), "passed": safety.get("passed"), "counts": safety.get("counts"), }, } write_json(workspace / "proposal.json", proposal) write_json(workspace / "safety.json", safety) write_json(workspace / "manifest.json", manifest) (workspace / "README.md").write_text(render_readme(proposal, safety, manifest), encoding="utf-8") return { "schema": "onec_patch_workspace_creation.v1", "workspace": str(workspace), "manifest": str(workspace / "manifest.json"), "files": len(files), "safety_passed": safety.get("passed"), } def main() -> int: parser = argparse.ArgumentParser(description="Create safe 1C patch workspace.") parser.add_argument("--proposal", type=Path, required=True) parser.add_argument("--output-root", type=Path, default=Path("reports/1c-sql/upo/patch-workspaces")) parser.add_argument("--slug") parser.add_argument("--force", action="store_true") parser.add_argument("--output", type=Path) args = parser.parse_args() result = create_workspace(args.proposal, args.output_root, args.slug, args.force) 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(result, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())