AI

Reviewing AI-Written Code: Checks That Catch Real Bugs

Table of Contents

This post is for engineers and tech leads who merge pull requests that an agent wrote, and who suspect their review is mostly reading for style. Reviewing AI-written code fails in a predictable way: the diff compiles, the tests in the same diff pass, and the bug sits in a line nobody had a reason to doubt. Instead of offering another checklist built on opinion, this post measures one. It takes a small order library, seeds fourteen defects of the kinds that published studies of LLM output describe, and then runs six review checks against every one. The result is a map of which check catches which bug, how long each check takes, and the one defect that only a human with the spec could find.

The numbers below came from this environment:

Measured 2026-11-03
Windows 11 Pro 25H2 (build 26200), AMD Ryzen 5 8600G (6C/12T), 16 GB RAM
Dart SDK 3.9.0 (stable), package:lints 6.1.0, package:test 1.31.0
One clean package, fourteen single-defect variants, six checks per variant

Dart was chosen because its analyzer, lint sets and test runner ship with the SDK, so every check here runs from one install. The defect classes, however, are not Dart-specific. Each one has a direct equivalent in TypeScript, Python, Java and Go, and the closing sections map the checks across.

What Goes Wrong in AI-Written Code?

AI-written code mostly fails in ways that compile. Studies of LLM output find misread requirements, missing corner cases, calls to methods or packages that do not exist, insecure choices such as string-built SQL, and weak error handling. Because the code looks idiomatic and arrives with passing tests, review has to target those classes on purpose rather than read for style.

That summary rests on four sources worth reading in full. The first is Tambon et al.’s empirical study of bugs in LLM-generated code, which sorted 333 buggy samples into ten patterns. Misinterpretation, missing corner case and hallucinated object are the ones this bench reuses. Next, Spracklen et al., presented at USENIX Security 2025, generated 576,000 code samples across 16 models and found an average hallucinated-package rate of at least 5.2 percent for commercial models and 21.7 percent for open-source ones.

On the security side, Veracode’s 2025 GenAI Code Security Report tested more than 100 models on 80 tasks and found that 45 percent of the generated code samples introduced a security flaw. Finally, CodeRabbit’s December 2025 report compared 470 open-source pull requests and counted about 1.7 times as many review findings in AI-co-authored ones, with error-handling gaps nearly twice as common.

None of these studies says AI code is worse than a hurried human’s code in every way. However, they agree on where the defects cluster, and that is exactly the information a review process needs.

The Bench: Fourteen Seeded Defects, Six Review Checks

The library is a slice of an order system: cart totals with a coupon and tax, top-SKU reporting, 1-based pagination, email normalization, an order repository over a SQL client interface, and a checkout service that charges a payment gateway with retries. Each function carries a short spec comment, and the clean version passes every check. That mirrors reviewing AI-written code in practice, where the reviewer holds a spec and the code may or may not match it.

Each variant then changes exactly one thing. A small injector applies one exact string substitution to a copy of the clean package, so every variant differs from the baseline by one defect and nothing else. Here is one entry and the part that applies it:

// tool/inject.dart (excerpt): one defect is one exact substitution.
import 'dart:io';

typedef Defect = ({String id, String kind, String file, String from, String to});

const defects = <Defect>[
  (
    id: 'D08', kind: 'broken access control: ownership check dropped',
    file: 'lib/src/orders.dart',
    from: r'return order != null && order.ownerId == requesterId ? order : null;',
    to: r'return order;',
  ),
  // ... thirteen more
];

void main(List<String> args) {
  final defect = defects.singleWhere((d) => d.id == args.single);
  final file = File(defect.file);
  final source = file.readAsStringSync().replaceAll('\r\n', '\n');
  if (!source.contains(defect.from)) {
    stderr.writeln('${defect.id}: pattern not found in ${defect.file}');
    exit(2); // a silent no-op would report a "missed" defect that was never injected
  }
  file.writeAsStringSync(source.replaceFirst(defect.from, defect.to));
}

The exit on a missing pattern matters more than it looks. Without it, a typo in a pattern would leave a variant identical to the clean code, and every check would “miss” a defect that does not exist.

