Back to the blog

Blog / Engineering practice

When AI writes more code,
how should you review it?

If every change needs your line-by-line attention, you can become the limit on what your agents deliver. Decide which checks to automate and where your judgment is still needed.

Tim Elliott · 15 September 2026

Before hiring an engineer, be honest about how your team works with AI. Do you expect someone to read every line an agent produces? Which decisions need another person’s approval? What can they ship once the checks pass?

Those expectations affect who will enjoy the role and what you should assess. A candidate who expects to delegate most implementation may be frustrated by a process that requires them to rewrite every change themselves.

I think teams using agents should reconsider where they spend their review time. Some checks can run on every change without waiting for a person. That gives the reviewer more time to understand the behaviour, the assumptions and the consequences of getting it wrong.

Decide what needs a person’s attention.

A small internal prototype and a payment service need different levels of scrutiny. Team size alone doesn’t decide the risk: a two-person company can still hold sensitive customer data.

I’d automate repeatable checks such as formatting, type errors and agreed structural limits. Then I’d spend review time on whether the change solves the right problem, respects access boundaries and behaves sensibly when something fails.

Security-sensitive changes, destructive migrations and unfamiliar dependencies may still need a careful reading. Keeping changes small makes that easier. Passing CI means the checks you wrote passed; it doesn’t establish that you wrote every check you needed.

The person directing the agent should be able to explain why the result is acceptable. That is part of the ownership I’d look for when hiring.

Make the basic rules executable.

For a TypeScript project, start with formatting, linting, type checking and tests of the behaviour that matters. Put the commands in the repository so a developer or agent can run the same checks locally and in CI.

Merge these options into the project’s existing tsconfig.json. Strict mode enables stronger type checks; noEmit lets the checker run without producing JavaScript files. Retain the framework’s other required settings.

{
  "compilerOptions": {
    "strict": true,
    "noEmit": true
  }
}

Type checking won’t tell you that one customer can read another customer’s data if both IDs have the same type. Add tests that exercise the access rule. The same principle applies in Python, Rust or another stack: use its compiler, checker and test tools to enforce the conditions your application relies on.

Put a limit on hard-to-follow functions.

Biome’s noExcessiveCognitiveComplexity rule flags functions whose control flow exceeds a chosen complexity score. Nested branches and loops can make a function harder to follow. This is a cognitive-complexity measure, rather than a count of every possible execution path.

The rule needs enabling explicitly. Here is a biome.json example with a threshold of 15 and failures treated as errors:

{
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "complexity": {
        "noExcessiveCognitiveComplexity": {
          "level": "error",
          "options": { "maxAllowedComplexity": 15 }
        }
      }
    }
  }
}

Choose a threshold that makes sense for your codebase. A low score doesn’t prove a function is well designed, and hiding complexity in poorly named helpers won’t help the next person understand it.

Biome has no automatic fix for this rule. It reports the problem. An agent with access to that feedback can refactor the function and rerun the checks.

Stop large files getting larger.

One pattern I’ve noticed when using coding agents is that a file keeps growing as new features arrive. Each addition looks convenient in isolation. Eventually, the file is difficult to work with.

A file-size ratchet has been useful for this. Choose a normal limit, then allow existing files above that limit to stay at their current size or shrink. They cannot grow. Once a reduction is merged, that smaller size becomes the new ceiling.

An example with a 300-line limit

A 500-line file can stay at 500 or shrink to 460. After that change is merged, the next change cannot take it back to 500.

The script below compares tracked JavaScript and TypeScript files under src/ with the pull request’s base commit. New files have a 300-line limit. Save it as scripts/check-file-growth.mjs.

File-size ratchet script
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';

const cap = 300;
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' });
const paths = (output) => output.split('\0').filter(Boolean);
const lines = (text) => text === '' ? 0 : text.replace(/\r?\n$/, '').split('\n').length;

if (!process.env.BASE_REF) {
  throw new Error('Set BASE_REF to the PR base commit or a fetched base branch.');
}

