refactoring
Safe semantic refactors: preserving behavior during AI-driven code changes
Learn how to build verification loops that ensure AI-generated code refactors preserve behavior through compile checks, tests, and property-based assertions.

A refactor that silently changes what your code does isn't a refactor—it's a bug with a PR description. The promise of AI-assisted refactoring is speed and scale, but without mechanical guarantees that behavior stays identical, you're trading one problem for a worse one: distributed, subtle breakage that only surfaces in production.
Safe semantic refactors aren't about trusting the AI to "understand" your code. They're about building a verification loop that catches deviations before they merge. When Goatfied runs a refactor, the agent produces a plan, generates edits, then gates every change through compile checks, existing tests, and optional property-based assertions. If any gate fails, the loop retries with constraint feedback until the change either passes or the agent gives up. The result: you get speed where behavior truly doesn't change, and an early abort when it would.
What makes a refactor semantically safe
A semantically safe refactor preserves observable behavior under all inputs your tests cover—and ideally, under all possible inputs. Renaming a variable is trivially safe if your language has scoped bindings. Extracting a method is safe if the new signature captures all dependencies and side effects. Inlining a function is safe if you don't lose laziness or change evaluation order.
The danger zone starts when refactors span module boundaries, touch concurrency primitives, or interact with type inference in non-obvious ways. Consider extracting a callback into a named function in JavaScript: if that callback closed over a loop variable that's reassigned, hoisting it changes what it captures. Or renaming a Python class that's also registered in a plugin dictionary by string name somewhere else in the codebase—your IDE won't catch that.
Type systems catch some of this. If you rename a public struct field in Go and your tests compile, you've covered a lot of ground. But types don't model side effects, timing, or runtime reflection. A refactor that passes tsc --noEmit can still break if you changed the order of React hook calls or reordered import statements that have side effects.
The compile-lint-test gate pattern
Goatfied's agent loop enforces three layers of verification before considering any edit complete:
1. Compile gate: After each edit batch, run the language's compiler or type checker. If the code doesn't compile, the agent receives the error message as a constraint and retries the edit. This catches type errors, missing imports, and syntax mistakes immediately.
2. Lint gate: Run static analysis tools (ESLint, Ruff, Clippy, etc.) on the changed files. Lint rules encode project-specific invariants—no unused variables, no unsafe Rust, consistent formatting. Violations trigger a retry with the lint output as context.
3. Test gate: Execute the test suite scoped to the changed code. If tests fail, the agent sees the failure output and attempts a fix. If tests pass, you have evidence the observable behavior hasn't regressed within the test coverage envelope.
Each gate is fail-fast. The agent doesn't move to the next step until the current one passes. This creates a tight feedback loop: the AI isn't guessing whether a change broke something; it knows, and it adjusts.
Here's a minimal example of what a failing compile gate looks like in practice. Suppose the agent tries to rename a TypeScript interface property:
// Before
interface Config {
maxRetries: number;
}
// Agent's first attempt
interface Config {
maxAttempts: number;
}
// Compile error:
// src/client.ts:42:18 - error TS2339: Property 'maxRetries' does not exist on type 'Config'.
// const retries = config.maxRetries;
The agent receives this error, sees the reference in client.ts, and retries with an edit that also updates the call site. On the second pass, compilation succeeds.
Small diffs reduce semantic risk
Large, sweeping changes are exponentially harder to verify. If an AI agent rewrites 400 lines across five files in one pass, your test suite might catch the breakage, but debugging which specific edit caused it becomes manual archaeology.
Goatfied's architecture favors small, reversible diffs. The agent is constrained to produce focused edits—rename this function, extract these three lines, update this import path—then validate before moving on. If a refactor logically requires multiple steps (rename a class, update all call sites, adjust tests), the agent sequences them as separate edits, each validated independently.
This mirrors how careful human engineers refactor: change one thing, run tests, commit, repeat. The difference is the AI can execute this loop in seconds instead of minutes, and it doesn't get impatient or skip the validation step.
Small diffs also make review tractable. A 12-line PR that renames a function and updates its three call sites is easy to verify by inspection. A 400-line AI-generated rewrite requires trust or exhaustive manual testing.
When tests aren't enough: property-based checks
Unit tests verify specific examples. Property-based tests verify invariants across generated inputs. If you're refactoring a parser, a property might be: "for all valid input strings, the old and new parser produce identical ASTs." If you're refactoring a numerical algorithm, the property might be: "outputs match within floating-point epsilon for a million random inputs."
For critical refactors, you can extend Goatfied's test gate with property-based assertions that compare old and new implementations. This isn't built-in magic; you write a harness that imports both versions (or feature-flags between them), runs the same inputs through both, and asserts equality.
from hypothesis import given, strategies as st
import old_module
import new_module
@given(st.integers(), st.lists(st.text()))
def test_refactor_preserves_behavior(count, items):
old_result = old_module.process(count, items)
new_result = new_module.process(count, items)
assert old_result == new_result
Run this as part of the test gate. If Hypothesis finds a counterexample, the agent sees the failure and must revise. This shifts refactoring from "probably correct" to "validated across a large input space."
Handling indeterminate behavior and side effects
Not all behavior is deterministic or easily comparable. If your function logs to stdout, makes HTTP requests, or returns timestamps, naive equality checks fail even if semantics are preserved.
For side effects, you have three strategies:
Capture and compare traces: Mock the side effect and record what was called. Assert the sequence of calls matches between old and new. If the refactor was meant to preserve behavior, the trace should be identical.
Idempotent assertions: Instead of comparing outputs directly, assert idempotent properties. "Both versions write files to the same paths with the same checksums" or "both versions send the same set of SQL queries, possibly in different order."
Isolate pure logic: Refactor to separate pure computation from side effects, then apply property-based tests to the pure part. The side-effecting shell can be tested with integration tests or manual review.
Goatfied doesn't prescribe how you handle this—your test gate can be as sophisticated as needed. The point is that the agent won't proceed until your chosen verification passes.
Real constraints: when refactors can't be fully automated
Some refactors touch semantics in ways no test suite will catch. Changing a public API's contract, migrating from synchronous to asynchronous code, or altering database schema—these require human judgment about compatibility, rollout strategy, and downstream impact.
The honest answer: don't use AI for those. Use it for the mechanical parts. Let the agent rename 50 call sites after you've changed the function signature. Let it update import paths after you've moved a module. Let it reformat code after you've decided on the new structure.
Goatfied's loop is designed to fail safely. If the agent can't produce a valid edit within a reasonable number of retries (typically 3-5), it aborts and reports what it tried. You get a notification that the task is too complex or ambiguous for automated handling. That's better than a plausible-looking PR that breaks prod.
Auditing refactor history
When something does break post-merge—because test coverage had a gap, or a side effect wasn't modeled—you need to trace what changed. Goatfied logs every agent interaction: the plan, each edit attempt, which gates failed, and the final diff that passed.
This audit trail lives in your version control history (via commit messages and PR metadata) and optionally in Goatfied's managed platform as structured logs. You can replay the sequence: why did the agent choose this approach? What constraint guided the retry? Which test passed that shouldn't have?
Self-hosted deployments can pipe these logs into your existing observability stack. The goal is transparency: AI-generated code should be more traceable than human-written code, not less.