Skip to content

Code quality gates for agent-written code

A code quality gate for agent-written code is one set of deterministic checks (format, lint, type check, tests and static analysis on new code) that runs three times: after each agent edit, before the agent may report “done”, and as a required CI job that blocks the merge. AI review stays beside the gate as advice.

Your team’s agents open fifteen pull requests a day. Lint runs only in CI, twenty minutes after the push, so the agent reads the failure a round later and “fixes” it with // eslint-disable-next-line. The reviewer, who cannot read all fifteen diffs, approves the green ones. Six weeks later the codebase has 140 new suppressions and nobody decided to add any of them.

This page is for developers who wire the checks and tech leads who own them. It covers the mechanical half of verification: whether the code is well-formed. Whether the code does the right thing is the job of the tests and the oracle-strength measures.

What you’ll walk away with from code quality gates

Section titled “What you’ll walk away with from code quality gates”
  • One check command that is the gate, run identically by the agent, the hooks and CI
  • An ESLint config that makes every suppression carry a written reason, tested on ESLint 10
  • Hook registrations for Claude Code and Codex that feed failures back to the agent before a human sees them, and the Cursor equivalent
  • A GitHub Actions workflow with a required check job and a SonarQube new-code gate, with no secrets near the pull request’s code
  • Four copy-paste prompts: pass the gate without weakening it, turn a review finding into a rule, audit the gate, and triage static-analysis findings
  • Five measures that tell you whether the gate works, and who signs off on changing it

Why the checks have to run without a reviewer

Section titled “Why the checks have to run without a reviewer”

DORA’s 2025 report found that AI adoption has “a positive relationship” with delivery throughput but “does continue to have a negative relationship with software delivery stability”. It names the cause: “Without robust control systems, like strong automated testing, mature version control practices, and fast feedback loops, an increase in change volume leads to instability” (Google Cloud DORA blog, 2025-09-23).

The same gap shows up in what developers report. In Sonar’s developer survey of over 1,100 professional developers, “96% of developers do not fully trust AI-generated code, and only 48% always verify it before committing” (Sonar, 2026-01-08). A quality gate closes that gap mechanically. The check runs whether or not anyone remembers to run it, and the agent cannot finish its turn or merge until the check is green.

Which checks belong in a code quality gate?

Section titled “Which checks belong in a code quality gate?”

Block only on deterministic checks, and run each one at the earliest point that can still stop the defect. Advisory layers can raise questions, but they never decide a merge.

LayerRunsBlocks?CatchesOwner
Edit-time hookAfter each file editFeeds errors back to the agentFormat and lint errors in the file just editedDeveloper
Stop gateWhen the agent tries to end its turnSends the agent back to work onceThe full check failing anywhereDeveloper
Required CI job checkEvery pull requestYes, through branch protectionEverything above, on a clean runner, whatever tool wrote the codeTech lead
Static analysis on new codeEvery pull requestYes, as a second required checkBugs, vulnerabilities, duplication and coverage on changed linesTech lead
AI reviewEvery pull requestNo, advisoryLogic and intent problems no rule encodes yetReviewer

