How to Prevent AI From Breaking Existing Code: A Tutorial

· 11 min read

Preventing AI from breaking existing code comes down to one principle: make breakage cheap to detect and cheap to undo. Pin current behavior with tests, keep each task small, protect critical paths, and let CI block regressions. A prompt asking the model to "be careful" is not on that list.

Key takeaways- AI assistants rarely break code in one dramatic step; they drift through unrequested edits, renamed symbols and quietly changed behavior, so your defenses must catch drift.- Write characterization tests first and commit them separately; they are the only reliable definition of "unchanged" for legacy code.- Rules files are advisory. Enforce the important constraints with CODEOWNERS, permissions and required CI checks.- Review the diff, not the chat summary, and treat a small request that touched many files as the first warning sign.- Block PRs that change tests and source together unless a human approves, so the assistant cannot move the goalposts.

The short answer: make breakage cheap to detect and cheap to undo

A common first failure looks harmless. You ask for a small fix, the assistant also tidies a helper, renames a parameter and adjusts a condition "for clarity". Everything compiles, the chat summary sounds reasonable, and a behavior change ships.

Prevention has four layers:

  • Tests that pin current behavior.
  • A narrow task scope.
  • Protected paths that need a human.
  • CI that blocks regressions.

I treat the assistant like a fast junior contributor. Its output is useful, and it is never merged on trust.

Why bother with this much structure? The evidence on AI and code quality is mixed and early, and none of it proves your codebase will suffer. A 2024 GitClear analysis of over 150 million changed lines, summarized by Dev Notes, projected that code churn (lines reverted or updated within two weeks) would double in 2024 versus 2021 levels. That is a clue that more mistakes get introduced and then fixed, not proof that any given assistant breaks your code.

The 2023 DORA report, as summarized by AI4SDLC, found that more than half of roughly 3,000 respondents used AI for technical tasks, yet no sustained improvement in delivery metrics appeared. It also named fast code review and smaller changes as practical levers. That supports the approach here, though a survey of that kind cannot show cause and effect.

Practical rule: Assume the assistant will occasionally change behavior you did not ask about, and build the pipeline so that change fails loudly.

Prerequisites

You need the following before starting:

  • A git repository with a clean working tree, a branch-protected main and CI that runs on every pull request.
  • A test runner you can execute locally in a few minutes, plus a linter and type checker if your language has them.
  • An assistant that supports project rules files (for example Cursor rules or CLAUDE.md) and lets you review diffs before applying them.
  • A CODEOWNERS file or equivalent, and permission to add required status checks.

If your main branch is not protected, fix that first. Every later step assumes a failing check can actually stop a merge. Branch protection and required checks are configured in your hosting platform, and the labels differ by provider and plan, so check its documentation.

Step 1: Snapshot the current behavior with characterization tests

A characterization test records what code does today, including odd outputs, rather than what it should do. That distinction matters. If a legacy function returns an empty string for a null input, and a downstream report depends on that, the test should assert the empty string.

What to cover

  • Cover the public interface, not internals. Internals are what you want the assistant to be free to change.
  • Use snapshot or golden-file tests for functions with complex output, such as rendered templates, serialized payloads or report generators.
  • Feed in real-shaped inputs, including the ugly ones: empty values, legacy formats, unexpected encodings.

Coverage numbers mislead

Ten tests around the risky module beat 80% line coverage elsewhere. A coverage percentage tells you which lines executed, not whether a behavior change would be noticed. Spend your effort where a silent change would hurt most: billing logic, permission checks, data transformations.

Commit the tests alone

Put the characterization tests in their own commit, before any assistant work. That gives you a known-good reference. If a later diff touches those files, it stands out in review and in CI.

Example: you plan to let an assistant refactor a price-calculation module. Before that, you write eight golden-file tests with inputs like a zero quantity, a negative discount and a legacy currency code. You commit them alone. If any golden file changes later, you know the refactor altered output.

Step 2: Write project rules that limit what the assistant may touch

Put explicit constraints in the rules file your assistant reads (Cursor rules, CLAUDE.md or the equivalent). Keep them short and testable:

  • Do not rename or remove public symbols.
  • Do not edit existing tests to make them pass.
  • Do not add dependencies.
  • Do not modify these directories: migrations/, infra/, auth/.
  • Before declaring a task done, run the test, lint and type-check commands, and report the results.

List the exact commands. "Run the tests" is vague; a literal command is something the assistant can execute and you can verify.

Rules reduce drift, but they are advisory. A model can ignore them, especially in a long session where the instructions have scrolled out of focus. Security research also shows that rules files can themselves be an attack surface: the Cloud Security Alliance research note on AI-generated CVEs lists CVEs disclosed in 2025 against Cursor and GitHub Copilot's rule file processing. So review changes to the rules file like code, and do not copy rules from untrusted repositories.

Practical rule: Use the rules file to lower the rate of drift, and use tooling in Steps 4 and 6 to enforce anything you cannot afford to lose.

Step 3: Give one small, bounded task per branch

A good prompt states three things: the files in scope, the expected behavior change, and the explicit non-goals. For example: "Change calculate_total in pricing/total.py to round half up. Do not modify other files. Do not change function signatures. Do not touch tests."

Branch and commit discipline

  • Create a fresh branch for each task.
  • Commit before every assistant run, so every change is one git revert or git restore away.
  • Stop and split the task if the diff grows beyond what you can review in one sitting.

