Developer Productivity

How to Review AI-Generated Code Without Rubber-Stamping It

The uncomfortable thing about model-written code is that it reads well. Names are sensible, the structure is conventional, there are comments, and the error handling looks like error handling. Human-written code that is wrong usually looks wrong somewhere — a rushed name, a missing branch, a comment that contradicts the line under it. Model-written code that is wrong looks exactly like model-written code that is right. Your usual reviewing instinct, which is largely a smell detector tuned on other humans, gets no signal.

The takeaway up front: reviewing AI-generated code is a different job from reviewing a colleague's, because the correlation between "reads fluently" and "is correct" is broken. So stop reviewing for style and plausibility — machines and linters already handle those — and spend your attention on the three things a model has no reliable access to: whether the code matches your system's actual contracts, whether it handles the paths nobody wrote a test for, and whether it introduced something that only hurts later. This is a guide to doing that deliberately instead of scrolling to the bottom and clicking approve.

Why fluency breaks your review instincts

A language model produces the most plausible continuation of a prompt given everything it has absorbed about how code is usually written. That objective gets you idiomatic, well-shaped, conventional-looking output. It does not get you output that is true about your codebase, because the model's picture of your system is limited to whatever context it was given, plus a strong prior about how systems like yours normally work.

That gap produces a specific and predictable shape of error. The code is locally coherent and globally wrong. A function correctly implements retry logic — against an endpoint that is not idempotent. A cache is added with a sensible eviction policy — for data that must never be stale. A helper is written from scratch, cleanly — three directories away from the one that already exists and is already tested. None of that is visible line by line. All of it is visible if you review the decisions rather than the lines.

There is a second effect worth naming, because it is about you, not the model. A diff that offers no friction — no confusing names, no obvious gaps — quietly signals that there is nothing to find. Volume compounds it: AI assistance raises how much code arrives for review far more than it raises anyone's capacity to read it. Rubber-stamping is rarely a decision. It's what happens when a large, smooth diff meets a tired reviewer with no explicit method.

The failure modes worth hunting for

You cannot review everything with equal intensity, so aim at where models actually break. In rough order of how much damage they do:

Invented or misremembered interfaces. A call to a method that does not exist, a parameter renamed two versions ago, a config key that is close to the real one. The tell is code that is confident about an API it was never shown. This is the cheapest class to catch — types and a running test suite find most of it — which is exactly why it should never be what you spend human attention on.

Contract mismatches. The code compiles and the shape is right, but the semantics of your system are wrong: nullable treated as non-null, a currency assumed to be minor units when it is stored as a decimal, a timestamp assumed UTC when the column is local, an ID assumed sequential when it is a ULID. Models default to the most common convention, and your codebase is not obliged to be common.

Silent unhandled paths. Not missing error handling — models are good at adding try/catch — but error handling that swallows. A caught exception that logs and returns an empty list turns a failure into a plausible-looking empty result that propagates three layers before anyone notices the dashboard is quietly wrong. Ask what happens on partial failure, on empty input, on the second concurrent call.

Security-relevant defaults. String-built queries where the codebase uses parameter binding, authorization checked in the handler that was shown to the model but not the one that wasn't, permissive CORS or TLS verification disabled to make an example run. Models optimize for code that works, and the shortest path to "works" often skips a control.

Duplication and drift. The most under-reported cost. Generated code tends to be self-contained, because self-contained code is easier to produce without full knowledge of a repository. So you get a second date parser, a third HTTP wrapper, another slightly different retry policy. Each is fine. Collectively they are the maintenance bill, and no single review ever feels like the right place to object.

Tests that assert the implementation. When the same session writes the code and its tests, the tests encode what the code does, not what it should do. They pass, they raise coverage, and they lock in the bug.

A read order that surfaces the real problems

Reading a diff top to bottom is how you catch typos. Reading it in this order is how you catch the failure modes above.

1. Read the intent before the diff. What was this change supposed to do, in one sentence, written by the human who requested it? If nobody can state that without pointing at the code, stop — you cannot review an implementation against an unstated goal, and the model certainly didn't.

2. Read the interfaces the change touches, not the change. Function signatures, types, database columns, API payloads, config keys. This is where contract mismatches live, and it's a small surface. Verify against the real definitions, not against how the call site reads.

3. Read the deletions and the untouched neighbours. Diff tools show what was added, which is where fluency does its best work. Far more informative: what was removed, what was moved, and what nearby code the change now contradicts. A generated change often bypasses an existing guard rather than reusing it, and the guard is not in the diff.

4. Trace one full path by hand. Pick the most consequential input — the one that touches money, permissions, or user data — and follow it through the new code, out to every call it makes and back. Not the happy path. The one where the third call fails.

