Terminal / api /patch_tool.py
Pulka
deploy: backend build-1781037398298 — 2026-06-09 20:36 UTC
7cc12d8 verified
Raw
History Blame Contribute Delete
6.83 kB
"""
patch_tool.py — Atomic file patching via unified diff (S366)
Pipeline: read → apply_patch → syntax_check → write_back (VFS)
Each stage is pure and independently testable. Write-back only happens
when patch applied cleanly AND syntax validation passes.
Design invariants:
- apply_unified_diff() is pure (no I/O, no side effects)
- check_syntax() uses stdlib only (ast, json) — zero extra deps
- patch_file_in_vfs() is atomic: partial failures leave VFS unchanged
- Never raises exceptions — returns error dict on all failure paths
- Compatible with both dict-of-str VFS and dict-of-dict VFS layouts
"""
from __future__ import annotations
import ast
import json
import re
from typing import Any
# ── Unified diff parser ───────────────────────────────────────────────────────
_HUNK_HEADER_RE = re.compile(
r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@'
)
def apply_unified_diff(original: str, patch: str) -> tuple[str, list[str]]:
"""
Apply a unified diff string to original text.
Returns (patched_text, errors).
errors is an empty list on clean apply.
"""
if not patch.strip():
return original, ["empty patch"]
patch_lines = patch.splitlines()
result: list[str] = original.splitlines(keepends=True)
errors: list[str] = []
# Collect hunk descriptors
hunks: list[tuple[int, int, list[str]]] = []
i = 0
while i < len(patch_lines):
m = _HUNK_HEADER_RE.match(patch_lines[i])
if m:
orig_start = int(m.group(1)) - 1 # convert to 0-indexed
orig_count = int(m.group(2) or 1)
body: list[str] = []
i += 1
while i < len(patch_lines) and not _HUNK_HEADER_RE.match(patch_lines[i]) \
and not (patch_lines[i].startswith("---") and i + 1 < len(patch_lines)
and patch_lines[i + 1].startswith("+++")):
body.append(patch_lines[i])
i += 1
hunks.append((orig_start, orig_count, body))
else:
i += 1
if not hunks:
# No hunk headers: treat patch as full-file replacement
return patch, []
# Apply hunks from bottom to top (preserves line offsets for earlier hunks)
offset = 0
for (orig_start, orig_count, body) in hunks:
pos = orig_start + offset
removes: list[str] = []
adds: list[str] = []
for line in body:
if line.startswith("-"):
removes.append(line[1:])
elif line.startswith("+"):
adds.append(line[1:])
# context lines ignored during apply
# Soft context validation (log mismatch but apply anyway)
for idx, (exp, got) in enumerate(zip(removes, result[pos:pos + len(removes)])):
if exp.rstrip("\n") != got.rstrip("\n"):
errors.append(
f"line {pos + idx + 1}: expected {exp.rstrip()!r:.40s}, "
f"got {got.rstrip()!r:.40s}"
)
# Apply: replace remove-lines with add-lines
add_lines = [a if a.endswith("\n") else a + "\n" for a in adds]
result[pos: pos + len(removes)] = add_lines
offset += len(adds) - len(removes)
return "".join(result), errors
# ── Syntax validation ─────────────────────────────────────────────────────────
def check_syntax(code: str, language: str) -> tuple[bool, str]:
"""
Returns (is_ok, error_message).
Python → ast.parse, JSON → json.loads, other languages → pass-through.
"""
lang = language.lower().strip()
if lang in ("python", "py"):
try:
ast.parse(code, mode="exec")
return True, ""
except SyntaxError as e:
return False, f"SyntaxError line {e.lineno}: {e.msg}"
elif lang == "json":
try:
json.loads(code)
return True, ""
except json.JSONDecodeError as e:
return False, f"JSONDecodeError: {e}"
# TypeScript / JavaScript / other: no static check available here
return True, ""
# ── VFS-aware atomic patch ────────────────────────────────────────────────────
async def patch_file_in_vfs(
path: str,
patch_text: str,
vfs: dict[str, Any],
language: str = "python",
) -> dict:
"""
Atomic read → apply_unified_diff → check_syntax → write_back.
VFS layout support:
vfs[path] = "content string" (simple)
vfs[path] = {"content": "...", ...} (metadata dict)
Returns:
{success: bool, path: str, lines_changed: int, errors: list, error: str|None}
Never raises.
"""
try:
# Read current content from VFS
entry = vfs.get(path)
if isinstance(entry, dict):
original = entry.get("content", "")
elif isinstance(entry, str):
original = entry
else:
original = ""
patched, diff_errors = apply_unified_diff(original, patch_text)
# Hard-fail on context mismatches (data integrity risk)
if diff_errors:
return {
"success": False,
"path": path,
"lines_changed": 0,
"errors": diff_errors[:5],
"error": f"Patch context errors: {'; '.join(diff_errors[:2])}",
}
# Syntax gate: only write back clean code
syntax_ok, syntax_err = check_syntax(patched, language)
if not syntax_ok:
return {
"success": False,
"path": path,
"lines_changed": 0,
"errors": [syntax_err],
"error": syntax_err,
}
# Atomic write-back
lines_changed = abs(len(patched.splitlines()) - len(original.splitlines()))
if isinstance(vfs.get(path), dict):
vfs[path]["content"] = patched
else:
vfs[path] = patched
return {
"success": True,
"path": path,
"lines_changed": lines_changed,
"errors": [],
"error": None,
}
except Exception as e:
return {
"success": False,
"path": path,
"lines_changed": 0,
"errors": [str(e)[:300]], # S588: 200→300
"error": str(e)[:300], # S588: 200→300
}