Neighbouring gates plug into the same required-check list: architecture fitness functions for layering, complexity and duplication budgets; slop detection for plausible but wrong patterns; dependency verification for invented packages. This page builds the base the others attach to.

  1. Put the whole gate behind one command. If the hook runs one command and CI runs another, the agent learns to pass the hook, and CI still fails. Add this to package.json (TypeScript shown; the Python and Go equivalents are in the table after these steps):

    {
    "scripts": {
    "format:check": "prettier --check .",
    "lint": "eslint --max-warnings=0 .",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "check": "npm run format:check && npm run lint && npm run typecheck && npm test"
    }
    }

    --max-warnings=0 turns every warning into a failure. A warning the gate tolerates is one the agent learns to ignore.

  2. Make every suppression carry a reason. An agent under a red gate reaches for eslint-disable and @ts-ignore. Suppressions are sometimes right, so do not ban them; make each one explain itself so that a reviewer can search the reasons. We ran this config on 2026-09-26 with ESLint 10.11.0, typescript-eslint 8.71.0 and @eslint-community/eslint-plugin-eslint-comments 4.8.1:

    // eslint.config.js: the lint half of the gate (ESLint 10, flat config)
    import { defineConfig } from 'eslint/config';
    import js from '@eslint/js';
    import tseslint from 'typescript-eslint';
    import comments from '@eslint-community/eslint-plugin-eslint-comments/configs';
    export default defineConfig(
    js.configs.recommended,
    tseslint.configs.recommended,
    comments.recommended,
    {
    linterOptions: { reportUnusedDisableDirectives: 'error' },
    rules: {
    // A suppression must say why, so a reviewer can grep the reasons.
    '@eslint-community/eslint-comments/require-description': 'error',
    // @ts-ignore is banned; @ts-expect-error needs a 10+ character reason.
    '@typescript-eslint/ban-ts-comment': [
    'error',
    { 'ts-expect-error': 'allow-with-description', 'ts-ignore': true, minimumDescriptionLength: 10 },
    ],
    '@typescript-eslint/no-explicit-any': 'error',
    },
    },
    );

    Install the packages with npm i -D eslint @eslint/js typescript-eslint typescript @eslint-community/eslint-plugin-eslint-comments, and set "type": "module" in package.json (or name the file eslint.config.mjs). In our run, a bare // eslint-disable-next-line and a bare // @ts-ignore both failed. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- third-party typings are wrong, PROJ-9 passed.

  3. Tell the agent the gate exists. Add three lines to CLAUDE.md, AGENTS.md or a Cursor project rule, whichever your tools read:

    ## Definition of done
    - `npm run check` passes. Run it before you say a task is finished.
    - Never add `eslint-disable`, `@ts-expect-error`, `# noqa` or `# type: ignore` without a reason after `--`, and list each one you add in your summary.
    - Never edit eslint.config.js, tsconfig.json, .prettierrc, sonar-project.properties or .github/ to make a check pass. Stop and explain instead.

    Instructions are advice the agent usually follows. Steps 4 and 5 are what enforces them.

  4. Run the checks inside the agent loop. Register an edit-time hook and a stop gate in each tool, as shown in the next section. The agent then sees a failure seconds after causing it, while the context that caused it is still in the session.

  5. Make CI the gate that cannot be skipped. Add the workflow below and mark check and sonar as required status checks in the branch protection rule or ruleset for your default branch. Hooks run only where the agent runs. The required check runs for every pull request, including ones from a cloud agent or a teammate without hooks installed.

  6. Protect the gate’s own files. Add eslint.config.js, tsconfig.json, .prettierrc, sonar-project.properties and .github/ to CODEOWNERS with your platform or lead team as owner, and turn on Require review from Code Owners. How to lock these files per tool, and how to run checks from a workflow the pull request cannot edit, is in protecting the oracle.

  7. Prove the gate goes red. Open a throwaway pull request with one deliberate violation per check: a misformatted file, an unused variable, a type error, a bare @ts-ignore, a failing test. Every check must fail, and the merge button must stay disabled. Repeat after any change to the gate.

The same pattern in other stacks:

StackFormatLintTypesSuppression rule
TypeScriptprettier --check .eslint --max-warnings=0 .tsc --noEmitrequire-description, ban-ts-comment (above)
Pythonruff format --check .ruff check .mypy .Ruff rules PGH003 and PGH004 reject a blanket # type: ignore or # noqa and force a specific code
Gogofmt -l . (fails when output is non-empty)golangci-lint runthe compiler and go vet ./...golangci-lint’s nolintlint with require-explanation: true

How do you run the checks inside the agent loop?

Section titled “How do you run the checks inside the agent loop?”

Both CLIs use one contract: a hook that exits with code 2 sends its stderr back to the agent. After an edit (PostToolUse) the file is already written, so the agent fixes it on its next step. At Stop the agent keeps working instead of ending its turn. Exit 1, a crash or a timeout lets the action proceed, so a hook is a fast-feedback layer, not the enforcement point. The full four-hook set, with fixtures that prove each hook blocks, is in hooks as deterministic guardrails.

The stop gate is shared by both CLIs. It runs the whole check once, then hands over to a human if the agent cannot fix the failure, so it never loops:

