Spaces:
Sleeping
Sleeping
File size: 6,828 Bytes
1a172d1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | """
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
}
|