The Fourteen Defects and Where They Come From

IDDefectStudy category it reproduces
D01Calls firstWhereOrNull from package:collection with no importHallucinated object (Tambon)
D02reduce() on an empty cart instead of foldMissing corner case (Tambon)
D03Coupon taken off after tax instead of beforeMisinterpretation (Tambon)
D041-based page number used as 0-based offsetMissing corner case, off by one
D05Money rounding via double division and toInt() truncationNumeric correctness
D06Email lowercased but not trimmedMissing corner case
D07User input interpolated into a SQL stringCWE-89 injection (Veracode)
D08Ownership check dropped from an order lookupBroken access control (Veracode, OWASP A01)
D09save() future not awaitedError handling (CodeRabbit)
D10Failed charge caught and saved as pendingError handling, swallowed exception
D11Retry loop with no limit and no backoffError handling, resilience
D12Full card token written to the logSensitive data exposure (CWE-532)
D13sort() on the caller’s list instead of a copyNon-prompted side effect
D14Date range excludes its last dayBoundary error

These defects were seeded by hand, not sampled from a model’s output. Consequently, the bench answers “can this check catch this class of bug”, not “how often does a model produce it”. The frequency question belongs to the studies above.

The Six Checks

Each check is something a team can add to a pipeline in an afternoon:

  1. analyze-recommended: dart analyze --fatal-infos with package:lints/recommended.yaml, which is what dart create generates.
  2. analyze-strict: the same analyzer with strict casts, strict inference, strict raw types, and the async and error-handling lints that are off by default.
  3. test-happy: one typical input per function, asserting the spec’d output. This is the shape of the tests that usually arrive in the same diff as generated code.
  4. test-edge: one test per sentence of each spec comment, written from the spec rather than from the implementation.
  5. test-property: 500 seeded random cases per function, checked against invariants or a slow reference model.
  6. greps: four project-specific regex rules over lib/.

The strict analyzer configuration is short:

# analysis_options.yaml: recommended set plus strict types and off-by-default rules
include: package:lints/recommended.yaml

analyzer:
  language:
    strict-casts: true
    strict-inference: true
    strict-raw-types: true

linter:
  rules:
    - unawaited_futures
    - discarded_futures
    - avoid_catches_without_on_clauses
    - use_rethrow_when_possible
    - only_throw_errors
    - avoid_dynamic_calls
    - cancel_subscriptions
    - close_sinks
    - avoid_print
    - prefer_final_locals

The grep rules are cruder, on purpose. Each one encodes a mistake this codebase must never contain, and none of them needs a parser:

#!/usr/bin/env bash
# Project-specific review greps over lib/. Prints one line per hit, exits 1 if any rule hits.
cd "$(dirname "$0")/.." || exit 2
hits=0
rule() {  # $1 = rule name, $2 = extended regex
  local out
  out=$(grep -rnE "$2" lib --include='*.dart') && { echo "$out" | sed "s/^/[$1] /"; hits=1; }
}
rule sql-interpolation   '(SELECT|INSERT|UPDATE|DELETE)[^;]*\$\{?[a-zA-Z_]'
rule secret-in-log       '(log|print)\(.*\$\{?[a-zA-Z_.]*([Tt]oken|[Pp]assword|[Ss]ecret)\b'
rule catch-without-on    '^\s*\}? ?catch \('
rule unbounded-loop      'while \(true\)'
exit $hits

What a Spec Test and a Property Test Look Like

The edge tests read like the spec. For example, the order lookup’s spec says another user gets null, the same answer as for a missing order, so the test asserts exactly that:

import 'package:review_bench/review_bench.dart';
import 'package:test/test.dart';

import 'support/fakes.dart';

void main() {
  test('another user cannot read the order', () async {
    final h = Harness([null]); // payment gateway that succeeds once
    await h.place(); // order o-1, owned by u-1
    expect(await h.repo.findForUser('o-1', 'u-2'), isNull);
  });
}

Property tests, in contrast, do not name a case at all. Instead, they state something that must hold for every input and then throw a few hundred seeded inputs at it. Pagination has a clean invariant: joining every page in order gives back the original list.

import 'dart:math';