#!/usr/bin/env bash
# .agent-hooks/stop-check.sh: Stop hook for Claude Code and Codex
set -uo pipefail
again=$(jq -r '.stop_hook_active // false' 2>/dev/null || echo false)
[ "$again" = "true" ] && exit 0 # second attempt: let the turn end, CI decides
cd "$(git rev-parse --show-toplevel)" || exit 0
if ! out=$(npm run --silent check 2>&1); then
printf 'npm run check fails. Fix the cause; do not suppress it or edit gate config.\n%s\n' \
"$(printf '%s' "$out" | tail -n 40)" >&2
exit 2
fi
exit 0

Make it executable with chmod +x .agent-hooks/stop-check.sh. It needs jq on PATH.

Register an edit-time lint hook and the stop gate in .claude/settings.json (checked against Claude Code 2.1.283). The matcher is a regular expression over the tool name; timeouts are in seconds.

{
"hooks": {
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-edited-file.sh", "timeout": 60 }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.agent-hooks/stop-check.sh", "timeout": 600 }] }
]
}
}

The edit-time hook reads the edited path from tool_input.file_path on stdin and checks only that file. A project-wide tsc belongs in the stop gate, not here: on every edit it would re-report every existing error in the repository.

#!/usr/bin/env bash
# .claude/hooks/lint-edited-file.sh: PostToolUse, Edit|Write
set -uo pipefail
file=$(jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0
case "$file" in *.ts|*.tsx|*.js|*.jsx) ;; *) exit 0 ;; esac
bin="$CLAUDE_PROJECT_DIR/node_modules/.bin"
"$bin/prettier" --write "$file" >/dev/null 2>&1 || true # formatting repairs silently
if ! out=$("$bin/eslint" --max-warnings=0 "$file" 2>&1); then
printf 'ESLint fails on %s. Fix it without adding a suppression:\n%s\n' "$file" "$out" | head -c 4000 >&2
exit 2
fi
exit 0

Type /hooks in a session to confirm both hooks loaded and which settings file they came from.

The workflow has two jobs. check runs the pull request’s code with no secrets and a read-only token. sonar holds the Sonar token and runs no project scripts: it reads the files and the coverage report that check produced. Its configuration still comes from the pull request, which the security notes below cover.

.github/workflows/quality.yml
name: quality
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run check
- name: Coverage for Sonar
run: npx vitest run --coverage --coverage.reporter=lcov
- name: Flag changes to the gate's own config
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: |
changed=$(git diff --name-only "origin/$BASE_REF...HEAD" -- \
eslint.config.js tsconfig.json .prettierrc sonar-project.properties .github/)
[ -z "$changed" ] || echo "::warning::This pull request changes gate config: $changed"
- uses: actions/upload-artifact@v7
with:
name: coverage
path: coverage/lcov.info
sonar:
needs: check
# Fork pull requests get no secrets, so skip rather than fail.
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/download-artifact@v8
with:
name: coverage
path: coverage
- uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- uses: SonarSource/sonarqube-quality-gate-action@v1
with:
pollingTimeoutSec: 600
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

CI calls the same npm run check as the hooks, so a check you add to that script reaches CI with no workflow edit. The coverage step runs the tests a second time only to write the report Sonar reads; it gates nothing that check has not already gated. The coverage step needs @vitest/coverage-v8 in devDependencies. Point Sonar at the coverage report in sonar-project.properties with sonar.javascript.lcov.reportPaths=coverage/lcov.info. The quality-gate action fails the job when SonarQube Server’s quality gate fails, so sonar becomes a blocking check. For SonarQube Cloud, drop SONAR_HOST_URL and follow Sonar’s instructions for its quality gate check. Action majors were checked on 2026-09-26: checkout@v7, setup-node@v7, upload-artifact@v7, download-artifact@v8, sonarqube-scan-action@v8 and sonarqube-quality-gate-action@v1.

Configure the quality gate on new code, not overall code. A gate on overall code fails every pull request in a legacy repository until someone fixes years of findings, and the team learns to ignore it. A gate on new code holds each change to the standard from today. The ratchet approach for lint and complexity is in why ratchets beat thresholds.

Security notes, because readers copy this workflow as-is. npm ci runs the pull request’s lifecycle scripts, which is why check holds no secrets and the token is read-only. And the sonar job reads sonar-project.properties (and this workflow file) from the pull request, which is why both are under CODEOWNERS in step 6. Code-owner review blocks the merge, not the run: anyone who can push a branch to this repository, including an agent with push access, gets a run in which SONAR_TOKEN is present before any review. Use a token that can only analyse this one project, never an admin or global-analysis token, and rotate it if a branch run ever changes either file unreviewed. The base branch is passed through env: rather than interpolated into the script, so a branch name cannot inject shell code.

