When you review code an AI wrote, check it against the request first: every changed file should trace back to what was asked. Then look for the mistakes generated code tends to make: changes nobody asked for, library calls that don't exist, tests that test nothing, errors that disappear, and loosened security settings.
The short version, as a checklist:
- Write down what was asked.
- Every changed file has a reason that traces to the request.
- Every library call exists, with those arguments, in the version you use.
- Tests fail when you break the code.
- No error is caught and quietly turned into an empty value.
- No security setting was loosened to make something work.
- New code uses the helpers and patterns the codebase already has.
- Comments say what the code actually does.
Code from a coding assistant usually looks finished: tidy formatting, sensible names, comments, passing tests. That finish makes it easy to approve without reading.
With code a person wrote, the surface tells you something. Messy formatting or a vague name often means the author was rushing, and you slow down. Generated code has a tidy surface whatever is underneath, so that signal is gone. You have to check what the code does against what was asked, line by line.
This is a list of the things worth checking, with examples. It applies whether the code came from Claude Code, Copilot, Cursor or anything else, and whether you're reviewing a teammate's pull request or your own session before you push.
Start with the request
The request is the spec. Before you read the diff, find out what the assistant was asked to do.
If it's your own change, scroll back through the session and reread your prompts, including the follow-ups. You probably asked for more, or less, than you remember. If it's a teammate's pull request, ask them what they asked for. A pasted prompt is a perfectly good pull request description.
Write the request down in a sentence or two. Every check below compares the code with it.
Changes nobody asked for
Assistants are helpful in ways you didn't request. You ask for a fix to a date-parsing bug, and the diff also:
- renames a helper "for clarity",
- changes a default timeout from 30 seconds to 10,
- reformats a file it didn't need to touch,
- removes a function it decided was unused.
Each of these is a change nobody reviewed the need for. The timeout one can cause a production incident on its own. The "unused" function may be called through a string, a config file or another repository.
Compare the list of changed files with the request. Any file that doesn't obviously belong needs a reason. If there isn't one, ask for it to be reverted and done separately. A small diff that does exactly what was asked is far easier to trust than a large one that also tidies up.
APIs that don't exist
Models write code that looks like a real API. Sometimes the function doesn't exist, exists in a different version of the library, or takes different arguments.
A common one in Node:
import { promises as fs } from 'node:fs'
if (await fs.exists(configPath)) {
// ...
}
There is no fs.promises.exists. The callback module had fs.exists (now deprecated), and the promise API never did. In TypeScript the compiler catches this. In plain JavaScript it fails at runtime, possibly only on the branch nobody tested.
The subtler version is an option that sounds right, such as a retries setting on a client that doesn't support one. The call works, the option is silently ignored, and the code reads as if it retries.
What to do:
- Run the type checker and the linter. They catch the obvious cases.
- For any library call you don't recognise, jump to its definition or check the docs for the version in your lockfile, not the latest one.
- Be suspicious of options passed as plain objects. Unknown keys rarely cause an error.
How to catch APIs an AI coding assistant made up goes further into these checks.
Tests that test nothing
Generated tests often pass for the wrong reason. Watch for these patterns:
it('calculates the invoice total', async () => {
const calc = { total: vi.fn().mockReturnValue(120) }
const result = calc.total(items)
expect(calc.total).toHaveBeenCalled()
expect(result).toBeDefined()
})
This test mocks the very thing it claims to test, then checks that the mock was called and returned something. It would pass if the real total function were deleted.
Other versions of the same problem:
- Assertions that can't fail, like
toBeDefined()ortoBeTruthy()on a value that's always set. - A snapshot of whatever the code currently outputs, bugs included.
- An existing test edited to match new behaviour, when the old behaviour was right.
- A test for the happy path only, when the change was about an edge case.
The quickest check is to break the code on purpose. Change a > to >= or return early in the function under test, then run the tests. If nothing fails, the tests aren't covering it. Also look at the diff of existing test files with extra care. A changed expectation should come with a reason.
Errors that disappear
Assistants like code that doesn't crash. That often means catching an error and carrying on as if nothing happened:
async function loadSettings(userId: string) {
try {
return await api.getSettings(userId)
} catch (error) {
console.error(error)
return {}
}
}
Now a failed request looks like a user with no settings. The page renders, the defaults kick in, and if the user saves, their real settings may be overwritten with defaults. The bug report will be confusing and hard to trace.
Look for:
catchblocks that log and return an empty value.?? [],?? {}and optional chaining on data that should always be there. They turn a missing value into a quiet wrong answer.- Retries without a limit, and fallbacks that hide which path ran.
Ask what the caller should see when this fails. Usually the honest answer is to let the error reach someone who can act on it.
Security defaults
Generated code tends toward whatever gets the example working. Check for:
Access-Control-Allow-Origin: *on an API that uses cookies or tokens.- TLS checks turned off, such as
rejectUnauthorized: falseorverify=False, left in from debugging. - SQL built by string concatenation instead of parameters.
- New endpoints with no authentication or permission check.
- Secrets in code, in example config that gets committed, or in log lines.
- User input rendered as HTML, through
innerHTMLordangerouslySetInnerHTML. - Broad file permissions or shell commands built from input.
None of these are unique to AI-written code. They're easy to end up with when the request never mentioned security, because nothing in it asked the assistant to think about it.
Does it fit the codebase?
An assistant that doesn't read enough of your repository will solve the problem in its own way. The code works, and it's still a maintenance cost:
- A new
formatDatehelper whensrc/lib/dates.tsalready has one. fetchwith hand-rolled error handling in a codebase that has an API client.- A new dependency added for something the standard library or an existing package already does.
- A different logging style, error type or folder layout from the code next to it.
Search the codebase for the name of anything new. If something similar exists, ask for the existing one to be used.
Comments that describe intent
Generated comments say what the code is meant to do. That isn't always what it does. A comment that reads // Validate the email before saving above code that only checks for an @ is worse than no comment, because the next reader trusts it. Read the code, then check whether the comment is true.
Keep the request next to the code
Most of the checks above come down to one question: does this change match what was asked? That's hard to answer when the request is in a chat window and the code is in a diff.
If the change was made with Claude Code, Hyperpowers for VS Code can help here. When it explains a pull request built with Claude Code on your machine, it reads those sessions, so each change carries the request behind it. Review with Claude also compares the code with what you asked while writing it, and flags anything asked for and not done, or done and never asked for. You still make the call, with the request in front of you. Tracing AI-written code back to the prompt that asked for it explains what it reads.
An assistant can write a change in minutes. Checking it against the request is still the reviewer's job, and it's the part that tells you whether the change is right. For the order to do it in, see How to review a pull request that AI wrote, or try Hyperpowers in your browser on an example pull request before you install it.
Common questions
Is AI-written code worse than code a person wrote?
Not necessarily. It fails in different ways, and its tidy surface hides those failures better, so the review has to check behaviour against the request more carefully.
What is the fastest useful check?
Break the code on purpose and run the tests. If nothing fails, the tests don't cover the change.
Does this apply to code from Copilot or Cursor?
Yes. The checks are about the code, whichever assistant wrote it.