import 'package:review_bench/review_bench.dart';
import 'package:test/test.dart';

void main() {
  final random = Random(42); // seeded, so a failure reproduces exactly

  test('concatenating every page gives back the list', () {
    for (var i = 0; i < 500; i++) {
      final items = List.generate(random.nextInt(60), (i) => i);
      final size = 1 + random.nextInt(12);
      final pages = (items.length / size).ceil();
      final joined = [for (var p = 1; p <= pages; p++) ...pageOf(items, p, size)];
      expect(joined, items, reason: 'case $i: n=${items.length} size=$size');
    }
  });
}

That one test catches an off-by-one anywhere in the offset math, whether or not anyone thought about the last page. As a result, property tests are the cheapest way to cover the “missing corner case” category, because nobody has to list the corners in advance.

How the Matrix Was Run

A driver script copies the clean package, injects one defect, and runs every check, printing one line per variant and check. A check “catches” a defect when it passes on the clean package and fails on the variant:

#!/usr/bin/env bash
# tool/matrix.sh (excerpt). Usage: tool/matrix.sh matrix | time <runs>
ROOT=$(cd "$(dirname "$0")/.." && pwd)
WORK="$ROOT/../review-work"

run_check() {  # $1 = check name; returns 1 when the check flags something
  case "$1" in
    analyze-recommended|analyze-strict)
      cp "analysis/${1#analyze-}.yaml" analysis_options.yaml
      dart analyze --fatal-infos ;;
    test-*)
      timeout 120 dart test "test/${1#test-}_test.dart" ;;
    greps)
      bash tool/review_greps.sh ;;
  esac
}

variant() {  # $1 = clean | D01..D14
  rm -rf "$WORK/$1" && mkdir -p "$WORK/$1"
  cp -r "$ROOT"/{lib,test,tool,analysis,pubspec.yaml,pubspec.lock,.dart_tool} "$WORK/$1/"
  cd "$WORK/$1" || exit 2
  [ "$1" = clean ] || dart run tool/inject.dart "$1" || exit 2
  for c in analyze-recommended analyze-strict test-happy test-edge test-property greps; do
    if run_check "$c" > /dev/null 2>&1; then v=pass; else v=CAUGHT; fi
    printf '%s\t%s\t%s\n' "$1" "$c" "$v"
  done
}

The full script also prints a one-line detail per result, such as the lint name or the test count, which appears in the raw output below. The matrix ran once with tool/matrix.sh matrix. Pass or fail is deterministic here, since the property tests use a fixed seed, so a single run is the result. Timings ran separately with tool/matrix.sh time 5, and the reported value is the median of five runs.

Which Checks Caught Which Bugs, Measured

Here is the raw output of the matrix run, unedited:

