#!/usr/bin/env python3 """PA Batch Editor — Surgical multi-file editing from a single JSON manifest. Usage: python tools/migration/pa_editor.py [options] Companion to pa_batch.py (which creates files). This tool EDITS existing files with atomic transaction semantics: all edits succeed or ALL rollback. Operations: insert (before/after line), delete (N lines), replace (pattern→content). Targeting: line numbers OR regex patterns (agent chooses per-edit). See AGENTS.md in tools/migration/ for full manifest format and usage patterns. """ import argparse import copy import difflib import json import os import re import shutil import sys from typing import Any def detect_project_root(start: str) -> str: """Walk up from *start* looking for settings.gradle.""" cur = os.path.abspath(start) for _ in range(20): if os.path.isfile(os.path.join(cur, "settings.gradle")): return cur parent = os.path.dirname(cur) if parent == cur: break cur = parent return os.path.abspath(start) # --------------------------------------------------------------------------- # Edit validation # --------------------------------------------------------------------------- def _resolve_line( lines: list[str], target: dict[str, Any], filepath: str ) -> tuple[int | None, str | None]: """Resolve a target spec to a 0-based line index. Target formats: {"line": 10} → line 10 (1-based → index 9) {"pattern": "import.*"} → first line matching regex {"pattern": "import.*", "occurrence": 2} → 2nd match Returns (index, error_msg). On success error_msg is None. """ if "line" in target: ln = target["line"] if not isinstance(ln, int) or ln < 1: return None, f"invalid line number: {ln}" if ln > len(lines): return None, f"line {ln} out of bounds (file has {len(lines)} lines)" return ln - 1, None if "pattern" in target: pat = target["pattern"] try: regex = re.compile(pat) except re.error as exc: return None, f"invalid regex '{pat}': {exc}" occurrence = target.get("occurrence", 1) if not isinstance(occurrence, int) or occurrence < 1: return None, f"invalid occurrence: {occurrence}" count = 0 for i, line in enumerate(lines): if regex.search(line): count += 1 if count == occurrence: return i, None return None, f"pattern '{pat}' not found in {filepath}" + ( f" (occurrence {occurrence})" if occurrence > 1 else "" ) return None, "target must specify 'line' or 'pattern'" def _validate_edit(edit: dict[str, Any], idx: int) -> str | None: """Validate a single edit spec. Returns error message or None.""" op = edit.get("op") if op not in ("insert", "delete", "replace"): return f"edit {idx}: invalid op '{op}' (must be insert/delete/replace)" if op == "insert": if "content" not in edit: return f"edit {idx}: insert requires 'content'" if "target" not in edit: return f"edit {idx}: insert requires 'target'" pos = edit.get("position", "after") if pos not in ("before", "after"): return f"edit {idx}: position must be 'before' or 'after', got '{pos}'" elif op == "delete": if "target" not in edit: return f"edit {idx}: delete requires 'target'" count = edit.get("count", 1) if not isinstance(count, int) or count < 1: return f"edit {idx}: delete count must be a positive integer" elif op == "replace": if "content" not in edit: return f"edit {idx}: replace requires 'content'" if "target" not in edit: return f"edit {idx}: replace requires 'target'" count = edit.get("count", 1) if not isinstance(count, int) or count < 1: return f"edit {idx}: replace count must be a positive integer" return None # --------------------------------------------------------------------------- # Edit application (on in-memory line list) # --------------------------------------------------------------------------- def _apply_edit( lines: list[str], edit: dict[str, Any], idx: int, filepath: str ) -> tuple[list[str], str | None]: """Apply a single edit to *lines* (in-memory). Returns (new_lines, error_msg). Edits are applied to a copy. On error, returns (original_lines, error_msg). """ op = edit["op"] target = edit["target"] line_idx, err = _resolve_line(lines, target, filepath) if err or line_idx is None: return lines, f"edit {idx}: {err}" result = list(lines) if op == "insert": content = edit["content"] new_lines = _content_to_lines(content) pos = edit.get("position", "after") insert_at = line_idx if pos == "before" else line_idx + 1 for i, nl in enumerate(new_lines): result.insert(insert_at + i, nl) elif op == "delete": count = edit.get("count", 1) end = line_idx + count if end > len(result): return ( lines, f"edit {idx}: delete would go past end of file (line {line_idx + 1} + {count} > {len(result)})", ) del result[line_idx:end] elif op == "replace": count = edit.get("count", 1) end = line_idx + count if end > len(result): return ( lines, f"edit {idx}: replace range past end of file (line {line_idx + 1} + {count} > {len(result)})", ) new_lines = _content_to_lines(edit["content"]) result[line_idx:end] = new_lines return result, None def _content_to_lines(content: str | list[str]) -> list[str]: """Normalize content (string or list of strings) to list of lines with newlines.""" if isinstance(content, list): return [ln if ln.endswith("\n") else ln + "\n" for ln in content] # String: split into lines, preserve trailing newline raw_lines = content.split("\n") # If content ends with \n, split produces an empty trailing element — drop it if raw_lines and raw_lines[-1] == "": raw_lines = raw_lines[:-1] return [ln + "\n" for ln in raw_lines] # --------------------------------------------------------------------------- # Diff generation # --------------------------------------------------------------------------- def _generate_diff( old_lines: list[str], new_lines: list[str], filepath: str ) -> list[str]: """Generate unified diff between old and new content.""" return list( difflib.unified_diff( old_lines, new_lines, fromfile=f"a/{filepath}", tofile=f"b/{filepath}", lineterm="\n", ) ) # --------------------------------------------------------------------------- # Core processing # --------------------------------------------------------------------------- def process_manifest( manifest_path: str, project_root: str, force: bool, dry_run: bool, backup: bool, json_output: bool, ) -> bool: """Read *manifest_path*, validate ALL edits, then apply atomically (or preview).""" with open(manifest_path, "r", encoding="utf-8") as fh: manifest = json.load(fh) file_edits: list[dict[str, Any]] = manifest.get("edits", []) if not file_edits: _output(json_output, dry_run, [], [], [], []) return True # ====================================================================== # PHASE 1: Structural validation (no file I/O) # ====================================================================== errors: list[dict[str, Any]] = [] for fi, fe in enumerate(file_edits): path = fe.get("path") if not path: errors.append({"file_index": fi, "error": "missing 'path'"}) continue ops = fe.get("ops", []) if not ops: errors.append({"file_index": fi, "path": path, "error": "no ops specified"}) continue for oi, op_spec in enumerate(ops): err = _validate_edit(op_spec, oi) if err: errors.append({"file_index": fi, "path": path, "error": err}) if errors: _output(json_output, dry_run, [], [], errors, []) return False # ====================================================================== # PHASE 2: Read all files, resolve all targets, compute results # ====================================================================== file_states: list[ dict[str, Any] ] = [] # {path, abs_path, original, modified, diffs, edit_count} warnings: list[str] = [] for fi, fe in enumerate(file_edits): rel_path = fe["path"] abs_path = os.path.join(project_root, rel_path) if not os.path.isfile(abs_path): errors.append( {"file_index": fi, "path": rel_path, "error": "file not found"} ) continue try: with open(abs_path, "r", encoding="utf-8") as f: original = f.readlines() except Exception as exc: errors.append( {"file_index": fi, "path": rel_path, "error": f"read error: {exc}"} ) continue # Apply edits sequentially (order matters — line numbers shift) current = list(original) ops = fe["ops"] edit_count = 0 file_ok = True for oi, op_spec in enumerate(ops): current, err = _apply_edit(current, op_spec, oi, rel_path) if err: errors.append({"file_index": fi, "path": rel_path, "error": err}) file_ok = False break edit_count += 1 if not file_ok: continue if current == original: warnings.append(f"{rel_path}: edits produced no changes") diffs = _generate_diff(original, current, rel_path) file_states.append( { "path": rel_path, "abs_path": abs_path, "original": original, "modified": current, "diffs": diffs, "edit_count": edit_count, } ) # If ANY file had errors, abort ALL if errors: _output(json_output, dry_run, [], [], errors, warnings) return False # ====================================================================== # PHASE 3: Apply (or dry-run) # ====================================================================== modified_paths: list[str] = [] backup_paths: list[str] = [] if not dry_run: # Create backups first (if requested), so rollback is possible if backup: for fs in file_states: bak_path = fs["abs_path"] + ".bak" try: shutil.copy2(fs["abs_path"], bak_path) backup_paths.append(bak_path) except Exception as exc: errors.append( {"path": fs["path"], "error": f"backup failed: {exc}"} ) if errors: # Clean up any backups we already made for bp in backup_paths: try: os.remove(bp) except OSError: pass _output(json_output, dry_run, [], [], errors, warnings) return False written: list[str] = [] write_error = False for fs in file_states: try: with open(fs["abs_path"], "w", encoding="utf-8", newline="") as out: out.writelines(fs["modified"]) written.append(fs["abs_path"]) modified_paths.append(fs["path"]) except Exception as exc: errors.append({"path": fs["path"], "error": f"write failed: {exc}"}) write_error = True break # ROLLBACK on write failure if write_error: for abs_p in written: # Find the original content for this path for fs in file_states: if fs["abs_path"] == abs_p: try: with open(abs_p, "w", encoding="utf-8", newline="") as out: out.writelines(fs["original"]) except Exception: pass # best-effort rollback break # Also remove backups since we rolled back for bp in backup_paths: try: os.remove(bp) except OSError: pass _output(json_output, dry_run, [], [], errors, warnings) return False else: modified_paths = [fs["path"] for fs in file_states] _output(json_output, dry_run, file_states, modified_paths, errors, warnings) return True # --------------------------------------------------------------------------- # Output formatting # --------------------------------------------------------------------------- def _output( json_mode: bool, dry_run: bool, file_states: list[dict[str, Any]], modified: list[str], errors: list[dict[str, Any]], warnings: list[str], ): """Print results in human-readable or JSON format.""" total_edits = sum(fs.get("edit_count", 0) for fs in file_states) if json_mode: result: dict[str, Any] = { "dry_run": dry_run, "files_modified": len(modified), "total_edits": total_edits, "modified": modified, "errors": errors, "warnings": warnings, } if dry_run and file_states: result["diffs"] = {fs["path"]: "".join(fs["diffs"]) for fs in file_states} print(json.dumps(result, indent=2)) return mode = " (DRY RUN)" if dry_run else "" print(f"\nPA Batch Editor — {len(modified)} files, {total_edits} edits{mode}") print(f" MODIFIED: {len(modified)} files") print(f" ERRORS: {len(errors)}") if warnings: print(f" WARNINGS: {len(warnings)}") if modified and not dry_run: print("\nModified:") for p in modified: edit_count = 0 for fs in file_states: if fs["path"] == p: edit_count = fs["edit_count"] break print(f" {p} ({edit_count} edits)") if dry_run and file_states: print("\nDiffs:") for fs in file_states: if fs["diffs"]: print(f"\n--- {fs['path']} ({fs['edit_count']} edits) ---") for d in fs["diffs"]: line = d.rstrip("\n") print(line) else: print(f"\n--- {fs['path']} (no changes) ---") if errors: print("\nErrors:") for e in errors: path_info = e.get("path", f"index {e.get('file_index', '?')}") print(f" {path_info}: {e['error']}") if warnings: print("\nWarnings:") for w in warnings: print(f" {w}") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser( description="PA Batch Editor — surgical multi-file editing from a JSON manifest.", ) parser.add_argument("manifest", help="Path to JSON edit manifest file") parser.add_argument( "--force", action="store_true", help="Apply edits (required to write)" ) parser.add_argument( "--dry-run", action="store_true", help="Show diffs without writing (default if --force not given)", ) parser.add_argument( "--backup", action="store_true", help="Save .bak copies of original files before editing", ) parser.add_argument( "--json", action="store_true", help="Machine-readable JSON output" ) parser.add_argument( "--project-root", default=None, help="Project root override (auto-detected by default)", ) args = parser.parse_args() # Default to dry-run if --force not specified if not args.force and not args.dry_run: args.dry_run = True if not os.path.isfile(args.manifest): print(f"ERROR: manifest not found: {args.manifest}", file=sys.stderr) sys.exit(1) if args.project_root: project_root = os.path.abspath(args.project_root) else: project_root = detect_project_root(os.path.dirname(os.path.abspath(__file__))) try: ok = process_manifest( args.manifest, project_root, args.force, args.dry_run, args.backup, args.json, ) except json.JSONDecodeError as exc: print(f"ERROR: invalid JSON in manifest — {exc}", file=sys.stderr) sys.exit(1) except Exception as exc: print(f"ERROR: {exc}", file=sys.stderr) sys.exit(1) sys.exit(0 if ok else 1) if __name__ == "__main__": main()