// Resolve once; fail if the baseline is unavailable rather than silently reset it.
const base = git('rev-parse', '--verify', `${process.env.BASE_REF}^{commit}`).trim();
const previousFiles = new Set(paths(git('ls-tree', '-r', '--name-only', '-z', base, '--', 'src')));
const currentFiles = paths(git('ls-files', '-z', '--', 'src'));
let failures = 0;

for (const file of currentFiles) {
  if (!/\.[cm]?[jt]sx?$/.test(file) || !existsSync(file)) continue;
  const previous = previousFiles.has(file) ? lines(git('show', `${base}:${file}`)) : 0;
  const allowed = Math.max(cap, previous);
  const actual = lines(readFileSync(file, 'utf8'));

  if (actual > allowed) {
    console.error(`${file}: ${actual} lines; limit ${allowed}. Refactor before adding more.`);
    failures += 1;
  }
}

if (failures > 0) process.exitCode = 1;
else console.log('File growth check passed.');

For a local run, fetch the base branch and set BASE_REF=origin/main before running the script. Stage new files so Git includes them. Renamed files are treated as new paths. Adapt the scope for a monorepo, generated code or other file types.

The point is to prompt a useful refactor before adding more. Don’t encourage an agent to squeeze statements onto fewer lines or split files arbitrarily to satisfy a number. Keep the formatter enabled and review whether the new boundaries make sense.

You can ratchet warnings, too.

An existing codebase may have more lint warnings and complex functions than you can reasonably fix in one change. Switching every warning to an error can leave unrelated work blocked behind a large cleanup.

Record the existing problems as a baseline, then check whether a change makes them worse. As you fix them, reduce the allowance. Use the same scope locally and in CI so both measure the same files.

A total count is only part of the picture: removing one warning and adding another can leave it unchanged. Keep enough detail to spot new problems and worsening code. Make a lower baseline part of the refactor, and require a deliberate review before raising an allowance.

The check should tell an agent what failed and where. Give it a problem it can investigate, rather than just a red build to work around.

Make the checks part of every pull request.

This example combines those checks in a GitHub Action. It assumes Biome and TypeScript are installed as development dependencies, the lockfile is committed, and npm test runs your tests once and exits.

Save it as .github/workflows/quality.yml. The full checkout history lets the ratchet read the base commit. Biome’s CI command checks the files without rewriting them.

name: Code guardrails

on: pull_request

permissions:
  contents: read

jobs:
  quality:
    name: Quality gate
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          persist-credentials: false
      - uses: actions/setup-node@v7
        with:
          node-version: '24'
          cache: npm
      - run: npm ci
      - name: Lint and format
        run: npx --no-install biome ci .
      - name: Check types
        run: npx --no-install tsc --noEmit
      - name: Check file growth
        env:
          BASE_REF: ${{ github.event.pull_request.base.sha }}
        run: node scripts/check-file-growth.mjs
      - name: Test behaviour
        run: npm test

Then make Quality gate a required status check for merging. Require the branch to be up to date so the result covers integration with current work. Teams using a merge queue should also configure the workflow for the queue’s merge-group event and baseline.

Protect the definitions of these checks too. Require an owner’s review for changes to the workflow, lint configuration, ratchet and tests. An agent shouldn’t be able to resolve a failure by weakening the rule or deleting the test.

Configure the agent to read a failed check, investigate it, make a justified change and run the checks again. CI alone doesn’t create that loop or automatically refactor the code. Give the agent the commands, access to the results and a clear stopping point for failures it cannot resolve.

Review the combined work periodically.

Several individually reasonable changes can leave a confusing result. Two agents might introduce overlapping abstractions or make different assumptions about the same interface. A scheduled review can look across the changes that have landed together.

You can ask an agent to investigate those problems and propose small follow-up changes. Have it explain what each change preserves and how it checked that behaviour. Run those proposals through the normal checks and approvals; keep production release decisions separate from a cleanup task.