clean	analyze-recommended	pass	
clean	analyze-strict	pass	
clean	test-happy	pass	+8: All tests passed
clean	test-edge	pass	+17: All tests passed
clean	test-property	pass	+5: All tests passed
clean	greps	pass	
D01	analyze-recommended	CAUGHT	error lib\src\users.dart:22:16 undefined_method
D01	analyze-strict	CAUGHT	error lib\src\users.dart:22:16 undefined_method;error lib\src\users.dart:22:58 argument_type_not_assignable;warning lib\src\users.dart:22:34 inference_failure_on_untyped_parameter;info lib\src\users.dart:22:58 avoid_dynamic_calls
D01	test-happy	CAUGHT	+0 -1: Some tests failed
D01	test-edge	CAUGHT	+0 -1: Some tests failed
D01	test-property	CAUGHT	+0 -1: Some tests failed
D01	greps	pass	
D02	analyze-recommended	pass	
D02	analyze-strict	pass	
D02	test-happy	pass	+8: All tests passed
D02	test-edge	CAUGHT	+16 -1: Some tests failed
D02	test-property	CAUGHT	+4 -1: Some tests failed
D02	greps	pass	
D03	analyze-recommended	pass	
D03	analyze-strict	pass	
D03	test-happy	CAUGHT	+7 -1: Some tests failed
D03	test-edge	pass	+17: All tests passed
D03	test-property	CAUGHT	+4 -1: Some tests failed
D03	greps	pass	
D04	analyze-recommended	pass	
D04	analyze-strict	pass	
D04	test-happy	CAUGHT	+7 -1: Some tests failed
D04	test-edge	CAUGHT	+16 -1: Some tests failed
D04	test-property	CAUGHT	+4 -1: Some tests failed
D04	greps	pass	
D05	analyze-recommended	pass	
D05	analyze-strict	pass	
D05	test-happy	pass	+8: All tests passed
D05	test-edge	CAUGHT	+16 -1: Some tests failed
D05	test-property	CAUGHT	+4 -1: Some tests failed
D05	greps	pass	
D06	analyze-recommended	pass	
D06	analyze-strict	pass	
D06	test-happy	pass	+8: All tests passed
D06	test-edge	CAUGHT	+15 -2: Some tests failed
D06	test-property	CAUGHT	+4 -1: Some tests failed
D06	greps	pass	
D07	analyze-recommended	pass	
D07	analyze-strict	pass	
D07	test-happy	pass	+8: All tests passed
D07	test-edge	CAUGHT	+16 -1: Some tests failed
D07	test-property	pass	+5: All tests passed
D07	greps	CAUGHT	[sql-interpolation] lib/src/orders.dart:55
D08	analyze-recommended	pass	
D08	analyze-strict	pass	
D08	test-happy	pass	+8: All tests passed
D08	test-edge	CAUGHT	+16 -1: Some tests failed
D08	test-property	pass	+5: All tests passed
D08	greps	pass	
D09	analyze-recommended	pass	
D09	analyze-strict	CAUGHT	info lib\src\checkout.dart:70:11 unawaited_futures
D09	test-happy	CAUGHT	+6 -2: Some tests failed
D09	test-edge	CAUGHT	+16 -1: Some tests failed
D09	test-property	pass	+5: All tests passed
D09	greps	pass	
D10	analyze-recommended	pass	
D10	analyze-strict	CAUGHT	info lib\src\checkout.dart:64:7 avoid_catches_without_on_clauses
D10	test-happy	pass	+8: All tests passed
D10	test-edge	CAUGHT	+15 -2: Some tests failed
D10	test-property	pass	+5: All tests passed
D10	greps	CAUGHT	[catch-without-on] lib/src/checkout.dart:64
D11	analyze-recommended	CAUGHT	warning lib\src\checkout.dart:47:15 unused_field
D11	analyze-strict	CAUGHT	warning lib\src\checkout.dart:47:15 unused_field
D11	test-happy	pass	+8: All tests passed
D11	test-edge	CAUGHT	+15 -2: Some tests failed
D11	test-property	pass	+5: All tests passed
D11	greps	CAUGHT	[unbounded-loop] lib/src/checkout.dart:75
D12	analyze-recommended	CAUGHT	warning lib\src\checkout.dart:86:17 unused_element
D12	analyze-strict	CAUGHT	warning lib\src\checkout.dart:86:17 unused_element
D12	test-happy	pass	+8: All tests passed
D12	test-edge	CAUGHT	+16 -1: Some tests failed
D12	test-property	pass	+5: All tests passed
D12	greps	CAUGHT	[secret-in-log] lib/src/checkout.dart:60
D13	analyze-recommended	pass	
D13	analyze-strict	pass	
D13	test-happy	CAUGHT	+7 -1: Some tests failed
D13	test-edge	CAUGHT	+16 -1: Some tests failed
D13	test-property	CAUGHT	+4 -1: Some tests failed
D13	greps	pass	
D14	analyze-recommended	pass	
D14	analyze-strict	pass	
D14	test-happy	pass	+8: All tests passed
D14	test-edge	CAUGHT	+16 -1: Some tests failed
D14	test-property	CAUGHT	+4 -1: Some tests failed
D14	greps	pass	

The clean rows matter as much as the defect rows. Every check passed on the clean package, so every CAUGHT is a real catch and none is noise from a check that was already failing.

The Catch Matrix at a Glance

