Three Byte-Level Checks I Run Before an Agent Edits Files That Contain Japanese
When an agent edit swaps a single multi-byte character, git shows it as an ordinary one-line change. Here is how I fold invalid UTF-8, replacement characters, and normalization drift into one pass.
The Antigravity CLI 1.1.21 release notes from August 26 include a fix for edits breaking on files that contain non-ASCII characters.
The fix is welcome. What caught my attention was something else entirely: if the same thing had happened on my machine, would I have noticed?
To find out, I built a stand-in for a resource file listing ukiyo-e artwork names and deliberately removed a single byte from 神奈川沖浪裏. The diff of the broken file looked disappointingly ordinary.
As an indie developer maintaining Japanese resources, I can't treat this as someone else's problem. I hand JSON files full of Japanese artwork titles and headers full of Japanese comments to agents on a regular basis, and I had nothing in place that would have told me when one of them broke.
Git shows a corrupted byte as an ordinary one-line change
I started by reproducing what corruption actually looks like. Take a valid UTF-8 file and remove a single byte from the middle of a three-byte character.
# Drop the third byte of 浪 to produce an invalid UTF-8 sequencepython3 -c "b = bytearray(open('tracked.txt','rb').read())i = b.find('浪'.encode())del b[i+2]open('tracked.txt','wb').write(bytes(b))"git diff --numstatgit diff | head -8
The output:
1 1 tracked.txt
diff --git a/tracked.txt b/tracked.txt
index 4d2ec8b..50590fb 100644
--- a/tracked.txt
+++ b/tracked.txt
@@ -1,2 +1,2 @@
// Holds the artwork title
-const title = "神奈川沖浪裏";
+const title = "神奈川沖��裏";
Git does not switch to binary mode. It emits no warning. This is a perfectly ordinary 1 insertion(+), 1 deletion(-).
That is exactly the problem. When you are reading top to bottom through a diff where an agent touched thirty files, a line where one character inside a Japanese string has turned into a replacement glyph barely registers. The line length and structure are unchanged.
If you run agents unattended, a half-written file left behind by a timeout is actually easier to catch. A truncated file breaks the build. A single swapped character does not.
There are three distinct failure modes, and the usual checks cover them unevenly
I prepared five deliberately broken files and lined up how the common inspection commands respond.
File
State
file verdict
iconv -f UTF-8 -t UTF-8
Python strict decode
ok.txt
Valid UTF-8
UTF-8 text
exit 0
OK
broken.txt
One byte removed
Non-ISO extended-ASCII text
exit 1
FAIL (invalid continuation byte)
sjis.txt
Saved as CP932
OpenPGP Secret Key
exit 1
FAIL (invalid start byte)
bom.txt
UTF-8 with BOM
UTF-8 (with BOM) text
exit 0
OK
nfd.txt
Decomposed dakuten (NFD)
UTF-8 text
exit 0
OK
Three things stand out.
First, file is not dependable here. On my machine it classified a CP932-encoded Japanese text file as an OpenPGP Secret Key. The contents were a single line of Japanese. Encoding detection is heuristic, so misses like this are inevitable. Branching your automation on its verdict is a bad bet.
Second, iconv and Python's strict decode agree. Both are only judging byte-sequence validity, so that is expected. Pick whichever is more convenient.
Third, and most importantly: BOM and NFD pass every one of these checks. They are perfectly valid UTF-8 at the byte level. What is broken is not the bytes but everything downstream of them.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to decide where to place encoding checks so that a corrupted edit never reaches production unnoticed
✦You will be able to recognize the failure mode that a plain UTF-8 validity check silently lets through, and recover the files it already touched
✦You will be able to choose between a pre-commit hook and CI for a whole-repository scan, based on measured runtime rather than guesswork
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Start with the failure mode that is easiest to pin down: files that do not hold up as byte sequences at all.
# Scan the repository in parallel and print only the invalid filesfind content -type f \( -name '*.mdx' -o -name '*.json' -o -name '*.strings' \) -print0 \ | xargs -0 -P4 -n50 sh -c ' for f do iconv -f UTF-8 -t UTF-8 "$f" >/dev/null 2>&1 || echo "INVALID: $f" done' _
Throw away the converted output and read only the exit code. You never wanted the conversion.
Against my own repository (2,064 MDX files) this finished in 667 milliseconds with zero findings. At that scale you can run it on every commit without anyone perceiving a wait.
But this check has one decisive hole.
A file "repaired" by replacement characters passes this check
Agents and tool chains are often written not to crash when they meet a byte they cannot decode. The most common shape is a lenient read that substitutes a replacement character.
healed.txt decode=OK (the check passes)
content : const title = "�奈川沖浪裏";
U+FFFD count: 1
iconv exit=0
iconv returns 0. Python's strict decode succeeds. At the byte level the file is once again perfectly valid UTF-8. The only thing that has not come back is the content.
Line up the bytes and the substitution is obvious:
after corruption: 2074 6974 6c65 203d 2022 e7a5 e5a5 88e5 title = "......
after replacement: 2074 6974 6c65 203d 2022 efbf bde5 a588 title = "......
The remnant e7a5 has been replaced by efbfbd, the three bytes of U+FFFD. There is no way to recover the original character from that.
Learning this changed my mind about one thing specifically: where the check belongs. Put a validity check behind your tool chain and the evidence of corruption can disappear before the check runs. If it has to live behind, it needs a different signal.
Check 2: count the replacement characters
Practically speaking, this is the only way to recover traces of what a lenient read destroyed. Search for the byte sequence EF BF BD.
# Find files where a replacement character has crept into the contentgrep -rlP '\xef\xbf\xbd' content/ 2>/dev/null
One caveat. U+FFFD is a character almost nobody writes deliberately, but it legitimately appears in articles about mojibake itself and in test fixtures used as expected values. I exclude only the directory holding test data.
Keep that exclusion list short. When it starts growing, that is a signal the check itself is aimed at the wrong thing.
Check 3: normalization drift and BOM
The third category covers byte sequences that are entirely valid yet still break how the file is handled.
On macOS, filesystem behavior can leave you with NFD strings where dakuten and handakuten are decomposed. They look identical. The bytes are not.
NFC: e381 b7e3 8289 e381 990a (ぷ as one character)
NFD: e381 b5e3 829a e382 89e3 8199 0a (ふ plus a combining mark)
That difference lands directly on search.
$ grep -c "ぷ" nfc.txt1$ grep -c "ぷ" nfd.txt0
You believe you are searching for the same character and get nothing. Ask an agent to "fix every occurrence of this term" and matches will quietly go missing. The equivalent problem on the filename side is covered in when a Japanese filename becomes a different string on macOS and Linux. This one is about file contents.
BOM only requires looking at the first three bytes. JSON parsers and shebang detection both break on a leading BOM, so it is worth checking whenever you handle resource files.
Folding all three into a single pass
Running three separate commands means reading every file three times. At a few thousand files that difference stops being free. I folded all three judgments into one read.
#!/usr/bin/env python3"""Encoding check for resources containing Japanese, before and after edits.Exit code: 1 if there is a problem, otherwise 0."""import globimport sysimport unicodedataPATTERNS = ["content/**/*.mdx", "resources/**/*.json", "ios/**/*.strings"]SKIP = ("/fixtures/", "/testdata/") # only where mojibake is intentionaldef scan(): invalid, fffd, non_nfc, bom, total = [], [], [], [], 0 for pattern in PATTERNS: for path in glob.glob(pattern, recursive=True): if any(s in path for s in SKIP): continue total += 1 raw = open(path, "rb").read() # Check 3b: BOM (valid bytes, so look before decoding) if raw[:3] == b"\xef\xbb\xbf": bom.append(path) # Check 1: byte-sequence validity try: text = raw.decode("utf-8") except UnicodeDecodeError as e: invalid.append(f"{path} (pos={e.start}, {e.reason})") continue # nothing further is meaningful once decode fails # Check 2: traces left by a lenient read if "�" in text: fffd.append(f"{path} (x{text.count(chr(0xfffd))})") # Check 3a: normalization drift if text != unicodedata.normalize("NFC", text): non_nfc.append(path) return total, invalid, fffd, non_nfc, bomtotal, invalid, fffd, non_nfc, bom = scan()print(f"scanned={total} invalid_utf8={len(invalid)} " f"replacement_char={len(fffd)} non_nfc={len(non_nfc)} bom={len(bom)}")for label, items in (("INVALID UTF-8", invalid), ("U+FFFD", fffd), ("NON-NFC", non_nfc), ("BOM", bom)): for item in items: print(f" [{label}] {item}")# Invalid bytes and U+FFFD stop the build; drift and BOM are warningssys.exit(1 if (invalid or fffd) else 0)
That took 149 milliseconds, faster than the 667 milliseconds of the parallel iconv version. Process startup cost outweighed the cost of one extra read per file.
A note on why only two of the four conditions are fatal. Normalization drift and BOM show up for legitimate reasons, such as third-party data stored verbatim. Failing on those makes the exclusion list grow without end, and an exclusion list that long stops being read by anyone.
Choose the placement by how far you can roll back
There are three plausible places for this, and I have run all of them.
Placement
Delay before you notice
Best suited for
Right before handing files to the agent
None; it stops beforehand
Bulk edits across many files
Pre-commit hook
One commit
Everyday use; 150ms is imperceptible
CI
One push
A last line of defense, but expensive to unwind
I keep the pre-commit hook as my primary gate, and the reason is rollback distance. Before a commit, git checkout -- <file> restores everything. By the time CI catches it, other edits have often landed on top, and peeling off one broken character becomes real work.
Running the same script before a bulk edit is worth the two seconds as well. It settles the question of whether the agent broke the file or the file arrived broken, instantly. Without that distinction you end up with a wide field of suspects, the same way a failed patch application can consume an afternoon.
What to do next
Run one search for U+FFFD across your own repository.
Zero results means nothing has slipped through as of today. A single hit is a record of a character that was lost somewhere in your history, and git log -S will walk you back to the commit that introduced it.
Until I put this check in place, I assumed I would simply see it when Japanese text broke. In a diff, one character is invisible. Things that are invisible are better counted by a machine.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.