Small tasks also help review. The DORA summary above lists smaller changes among the levers associated with better delivery, which is a correlation from survey data, but the mechanism is easy to reason about: a reviewer catches a 40-line change far more reliably than a 900-line one.

When not to use a model

For a mechanical rename across hundreds of files, use your IDE's refactoring or a codemod. Those tools are deterministic and apply the same transformation everywhere. A language model may skip a file or alter a neighbouring line, and you will not know which.

Step 4: Protect critical paths with ownership and permissions

Add CODEOWNERS entries for auth, billing, migrations, infrastructure and configuration, so any change there requires a human reviewer. A sketch:

# CODEOWNERS (example)
/auth/            @example-org/security
/billing/         @example-org/payments
/migrations/      @example-org/platform
/infra/           @example-org/platform
*.lock            @example-org/platform

Then deny the assistant write access to secrets, lockfiles and deployment manifests through its permission settings, where the tool supports it. Support and setting names vary by assistant and version, so check its documentation rather than assuming.

Identity and release configuration deserve the strictest rules. In my platform work I manage identity as code with Keycloak and Terraform, precisely so that every change is a reviewable diff rather than a click in a console. The same logic applies here: if a change cannot be reviewed as text, an assistant can alter it without anyone seeing.

One limitation: CODEOWNERS only helps if branch protection requires code owner review. Without that setting, the file is decoration.

Practical rule: Anything that can lock users out, move money or change production topology needs a named human owner and a required review.

Step 5: Review the diff, not the explanation

The chat summary describes what the assistant intended. The diff shows what it did. They often differ, and the summary tends to omit deletions and side edits.

A fast first pass

  • Run git diff --stat first. A small request that touched many files is the first warning sign.
  • Look for deleted lines, changed function signatures, loosened conditions (> becoming >=, a removed null check) and edited tests.
  • Check for new dependencies in manifests, since the CSA note above reports that roughly 20% of AI-generated code samples referenced packages that do not exist, a pattern attackers exploit by registering those names. That figure comes from research samples, not your codebase, but it justifies a glance at every manifest change.

Use a fixed checklist for AI-written changes so you do not rely on mood. My guide on how to review AI-generated code covers it in detail.

To test your review habit, deliberately look at what the assistant did not mention. If the summary says "updated the handler" and the diff also shows a changed test fixture, that gap is the finding.

Step 6: Enforce gates in CI (Optional: add mutation testing)

Reviews fail when people are tired. CI does not get tired. Make these required status checks:

  • Unit and characterization tests.
  • Linter and type checker.
  • A dependency scan and a container image scan. I scan every container image with Trivy in CI.

Block test edits alongside source edits

Add a check that fails when a PR modifies both test files and source files, unless a designated reviewer approves. This directly targets the failure where an assistant "fixes" a red test by rewriting it. The implementation depends on your CI system; a short script comparing changed paths is usually enough. The cost is some friction on legitimate PRs, which is acceptable for core modules and annoying for greenfield code, so scope it to the paths you pinned in Step 1.

Optional: mutation testing

Mutation testing tools such as Stryker (JavaScript and others) or mutmut (Python) change your code in small ways and check whether tests fail. Surviving mutants show where a behavior change would go unnoticed. It is slow, so run it nightly on core modules rather than on every PR.

To roll back a bad merge that slipped through, revert the merge commit on main and re-run the pipeline. Because each task lived on its own branch with its own commits, the revert is clean.

Reporting rule: Report which checks were required and passed on each AI-assisted PR, not how much code the assistant wrote.

FAQ

Should I let the AI edit my existing tests?

Not to make a failing test pass. If behavior is meant to change, update the test yourself or review that edit separately and deliberately. New tests for new code are fine, provided a human reads them for assertions that check nothing.

Does a rules file actually stop the assistant from ignoring instructions?

No. It lowers the odds, but a model can still ignore or forget a rule, particularly in long sessions. Treat the file as guidance and back every rule you care about with CODEOWNERS, permissions or a required CI check.

How big should a task be when asking AI to change legacy code?

Small enough that you can read the whole diff in one pass and state the expected behavior change in a sentence. There is no universal line count. If the diff surprises you, the task was too large, so revert and split it.

Set this week's publishing plan

If you also want to see how these ideas play out in a real rewrite, I wrote about how I rebuilt my 10-year-old open source package with Cursor and Claude. Clean module boundaries make every step above easier, which is the subject of structuring a codebase for scale.

Use this checklist to put the guardrails in place this week:

  1. Pick the one module where a silent behavior change would hurt most, and write eight to ten characterization tests for its public interface.
  2. Commit those tests alone, on their own commit, and make sure they pass on main.
  3. Add a rules file with no-rename, no-test-edits, no-new-dependencies, off-limits directories and the exact commands to run.
  4. Add CODEOWNERS entries for auth, billing, migrations, infrastructure and lockfiles, and turn on required code owner review.
  5. Make tests, lint, type check and scans required status checks, and add a check that flags PRs changing tests and source together.
  6. For the next AI-assisted task, use a fresh branch, commit first, state files and non-goals in the prompt, and read git diff --stat before the full diff.
  7. Optional: schedule nightly mutation testing on the core module and review the surviving mutants monthly.
How to Prevent AI From Breaking Existing Code: A Tutorial - Ali Hesari