DefectRecommended lintsStrict analyzerHappy testsSpec testsProperty testsGreps
D01 hallucinated APIyesyesyesyesyesno
D02 empty cartnononoyesyesno
D03 coupon after taxnonoyesnoyesno
D04 off by onenonoyesyesyesno
D05 truncated roundingnononoyesyesno
D06 untrimmed emailnononoyesyesno
D07 SQL injectionnononoyesnoyes
D08 missing ownership checknononoyesnono
D09 unawaited savenoyesyesyesnono
D10 swallowed charge errornoyesnoyesnoyes
D11 unbounded retryincidentalincidentalnoyesnoyes
D12 token in logincidentalincidentalnoyesnoyes
D13 in-place sortnonoincidentalyesyesno
D14 last day excludednononoyesyesno
Caught3 of 145 of 145 of 1413 of 148 of 144 of 14

“Incidental” marks catches that happened for a reason unrelated to the defect class, and the next section explains each one. Counting only deliberate catches, the recommended lint set caught one defect, and it did so through the compiler’s type check, not through a lint.

What Each Check Costs

Every check is cheap enough to run on every push. Here is the timing output on the clean package:

analyze-recommended  median=1053ms runs=[1049 1053 1053 1068 1125]
analyze-strict       median=1035ms runs=[1023 1033 1035 1052 1087]
test-happy           median=1436ms runs=[1425 1435 1436 1437 1883]
test-edge            median=1448ms runs=[1437 1448 1448 1452 1452]
test-property        median=1462ms runs=[1444 1451 1462 1468 1468]
greps                median=143ms runs=[135 138 143 151 152]

Notably, the strict analyzer ran no slower than the recommended set, so the extra rules cost nothing at check time. Similarly, 2,500 property cases added only 26 ms over the eight happy tests, because nearly all of the 1.4 seconds is the test runner compiling and starting up. In other words, the cost of these checks is writing them, not running them.

Results That Cut Against Expectations

Three results in the matrix do not match the usual advice about reviewing AI-written code, and each one changes how the checks should be used.

Spec Tests Missed the Misread Spec

The spec-derived edge tests caught 13 of 14 defects, which looks like a clear winner. However, the one they missed is the most AI-shaped bug in the set. D03 applies the coupon after tax, a classic misinterpretation, and the edge suite passed it with +17: All tests passed.

The reason is mundane. The edge suite tests the coupon floor and the tax rounding separately, and the only coupon test uses a cart smaller than the coupon, where both orders of operations give 0. No test combined a coupon with tax on a cart large enough to show the difference. Meanwhile, the single happy-path test did exactly that, by accident of picking a realistic input:

00:00 +0 -1: cart total applies the coupon, then tax [E]
  Expected: <3000>
    Actual: <3100>

The lesson is not that happy tests beat edge tests. Rather, tests written one spec sentence at a time cover each rule in isolation and miss how rules interact. The property test caught D03 because its reference model computed the whole formula at once. Therefore, any function with two or more rules that combine needs at least one test, or one reference model, that exercises them together.

The Analyzer Caught Two Bugs It Has No Rule For

The recommended lint set has no rule about retry limits or logging secrets. Still, it flagged both D11 and D12, with these outputs:

warning - lib\src\checkout.dart:47:15 - The value of the field '_sleep' isn't used. Try removing the field, or using it. - unused_field

The unbounded retry loop no longer calls _sleep, so the backoff field became dead. Likewise, logging the raw token removed the only call to _last4, the helper that masked it. In both cases, the bug was the removal of a safeguard, and the safeguard left a dead field or function behind.

This is incidental, since a version of the same bug that kept the helper in use somewhere else would pass the analyzer. Even so, it suggests a review habit worth keeping: when an AI-written diff makes a helper unused, ask what that helper was protecting. Agents regenerate whole functions more often than they edit lines, and dropping a guard while keeping everything else plausible is exactly what that produces.

The Happy Test Caught the Side Effect by Luck

D13 sorts the caller’s list in place. The happy test caught it, but only because its input was a const list:

00:00 +1 -1: top SKUs come back by quantity [E]
  Unsupported operation: Cannot modify an unmodifiable list

With a regular list literal, the same test would have passed, since the returned SKUs are correct and nothing checks the input afterwards. In contrast, the spec test and the property test both assert that the input is unchanged, so they caught it on purpose. Accidental catches like this one make a suite look stronger than it is, which is why the table marks them separately.

