fix(file-tools): handle UTF-8 BOM in read_file / write_file / patch (#35278)
Some Windows editors prepend an invisible UTF-8 BOM (U+FEFF) to text files. We had no awareness of it, so: read_file surfaced a phantom U+FEFF as the first character; patch matches against the true first line could miss; and a write/patch round-trip silently stripped the marker, changing the file's byte signature. Now: - read_file / read_file_raw strip a single leading BOM so the model never sees it (only on the first chunk — the marker lives at byte 0). - patch_replace strips the BOM before fuzzy-matching (so an exact first-line match works) and its post-write verification compares BOM-stripped content. - write_file restores the BOM when the original file had one and the new content doesn't, mirroring the existing line-ending preservation (detect on disk via a cheap `head -c 3` probe or reuse pre_content, re-prepend across the edit). Guards against double-BOM. Mid-content U+FEFF is left alone (it's data there, not a file marker). Tests: TestBomHandling (real LocalEnvironment) — read-strips, raw-read strips, write preserves, no-BOM-when-original-had-none, no-double-BOM, patch round-trip preserves, patch matches first line through a BOM, plus helper unit tests. 208 file-tool tests green.
This commit is contained in:
@@ -183,5 +183,101 @@ class TestAtomicWrite:
|
||||
assert (os.stat(target).st_mode & 0o777) == 0o600
|
||||
|
||||
|
||||
class TestBomHandling:
|
||||
"""UTF-8 BOM is stripped on read and preserved across write/patch.
|
||||
|
||||
A BOM (U+FEFF, bytes EF BB BF) is an invisible leading marker some
|
||||
Windows editors prepend. The agent should never see it in read output,
|
||||
but a file that had one on disk must keep it after an edit so the byte
|
||||
signature is preserved.
|
||||
"""
|
||||
|
||||
BOM = "\ufeff"
|
||||
|
||||
@pytest.fixture
|
||||
def ops(self, tmp_path: Path):
|
||||
from tools.environments.local import LocalEnvironment
|
||||
from tools.file_operations import ShellFileOperations
|
||||
env = LocalEnvironment(cwd=str(tmp_path))
|
||||
return ShellFileOperations(env, cwd=str(tmp_path))
|
||||
|
||||
def test_helpers(self):
|
||||
from tools.file_operations import _strip_bom, _has_bom
|
||||
assert _strip_bom("\ufeffhello") == ("hello", True)
|
||||
assert _strip_bom("hello") == ("hello", False)
|
||||
assert _strip_bom("") == ("", False)
|
||||
# mid-string BOM is data, not a marker — left alone
|
||||
assert _strip_bom("a\ufeffb") == ("a\ufeffb", False)
|
||||
assert _has_bom("\ufeffx") is True
|
||||
assert _has_bom("x") is False
|
||||
assert _has_bom(None) is False
|
||||
|
||||
def test_read_strips_bom(self, ops, tmp_path: Path):
|
||||
target = tmp_path / "bom.py"
|
||||
# Write raw bytes with a real UTF-8 BOM prefix.
|
||||
target.write_bytes(self.BOM.encode("utf-8") + b"import os\nx = 1\n")
|
||||
res = ops.read_file(str(target))
|
||||
assert res.error is None, res.error
|
||||
# Line 1 content must NOT carry the phantom U+FEFF.
|
||||
first_line = res.content.split("\n", 1)[0]
|
||||
assert self.BOM not in first_line
|
||||
assert first_line.endswith("import os")
|
||||
|
||||
def test_read_raw_strips_bom(self, ops, tmp_path: Path):
|
||||
target = tmp_path / "bom.txt"
|
||||
target.write_bytes(self.BOM.encode("utf-8") + b"hello\nworld\n")
|
||||
res = ops.read_file_raw(str(target))
|
||||
assert res.error is None, res.error
|
||||
assert not res.content.startswith(self.BOM)
|
||||
assert res.content == "hello\nworld\n"
|
||||
|
||||
def test_write_preserves_bom(self, ops, tmp_path: Path):
|
||||
# Existing file has a BOM; agent rewrites with BOM-less content.
|
||||
target = tmp_path / "config.txt"
|
||||
target.write_bytes(self.BOM.encode("utf-8") + b"old\n")
|
||||
res = ops.write_file(str(target), "new content\n")
|
||||
assert res.error is None, res.error
|
||||
raw = target.read_bytes()
|
||||
assert raw.startswith(self.BOM.encode("utf-8")) # BOM restored
|
||||
assert raw == self.BOM.encode("utf-8") + b"new content\n"
|
||||
|
||||
def test_write_no_bom_when_original_had_none(self, ops, tmp_path: Path):
|
||||
target = tmp_path / "plain.txt"
|
||||
target.write_text("old\n")
|
||||
res = ops.write_file(str(target), "new\n")
|
||||
assert res.error is None, res.error
|
||||
assert not target.read_bytes().startswith(self.BOM.encode("utf-8"))
|
||||
|
||||
def test_write_does_not_double_bom(self, ops, tmp_path: Path):
|
||||
# If content already carries a BOM and the file had one, don't add a
|
||||
# second.
|
||||
target = tmp_path / "config.txt"
|
||||
target.write_bytes(self.BOM.encode("utf-8") + b"old\n")
|
||||
res = ops.write_file(str(target), self.BOM + "new\n")
|
||||
assert res.error is None, res.error
|
||||
raw = target.read_bytes()
|
||||
# exactly one BOM
|
||||
assert raw == self.BOM.encode("utf-8") + b"new\n"
|
||||
|
||||
def test_patch_roundtrip_preserves_bom(self, ops, tmp_path: Path):
|
||||
target = tmp_path / "edit.py"
|
||||
target.write_bytes(self.BOM.encode("utf-8") + b"a = 1\nb = 2\nc = 3\n")
|
||||
res = ops.patch_replace(str(target), "b = 2", "b = 22")
|
||||
assert res.success, res.error
|
||||
raw = target.read_bytes()
|
||||
assert raw.startswith(self.BOM.encode("utf-8")) # marker survived
|
||||
assert raw == self.BOM.encode("utf-8") + b"a = 1\nb = 22\nc = 3\n"
|
||||
|
||||
def test_patch_matches_first_line_through_bom(self, ops, tmp_path: Path):
|
||||
# The whole point: an edit targeting the BOM-prefixed first line
|
||||
# must match cleanly (the matcher sees BOM-stripped content).
|
||||
target = tmp_path / "mod.py"
|
||||
target.write_bytes(self.BOM.encode("utf-8") + b"import os\nimport sys\n")
|
||||
res = ops.patch_replace(str(target), "import os", "import os, json")
|
||||
assert res.success, res.error
|
||||
raw = target.read_bytes()
|
||||
assert raw == self.BOM.encode("utf-8") + b"import os, json\nimport sys\n"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
Reference in New Issue
Block a user