5. Ask what got duplicated. Search the repo for one distinctive term from the new code: the domain noun, the helper name, the constant. If something with the same job already exists, that's the review comment that pays off longest.

6. Read the tests last, and adversarially. For each test, ask what wrong implementation would still pass it. If the answer is "several," the test asserts shape, not behaviour. Then ask which failure path from step 4 has no test at all — that gap is the real finding.

Steps 1 through 3 are cheap and catch structural problems; 4 and 5 deserve the bulk of your time. None of this is about formatting, naming, or idiom — those should never reach a human.

Automate everything that does not need judgement

The only durable way to keep human review meaningful is to make the machine reject everything a machine can detect, before the diff is opened. This is the same discipline as agreeing on a branching policy early — a convention like rebase vs merge is worth settling once so nobody re-litigates it per pull request. Review standards work the same way: encode them, don't repeat them.

At minimum, put these in front of every AI-assisted change:

  • A type checker in strict mode. The highest-yield gate against invented interfaces and null-handling mistakes, because it verifies the code against your definitions rather than the model's prior.
  • Linters with the rules that matter turned into errors. Unused results, shadowed variables, floating promises, missing awaits, ignored exceptions. The trade-off is friction on legitimate exceptions; the fix is an explicit inline suppression a reviewer can see, which is itself a useful signal.
  • A dependency check. New third-party packages are one of the most common quiet additions in generated code. Gate on the fact of a new dependency, not just its known vulnerabilities, so somebody consciously accepts it.
  • A secret scanner and a static analysis pass. Catches the hardcoded key and the string-concatenated query cheaply and without opinion.
  • A test run that includes something the model did not write. Existing regression tests are the closest thing you have to an independent check on new code. If the change ships with new tests only, the suite has not validated anything.

The trade-off is real: each gate costs setup and adds latency, and an over-tuned pipeline trains people to bypass it. Add gates in order of what your incidents have actually been caused by, not in order of what is easy to install.

Run the review as a conversation, not an inspection

The most effective control is social. Whoever submits AI-generated code owns it exactly as if they had typed every character — a norm worth stating out loud, because the failure mode is diffuse responsibility, where the author feels like a conduit and the reviewer assumes the author understood it.

Two habits enforce it without ceremony. First, require the author to explain a non-obvious decision in their own words: why this data structure, why the retry is safe here, what happens if the second call fails. Not as a test — as the thing that reveals whether the change was understood or merely accepted. Second, require the diff to be small. Generated changes arrive large because generating large is free, but reviewing large is not, and if a change can't be split, that itself is worth saying.

The point is not to slow AI assistance down. It's that the bottleneck moved. Writing code got cheap; understanding it did not, and review is now the only place that understanding is guaranteed to happen.

FAQ

How do I review AI-generated code effectively?

Review decisions rather than lines. Confirm the change's stated intent, verify it against the real interfaces and contracts in your codebase, trace one consequential failure path by hand, check whether it duplicated something that already exists, and read its tests adversarially by asking what wrong implementation would still pass them. Push everything mechanical — formatting, types, lint, secrets — into automated gates so your attention goes to the things a tool cannot judge.

Should AI-generated code be reviewed more strictly than human code?

Not more strictly, but differently. The volume is higher and the usual heuristics are less reliable, because fluent-looking output no longer correlates with careful thinking. Spend less time on style and idiom, which models handle well, and more on system-level correctness, unhandled paths, and duplication, which they handle poorly without full context.

What are the most common bugs in AI-generated code?

Calls to interfaces that do not exist or have changed, assumptions about your data that follow the most common convention instead of yours (units, time zones, nullability, ID format), error handling that swallows failures into plausible empty results, security-relevant shortcuts taken to make code run, and reimplementation of helpers the codebase already has.

Can I trust tests that the AI wrote for its own code?

Treat them as a statement of intent, not as verification. Tests generated in the same pass as the implementation tend to assert what the code does rather than what it should do, so a bug and its test are consistent with each other. The useful check is your pre-existing regression suite, plus tests written from the requirement by someone who has not read the implementation.

How do I stop reviewers from rubber-stamping AI-generated pull requests?

Reduce what reaches them and make ownership explicit. Cap diff size, automate every check that does not require judgement, require the submitting engineer to explain the non-obvious decisions in their own words, and state the norm that the author is responsible for the code as if they had written it by hand.

Next step

Take the next AI-assisted pull request in your queue and run it through the read order above — intent first, interfaces second, deletions third, one failure path traced by hand, then the tests read adversarially. Then look at what you found and ask which of it a type checker or a linter could have caught for free, and go turn that on. More pragmatic engineering guides, and task-level reference snippets you can copy into the change you are reviewing, are at TheAppCode.

Comments are disabled for this article.