DEV Community

Genevieve Breton
Genevieve Breton

Posted on

Reverse-applying AI changes to obfuscated code: a 3-way merge that actually works

In the last article I went through what breaks when you obfuscate Java code before sending it to an AI assistant — Spring Data, JPA, Lombok, the whole framework iceberg. That was about getting the obfuscated source out in a state the AI can work on.

This one is about the much subtler half: getting the AI's changes back in.

It looks trivial. You sent Cls_a1b2c3d4 to the AI, the AI returned a modified Cls_a1b2c3d4, you have a mapping table, just walk the file and replace each obfuscated identifier with its original. Done in twenty lines of code.

Except your real file — the one a human will read tomorrow morning — now has no comments, no Javadoc, no blank lines between methods, no formatting choices you made over six months. The obfuscation pipeline stripped all of that on the way out. Reversing the rename doesn't bring it back.

This is the story of why naive reverse-translation is wrong, why it's a 3-way merge problem, not a translation problem, and what the merge actually has to handle in practice.


The naive reverse

Here's what most people try first:

String aiOutput = readAiResponse();
String realSource = aiOutput;
for (Mapping m : mappings) {
    realSource = realSource.replace(m.obfuscated(), m.real());
}
writeRealFile(realSource);
Enter fullscreen mode Exit fullscreen mode

Set aside that you also need word-boundary regex and longest-match-first ordering to avoid prefix collisions — assume you handled all of that. The output is still wrong.

Why? Because the file you sent to the AI was not just renamed. It was also:

  • Comment-stripped. Sending Javadoc and inline comments to the AI is gratuitous leakage — they contain plain-English domain language. So they get replaced with blank-equivalent lines before transmission.
  • Reformatted in subtle ways. Multi-line string literals get sanitized. Annotations on separate lines get coalesced. Blank lines are preserved but only by accident.

When you reverse-translate the AI's output and write it back, you're overwriting your real source with the obfuscation-pipeline-shaped version of itself, plus the AI's changes. Every comment you wrote is gone. Every formatting choice. Every blank line at the right place.

The first time I ran this end-to-end on a real project, I tested it on a service class. The AI added one method. I diffed the result against my source: 312 lines changed. One of them was the AI's new method. The other 311 were comments and formatting I had just nuked.


The mental shift: it's a merge, not a translation

Here's the model that finally clicked. The obfuscated file is not the canonical version of your source. It is a projection of your source — one that lost information on purpose. You can't reconstruct your source from the projection alone. You need both.

In git terms: this is a 3-way merge.

Three inputs:

  1. Snapshot — the obfuscated version of your code before the AI touched it. (Your "common ancestor.")
  2. Cache — the obfuscated version after the AI's changes. (The "their" side.)
  3. Real — your actual source file, with all comments and formatting intact. (The "ours" side.)

The output is your real file, with only the AI's changes applied.

The merge logic, in one sentence: for each line, if the AI didn't change it (snapshot line == cache line), keep your real line; if the AI changed it, de-obfuscate the cache line and use that.

Stated like that, it's almost obvious. The implementation has interesting corners.


The easy case: same line count

When the AI modifies lines without adding or removing any, the line indices line up across all three files. The merge is one pass:

String[] snapshotLines = snapshot.split("\n", -1);
String[] cacheLines = cache.split("\n", -1);
String[] realLines = real.split("\n", -1);

