DEV Community

Finley Zhou
Finley Zhou

Posted on

Case Study: A Free Model Refactored a C++ Library. An ABI Gate Rejected 11 of 12 Patches.

The short version

A free model produced 12 modernization patches for a small C++ library. A 20-line symbol-diff gate rejected 11 of them before any human review. The one patch that passed changed the internal container and broke a downstream assumption. Zero patches merged as-is.

That is the pattern I keep seeing with AI-generated C++: the diff looks better, and the binary contract quietly breaks.

Background

kvstore is a small key-value store I maintain. One public header, one implementation file, compiled into libkvstore.so. Three downstream projects link against it. The public API is small:

// kvstore.hpp (abbreviated)
namespace kvstore {
class Store {
public:
  explicit Store(const std::string& path);
  bool put(const std::string& key, const std::string& value);
  std::optional<std::string> get(const std::string& key) const;
  bool remove(const std::string& key);
  size_t size() const;
};
}
Enter fullscreen mode Exit fullscreen mode

The library has been stable for a year. Nobody touched the public header. Then I asked a free model to modernize it.

Goal

I wanted one number: how many refactor proposals survive a merge gate that checks binary compatibility. The gate had a single job — reject any patch that changes the set of exported symbols or their mangled names.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to generate the proposals and its free server option to run the disposable builds.

The gate

Symbol-level ABI checks do not need a heavy tool. nm and c++filt are enough for a first line:

#!/usr/bin/env bash
set -euo pipefail

extract_symbols() {
  nm -D --defined-only "$1" \
    | awk '{print $3}' \
    | c++filt \
    | sort -u
}

extract_symbols build/baseline/libkvstore.so > baseline.syms
extract_symbols build/patched/libkvstore.so  > patched.syms

removed=$(comm -23 baseline.syms patched.syms)
added=$(comm -13 baseline.syms patched.syms)

if [[ -n "$removed" || -n "$added" ]]; then
  echo "ABI GATE FAILED"
  echo "--- removed ---"; echo "$removed"
  echo "--- added ---";   echo "$added"
  exit 1
fi

echo "ABI GATE PASSED"
Enter fullscreen mode Exit fullscreen mode

The gate is deliberately coarse. It catches signature changes, removed symbols, and added noexcept specifiers, because all of those change the mangled name. It does not catch class layout changes or behavioral changes. More on that later.

Workflow

  1. Build the baseline: g++ -std=c++20 -shared -fPIC -O2 -o build/baseline/libkvstore.so kvstore.cpp.
  2. Extract the baseline symbol manifest and commit it to the repo.
  3. For each of the 12 proposals: apply the patch, build into build/patched/, extract symbols, diff against the manifest.
  4. If the gate passes, run the unit tests and the downstream smoke tests.

Each iteration took about eight seconds of compute. Twelve proposals, twelve builds, twelve diffs. Total gate time: under two minutes.

Results

Outcome Count Typical change
Rejected: signature changed 7 std::stringstd::string_view in public methods
Rejected: noexcept added 3 noexcept on get(), size(), remove()
Rejected: symbol removed 1 remove() labeled "dead code"
Passed the ABI gate 1 private storage std::mapstd::unordered_map

Eleven of twelve patches looked cleaner in review and broke the binary contract. The model preferred std::string_view and noexcept, which are idiomatic, modern, and exactly the changes that alter mangled names. A human reviewer would likely have approved several of them.

The twelfth patch passed the gate and the unit tests. Then a downstream smoke test failed: one consumer iterated keys in sorted order. std::unordered_map does not guarantee that. The ABI was intact. The behavior was not.

Lessons

  • ABI compatibility is invisible in a diff. A patch that removes three lines of "redundant" code can remove a symbol a downstream binary needs.
  • Free models optimize for style. The proposals were not malicious or sloppy; they were idiomatic. Idiomatic C++ changes are often ABI-breaking changes.
  • A 20-line gate is cheap enough to run on every proposal. Eight seconds per patch is nothing compared with a late-night production incident.
  • Symbol checks are necessary but not sufficient. The one patch that passed changed iteration order. Pair the gate with behavioral tests that encode real consumer assumptions.
  • Ask the model to state the contract change explicitly. When I asked why a patch changed a mangled name, the model usually identified the cause correctly. The explanation is a useful review artifact — but only after the gate has done its job.

Who should not use this

  • Header-only libraries. There is no .so to inspect; compile a fixed consumer against both versions instead.
  • Pre-1.0 projects where ABI breaks are intentional. The gate will just add noise.
  • Projects that ship only executables. If nothing links against your code, the symbol diff protects nothing.

If you are merging AI-generated C++ patches, the cheapest safety net is a symbol diff. Run it on your next proposal and count how many survive. I suspect your number will look like mine.

Top comments (0)