The Bug Only a Spec Could Catch

D08 drops the ownership check from findForUser, so any logged-in user can read any order by ID. This is broken access control, number one on the OWASP Top 10, and it is the only defect in the set that exactly one check caught:

00:00 +10 -1: orders another user cannot read the order [E]
  Expected: null
    Actual: <Instance of 'Order'>

No analyzer rule can catch it, because the broken code is simpler and cleaner than the correct code. A grep cannot catch it either, because there is no bad pattern to match, only a missing condition. A property test would need an invariant like “a user never sees another user’s order”, which is exactly the spec, written down. Happy-path tests, finally, always run as the owner.

In other words, authorization bugs are invisible to every check that does not know who is allowed to do what. That knowledge lives in the spec and in the reviewer’s head, not in the code. Consequently, reviewing AI-written code that touches data access has to include reading every query for its ownership or tenant condition, and at least one test per endpoint that calls it as the wrong user. The API security checklist for production applications covers the broader set of access checks worth testing this way.

How to Review AI-Written Code: A Checking Order

For reviewing AI-written code, the matrix suggests an order that puts the cheapest, most mechanical checks first, so human attention goes where no tool reaches:

  1. Turn on the strict analyzer and fail the build on infos. It caught the hallucinated API, the unawaited future and the catch-all, in about one second.
  2. Add project greps for the mistakes that must never ship. String-built SQL, secrets in logs, bare catches and while (true) each take one line.
  3. Check every new dependency before reading the code. Confirm the package exists, who publishes it and how old it is, since hallucinated names are what slopsquatting attacks register.
  4. Read the tests before the implementation. List what the spec says and mark which sentences have a test. Untested sentences are where to look first.
  5. Add one property test or reference model for pure logic. It caught eight of fourteen defects with five test functions.
  6. Look for safeguards that became dead code. An unused helper, field or parameter in a regenerated function often marks a removed check.
  7. Trace authorization and data flow by hand. Every query needs an owner or tenant condition, and every endpoint needs a wrong-user test.

Steps one and two belong in CI, not in a reviewer’s memory. Running them as agent hooks also works well, so the agent sees the failure before a human does, as described in automating linting and tests with Claude Code hooks. Teams that are not on an agent workflow can get the same effect from git hooks for code quality checks.

Mapping These Checks to Other Stacks

Nothing here depends on Dart. Every stack used for reviewing AI-written code has an equivalent for each check, although the defaults differ:

CheckTypeScriptPythonJava / Kotlin
Hallucinated APItsc --noEmitmypy --strict or pyrightThe compiler
Unawaited future@typescript-eslint/no-floating-promisesRUF006 in Ruff, asyncio debug modeError Prone FutureReturnValueIgnored
Catch-all handlerESLint no-empty, a Semgrep ruleRuff BLE001, S110Error Prone, SpotBugs REC_CATCH_EXCEPTION
SQL interpolationSemgrep rulesBandit B608, Ruff S608SpotBugs SQL_NONCONSTANT_STRING_PASSED_TO_EXECUTE, Semgrep
Property testsfast-checkHypothesisjqwik

The catch is that most of these rules are not on by default. In particular, no-floating-promises needs typed linting, and Ruff’s security rules sit in an opt-in group. Consequently, a project that never changed its lint configuration is probably running the equivalent of the “recommended” column, which caught one defect out of fourteen here.

Reading the Diff: What Still Needs a Human

Tools narrow the search, but four questions only a reviewer can answer, and they line up with the defects that needed a spec:

  • Does the code do what was asked, or what was plausible? D03 is plausible pricing logic. It is also wrong for this spec, and only someone who knows the spec can tell.
  • What did the diff remove? Agents rewrite whole functions, so a dropped condition hides inside a block that looks new. Reading the removed lines in the diff, not only the added ones, puts D08 and D11 in plain view.
  • Who is allowed to call this? Access rules are business rules, and the code cannot state them by itself.
  • What happens on the second call, the empty input and the failure path? These are the paths that generated tests skip, because the prompt rarely mentions them.