StringBuilder out = new StringBuilder();
for (int i = 0; i < cacheLines.length; i++) {
    if (i > 0) out.append('\n');
    if (snapshotLines[i].equals(cacheLines[i])) {
        // AI didn't touch this line — keep the real version (with comments, formatting)
        out.append(realLines[i]);
    } else {
        // AI changed this line — de-obfuscate it
        out.append(deobfuscate(cacheLines[i]));
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it. The whole trick is the snapshotLines[i].equals(cacheLines[i]) check. Equal obfuscated lines mean the AI didn't write here, so your real line — comments, blank lines, formatting — survives untouched. Only the cells the AI actually changed get the de-obfuscated translation.

This single trick made the merge usable. On a typical edit (the AI adds a parameter, changes a return type, inserts a guard clause), it touches 5–20 lines and the rest of the file stays bit-for-bit identical to my source. No phantom formatting changes, no destroyed Javadoc.


The hard case: AI added or removed lines

When the AI adds an if block or removes a redundant method, line counts diverge between snapshot and cache. Now indices don't line up — line N of the cache might correspond to line N+3 of the snapshot, or to nothing at all.

You can pull in java-diff-utils and run a real LCS-based diff here. I tried that first. It works, but it adds a dependency, the diff format needs translation, and for the size of edits the AI typically makes (5–50 lines), a homegrown linear walker is faster and easier to reason about.

The walker keeps three indices — one per file — and decides at each step whether the current cache line is an unchanged line (advance all three), a modification (advance all three, but de-obfuscate the cache line), or an insertion (advance only the cache index):

int si = 0, ci = 0, ri = 0;
while (ci < cacheLines.length) {
    if (si < snapshotLines.length && snapshotLines[si].equals(cacheLines[ci])) {
        // unchanged → keep real
        out.append(realLines[ri]);
        si++; ci++; ri++;
    } else {
        // changed: modification or insertion?
        boolean isInsertion = false;
        if (si < snapshotLines.length) {
            for (int look = ci + 1; look < cacheLines.length && look < ci + 50; look++) {
                if (snapshotLines[si].equals(cacheLines[look])) {
                    isInsertion = true;
                    break;
                }
            }
        }
        if (isInsertion) {
            // AI inserted a new line before the next snapshot line
            out.append(deobfuscate(cacheLines[ci]));
            ci++;
        } else {
            // AI modified or replaced this line
            out.append(deobfuscate(cacheLines[ci]));
            si++; ci++; ri++;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The 50-line look-ahead window deserves a comment. It's the heuristic that decides "did the AI insert new code before this snapshot line, or did it modify this snapshot line?" If the next snapshot line shows up within the next 50 cache lines, treat the current cache line as an insertion. Otherwise treat it as a modification.

Why 50? Two reasons:

  1. Cost. A full O(N²) LCS on a 2000-line file does ~4M comparisons. Bounded look-ahead caps each step at 50 comparisons → 100k total. On a real file this is microseconds.
  2. Realism about edit shapes. AI assistants rarely insert 50+ contiguous lines without also modifying surrounding code. When they do, you're outside "merge a small edit" territory and you should be re-running the obfuscation pipeline on the result anyway.

It is a heuristic. On pathological inputs (the AI rewrites the entire file), it degrades to "treat everything as modification" which produces a usable but heavily de-obfuscated file. That's the right failure mode — you'll lose formatting on the affected stretch, but you won't lose data.


Three traps that the test suite found

The merge in the previous sections is the version that works. Getting there involved walking face-first into a few traps that aren't obvious from the algorithm.

Trap 1: snapshot and cache disagree on line count even when the AI didn't add lines

Early on I was confused by failures that looked like the AI had inserted lines, when the diff in the assistant's output clearly hadn't.

What was happening: the snapshot had been written months earlier, when the obfuscation pipeline's comment-stripping pass replaced multi-line /* ... */ comments with a single empty line. The current version replaces each line of the comment with its own empty line — preserving line count. So a snapshot from version v1 and a cache from version v2 could disagree by dozens of lines for the same source file, just because of comment-stripping format drift.

The fix: when snapshot and cache disagree on line count, re-obfuscate the real file on the fly to get a fresh snapshot in the current format, and use that as the merge ancestor. Only .java files — sanitizers for .properties, .yml, and pom.xml preserve line count by construction, so any line-count drift on those files is a genuine AI edit, not a format mismatch.

if (snapshotLines.length != cacheLines.length && obfRelativePath.endsWith(".java")) {
    String freshObfuscated = engine.obfuscateContent(realContent);
    if (freshObfuscated != null) {
        snapshotContent = freshObfuscated;
    }
}
Enter fullscreen mode Exit fullscreen mode

I did not gate this on the obfuscation pipeline version — the cost of re-obfuscating one file on demand is negligible, and the alternative (storing version metadata per snapshot and migrating on read) was complexity I didn't want.

Trap 2: don't run the Java obfuscation pipeline on a .properties file

There's a sharp corner in the fix above. The re-obfuscation call is engine.obfuscateContent(realContent). That method runs the Java pipeline — JavaParser AST walk, identifier replacement, comment stripping, reflection-string post-processing.

If I run it on a .properties file, it produces a near-identity transformation (no Java identifiers to rename, no comments to strip the same way). The output is almost-but-not-quite the same as the real file. Now I have a "snapshot" that diverges from the cache on every single line, because the properties sanitizer (a different pipeline) produced the cache, while the Java pipeline produced this fresh "snapshot."

The merge then concludes that the AI rewrote every line of the properties file, and helpfully writes the sanitized (REDACTED placeholder) values back into the real application.properties. That's not a corrupted file — that's a data exfiltration risk inverted: the redaction now overwrites the real secret.

The .endsWith(".java") guard above isn't a perf optimization. It's a correctness boundary.

Trap 3: AI-created files don't have a snapshot at all

When the AI creates a new file — Cls_a1b2c3d4Test.java, say — there's no snapshot to merge against. There's no real file either. You just have the cache.

This case is simpler in some ways (full de-obfuscation of the content, no merge) but it has its own corner: the filename itself contains obfuscated identifiers. Cls_a1b2c3d4Test.java needs to become InvoiceServiceTest.java — the AI used the obfuscated class name as a prefix to a new identifier, and the path resolver has to recognize the embedded mapping.

The strategy: try a full-filename match against known class mappings first (Cls_a1b2c3d4.javaInvoiceService.java). If that fails, run the standard line de-obfuscation on the filename without extension and treat whatever comes out as the real name (Cls_a1b2c3d4TestInvoiceServiceTest). Same for package path segments.

if (!matched && fileName.endsWith(".java")) {
    String stem = fileName.substring(0, fileName.length() - 5);
    fileName = engine.deobfuscateLine(stem) + ".java";
}
Enter fullscreen mode Exit fullscreen mode

The same machinery you wrote to de-obfuscate file contents solves the file path problem if you feed it the path as a string. Once I noticed this, several other corner cases I'd been hand-rolling (paths in stack traces, file references in error messages) collapsed into the same call.


What about deletions?

The AI sometimes "cleans up" by deleting a file. I do not auto-apply deletions. The merge reports them — applied: 3, created: 1, deletedByAi: 1 — and the developer decides whether to follow through.

This is not a technical limitation. It's a deliberate asymmetry. The cost of accidentally creating a file is a git rm away. The cost of accidentally deleting a file the developer hadn't checked in yet is unrecoverable. The merge plays defense on the irreversible side.


Why this generalizes

I started building this for obfuscation because I had to. But the pattern — projection, transformation in the projected space, merge back into the original — shows up in a lot of places once you look for it:

  • Source maps in JavaScript bundlers. The bundled file is a projection; the original sources are the real version; you map errors back via the source map.
  • AST-based refactoring tools. The AST is a projection; the textual source has comments and formatting the AST doesn't; round-tripping requires a 3-way merge.
  • Notebook → script extraction and back. Strip cells to a script for review; merge edits back into the notebook.
  • Anything that asks an LLM to edit code with stripped context. Hide secrets, hide proprietary names, hide internal comments — and now you own a merge problem.

The takeaway from six months of breaking my own merge: don't think of the projection as a translation. Think of it as a branch. Once you call it a branch, you stop trying to invent a clever inverse and you start writing a merge — which is a problem the industry has spent decades solving.


If you want to see the merge running on real Java edits — including the tricky cases — the example diffs and test fixtures live in gitlab.com/gbreton7/promptcape-docs. It's the docs and worked-examples companion to **PromptCape*, the obfuscation proxy I'm building for Claude Code and Cursor. MRs welcome if you've run into a merge case I haven't.

Top comments (0)