AI review finds what no rule encodes yet: a wrong assumption, a missing edge case, a change that does not match the spec. It also produces false positives and misses at a rate you cannot predict per pull request. So it never blocks. It comments, and the human who approves reads its findings next to the evidence. The full triage, including which pull requests a human still reads, is in reviewing an agent’s pull request without reading every line.

In a fresh session on the branch, not the one that wrote the code, run /code-review for the current diff, or /code-review high 1234 for pull request 1234. --comment posts the findings to the pull request, and --fix applies them. The managed Code Review service (research preview, Team and Enterprise) reads review rules from a repository-root REVIEW.md. Its check run always completes as neutral, so it never blocks a merge through branch protection (checked 2026-09-26).

The loop that makes the gate stronger over time: when the same AI-review finding appears on three pull requests, turn it into a deterministic rule (a lint rule, a type, a test) and move it from advice into the gate. How review bots from each vendor compare on noise and configuration is in AI code review bots compared.

A gate that is always green proves nothing on its own. Track five measures per repository, monthly, and read the trend:

MeasureDefinitionWhat a bad trend tells you
Suppressions addedNew suppression comments per merged pull request, counted from the diffRising: the agent is silencing the gate instead of fixing code. Tighten the rules in step 2 and the definition of done
Gate escapesProduction defects that an existing lint, type or static-analysis rule could have caught, divided by all production defectsAbove zero: a check is not running where you think it is. Re-run the red-team pull request from step 7
Rules promotedAI-review findings turned into deterministic rules this monthZero for months: the gate has stopped learning, and review carries the load
Time to green in the loopMedian agent turns between a hook failure and a passing checkRising: failure messages are too long or unclear for the agent to act on
OverridesMerges with a required check failing, through an admin bypassAny: someone decided the gate was wrong. Fix the rule or record the reason

Sign-off is explicit. The tech lead owns the gate’s configuration through CODEOWNERS and changes it only in reviewed pull requests. The approving reviewer is accountable for reading the AI-review findings, not for re-running the checks. Nobody merges with a required check red except through a logged admin override. These measures sit with the team’s other numbers in metrics frameworks for agentic engineering.

What breaks when you gate agent-written code?

Section titled “What breaks when you gate agent-written code?”

The agent silences the gate instead of fixing the code. It adds // eslint-disable-next-line, loosens tsconfig.json, or marks a test .skip. Recovery: the suppression rules in step 2 make bare suppressions fail; CODEOWNERS on the config files makes loosening visible; the “Flag changes to the gate’s own config” step annotates the pull request. Test weakening has its own detector in slop detection.

The loop slows to a crawl. A hook that runs the whole test suite or a project-wide tsc after every edit adds minutes per turn, and people disable it. Recovery: keep edit-time hooks per file and under a few seconds; run the full check once, at Stop, and again in CI.

A legacy repository is red on day one. Turning on --max-warnings=0 or a strict quality gate surfaces thousands of findings, and the team switches the gate off. Recovery: gate new code only (Sonar’s new-code definition, lint on changed files) and ratchet the existing count down, as in architecture fitness functions.

Local and CI results disagree. The hook passes on a laptop and CI fails, or the reverse, because the tool versions differ. Recovery: run every tool from node_modules/.bin or the lockfile’s pinned versions, never a global install, and use the same npm run check in both places.

The pull request edits the workflow that checks it. A pull_request workflow runs the YAML from the pull request itself, so an agent that deletes the lint step still gets a green check. Recovery: CODEOWNERS on .github/, and for the decisive checks a required workflow stored in another repository, as described in protecting the oracle.

The static-analysis gate never fails on pull requests. Sonar analyses main only, or the gate is set on overall code and was already failing, so nobody reads it. Recovery: confirm pull-request analysis runs, set the gate on new code, and include a deliberate Sonar finding in the step 7 red-team pull request.

AI review becomes a merge blocker by habit. Reviewers wait for the bot’s comments and treat silence as approval. Recovery: say in the review policy that AI review is advisory, keep the approver’s name on every merge, and promote recurring findings to rules so the bot has less to find.