Keep infrastructure changes in code as well.

I’d aim to describe every repeatable infrastructure change in code: environments, permissions, storage and deployment configuration. Review the proposed change through the same process as the application. Document the exceptions where a provider doesn’t support it.

You also need to notice when the running environment changes outside that process. A Terraform refresh-only plan can surface differences between managed resources and recorded state. It checks without applying changes.

A normal plan answers a broader question: what would change to bring the environment into line with the configuration? OpenTofu supports both approaches. Review expected changes on an infrastructure pull request; investigate an unexpected non-empty plan against the configuration you believe is already deployed.

The example below runs on weekday mornings and can be started manually. It assumes an AWS project in infra/, an existing remote state backend and committed provider lockfile. Set repository variables for AWS_REGION, AWS_DRIFT_ROLE_ARN and a pinned TERRAFORM_VERSION. Configure any required Terraform inputs and select the correct workspace for your environment.

Use an AWS OIDC role restricted to this repository’s protected default branch. Give it access to read the relevant resources and state, plus the permissions needed for backend locking. It doesn’t need permission to apply infrastructure changes. Adapt authentication for other providers.

Example scheduled drift-check workflow
name: Infrastructure drift

on:
  schedule:
    - cron: '17 7 * * 1-5'
  workflow_dispatch:

permissions:
  contents: read
  id-token: write

jobs:
  drift:
    # Manual runs must also use the protected default branch.
    if: github.ref_name == github.event.repository.default_branch
    runs-on: ubuntu-latest
    timeout-minutes: 15
    concurrency:
      group: infrastructure-drift
      cancel-in-progress: false
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_DRIFT_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: ${{ vars.TERRAFORM_VERSION }}
          terraform_wrapper: false
      - run: terraform init -input=false -lockfile=readonly
      - name: Look for changes outside Terraform
        shell: bash
        run: |
          result=0
          terraform plan -refresh-only -detailed-exitcode \
            -input=false -no-color -lock-timeout=60s || result=$?
          case "$result" in
            0) echo 'No drift reported.' ;;
            2) echo '::error::Drift detected. Investigate before applying anything.'; exit 1 ;;
            *) echo '::error::Drift check could not complete.'; exit 1 ;;
          esac

The plan returns 0 for no changes, 2 when it finds changes and 1 for an error. This workflow makes detected drift or a failed check visible as a failed run; make sure the team responsible receives those notifications.

Investigate before deciding whether to update the configuration or reverse an outside change. This isn’t an inventory of unmanaged resources, and it only sees differences Terraform and its providers expose. Keep infrastructure logs private. The template needs your environment’s configuration; it hasn’t been run against your account.

Be careful about what the report exposes. Machine-readable plan output can contain sensitive values. Keep raw plans and diagnostic files out of shared summaries and uploaded artifacts. Publish a short status and only details you have checked are safe to share, including when the command fails.

The workflow examples use major action versions for readability. Pin actions to reviewed commit SHAs in production and maintain those pins alongside your dependencies.

Explain this workflow to the person you’re hiring.

If this is how you want to work, put it in the assessment brief. Give candidates the checks and ask them to extend the application within those constraints. Explore what they trusted the agent to do, which checks they added and where they chose to inspect the code themselves.

There’s a useful interview question in any failed check: “What did this catch, and what could still be wrong even though it now passes?”

You’ll learn more from their explanation than from asking whether they believe in code review. Build the exercise around the way your team actually works, and be clear about which decisions they will own.

Research and further reading

Technical references checked September 2026. The thresholds are examples to adapt, not research-established measures of code quality.

  1. TypeScript: strict and noEmit.
  2. Biome: cognitive complexity and CI integration.
  3. GitHub: protected branches and required checks.
  4. Terraform: plan modes and exit codes.
  5. OpenTofu: plan modes and sensitive values in machine-readable output.
  6. Official action documentation: checkout, setup-node, setup-terraform and AWS credentials and OIDC.