For the human side of review, including how to word feedback on a pull request from a teammate who used an agent, see code review best practices for giving and receiving feedback.

Should an AI Review the AI’s Code?

It can help, as long as it is not the only reviewer. AI review tools are good at spotting known patterns like D07, D10 and D12, which overlap heavily with what the greps and the strict analyzer already catch. On the other hand, they share the author’s blind spot on D03 and D08, because neither the author model nor the reviewer model has the spec unless someone writes it down. The comparison of AI-powered code review tools covers what the current tools check. Treat them as another layer in the sieve, not as the person who signs off.

A Real Scenario: The Feature That Passed Review

Consider a small product team that started using a coding agent for backend work over the past quarter. Pull requests now arrive faster than before, each with green tests, and reviews have shrunk to a skim of the diff and a check that CI passed. A refund endpoint ships that looks up orders by ID, and its tests cover the happy path, a missing order and a refund that exceeds the total.

A few weeks later, a support ticket shows that one customer could see another customer’s refund status by changing the ID in the URL. The ownership check that every other endpoint has was never in the prompt, so the agent never wrote it, and the tests the agent wrote alongside the code ran as the owner every time. Nothing in CI could have failed, because nothing in CI knew the rule.

The fix takes the team an afternoon: a shared wrong user gets 404 test helper, a grep that flags any repository query without an owner_id or tenant_id condition, and one line in the pull request template asking which access rule the change touches. The trade-off is real, since the grep has false positives on admin queries that need an allow comment. Still, that is cheaper than a second incident.

When to Use the Full Review Stack

  • The change touches money, authorization, personal data or anything that writes to a database
  • An agent generated the implementation and its tests in the same session, from the same prompt
  • The function combines two or more business rules, such as discounts, tax and rounding
  • The diff rewrites an existing function rather than adding a new one, so a guard could have been dropped
  • A new dependency appears that nobody on the team has used before

When NOT to Use the Full Review Stack

  • Throwaway scripts and prototypes that will never see production data
  • Generated code with its own verification, such as a client produced from an OpenAPI schema
  • Pure UI layout changes, where a visual check catches more than any test would
  • Mechanical refactors that a compiler and the existing suite already pin down, such as renames
  • Spikes whose only purpose is to answer a question and then be deleted

Common Mistakes When Reviewing AI-Written Code

  • Accepting the tests in the same diff as evidence, when they were written from the implementation they test
  • Leaving the lint configuration at the defaults, which in this bench caught one defect out of fourteen
  • Writing one test per rule and never one that combines rules, which is how D03 passed thirteen other checks
  • Reading only the added lines of a diff and missing the condition that disappeared
  • Counting accidental catches, such as a const input, as coverage
  • Installing a suggested package without checking that it exists and who publishes it
  • Asking a second model to review the first and treating its silence as approval

What This Measurement Does Not Cover

The fourteen defects were seeded by hand to represent published categories. They were not sampled from model output, so the bench says nothing about how often each class appears. Moreover, the same author wrote the defects and the checks, which favors the checks. In particular, the greps were written knowing the codebase, and an independent reviewer’s rules would catch less on unfamiliar code. The edge suite’s one miss happened despite that advantage, which is part of why it is worth reporting.

Each variant also contains exactly one defect. Real diffs can carry several, and one defect can mask another, for example when a compile error stops every test from running, as D01 did. The bench is 182 non-blank lines of library code and 265 of tests, so it cannot show how review attention degrades on a 2,000-line diff. Finally, nothing here measures reviewer time, false-positive rates on a large codebase, or the speed gains of using an agent at all. Those depend on a team’s own history, not on a bench.

Conclusion

Reviewing AI-written code works when each check targets a defect class instead of reading for style. On this bench, the strict analyzer and four greps caught six of fourteen seeded defects in about 1.2 seconds, while property tests added seven more. Spec tests caught thirteen but missed the misread pricing rule, and the missing ownership check fell to the spec tests alone.

Start this week with the two cheapest changes: turn on the strict analyzer rules for your stack and fail the build on them, then write one wrong-user test for every endpoint that reads by ID. Next, look at the tests an agent writes alongside its code, which is the subject of generating unit tests with large language models on this site.