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
checkcommand 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
checkjob 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.
| Layer | Runs | Blocks? | Catches | Owner |
|---|---|---|---|---|
| Edit-time hook | After each file edit | Feeds errors back to the agent | Format and lint errors in the file just edited | Developer |
| Stop gate | When the agent tries to end its turn | Sends the agent back to work once | The full check failing anywhere | Developer |
Required CI job check | Every pull request | Yes, through branch protection | Everything above, on a clean runner, whatever tool wrote the code | Tech lead |
| Static analysis on new code | Every pull request | Yes, as a second required check | Bugs, vulnerabilities, duplication and coverage on changed lines | Tech lead |
| AI review | Every pull request | No, advisory | Logic and intent problems no rule encodes yet | Reviewer |
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.
Build the gate, step by step
Section titled “Build the gate, step by step”-
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=0turns every warning into a failure. A warning the gate tolerates is one the agent learns to ignore. -
Make every suppression carry a reason. An agent under a red gate reaches for
eslint-disableand@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-comments4.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"inpackage.json(or name the fileeslint.config.mjs). In our run, a bare// eslint-disable-next-lineand a bare// @ts-ignoreboth failed.// eslint-disable-next-line @typescript-eslint/no-explicit-any -- third-party typings are wrong, PROJ-9passed. -
Tell the agent the gate exists. Add three lines to
CLAUDE.md,AGENTS.mdor 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.
-
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.
-
Make CI the gate that cannot be skipped. Add the workflow below and mark
checkandsonaras 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. -
Protect the gate’s own files. Add
eslint.config.js,tsconfig.json,.prettierrc,sonar-project.propertiesand.github/toCODEOWNERSwith 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. -
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:
| Stack | Format | Lint | Types | Suppression rule |
|---|---|---|---|---|
| TypeScript | prettier --check . | eslint --max-warnings=0 . | tsc --noEmit | require-description, ban-ts-comment (above) |
| Python | ruff format --check . | ruff check . | mypy . | Ruff rules PGH003 and PGH004 reject a blanket # type: ignore or # noqa and force a specific code |
| Go | gofmt -l . (fails when output is non-empty) | golangci-lint run | the 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 Codexset -uo pipefailagain=$(jq -r '.stop_hook_active // false' 2>/dev/null || echo false)[ "$again" = "true" ] && exit 0 # second attempt: let the turn end, CI decidescd "$(git rev-parse --show-toplevel)" || exit 0if ! 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 2fiexit 0Make 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|Writeset -uo pipefailfile=$(jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0case "$file" in *.ts|*.tsx|*.js|*.jsx) ;; *) exit 0 ;; esacbin="$CLAUDE_PROJECT_DIR/node_modules/.bin""$bin/prettier" --write "$file" >/dev/null 2>&1 || true # formatting repairs silentlyif ! 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 2fiexit 0Type /hooks in a session to confirm both hooks loaded and which settings file they came from.
Codex reads .codex/hooks.json in a trusted project; the JSON shape matches Claude Code’s (checked against Codex CLI 0.157.1). Codex writes files through its apply_patch tool, which passes a patch rather than a single file_path, so a per-file lint hook copied from Claude Code lints nothing. Run the whole check at Stop instead:
{ "hooks": { "Stop": [ { "hooks": [{ "type": "command", "command": "\"$(git rev-parse --show-toplevel)/.agent-hooks/stop-check.sh\"", "timeout": 600 }] } ] }}Project hooks do not run until you trust them. Open /hooks in a session to review and trust them, and again after anyone changes hooks.json. Do not pass --dangerously-bypass-hook-trust on a checkout you did not produce.
Cursor has hooks that “can observe, block, or modify behavior” (Cursor hooks docs, checked 2026-08-28). We could not re-verify their event names or configuration file from our environment on 2026-09-26, so take those from Cursor’s hooks reference and port stop-check.sh to them.
Until that adapter is proven, rely on two things that do not depend on Cursor’s hook schema: the definition of done from step 3 in a Cursor project rule, and the required CI checks from step 5. Cursor’s Cloud Agents run in their own cloud VMs, so a hook on your laptop never reaches them; the required check does.
Make the CI job the gate
Section titled “Make the CI job the gate”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.
name: qualityon: 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.
Where does AI review fit in the gate?
Section titled “Where does AI review fit in the gate?”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).
codex exec review --base main reviews the branch against main non-interactively; -o review.md writes the final message to a file (Codex CLI 0.157.1). The --base, --commit and --uncommitted presets cannot be combined with custom instructions, so put standing review rules in AGENTS.md. On GitHub, commenting @codex review on a pull request asks the connected Codex for a review (checked 2026-08-28).
Bugbot “reviews pull requests and identifies bugs, security issues, and code quality problems” (cursor.com, checked 2026-08-28) and comments on the pull request once your repository is connected. Treat its comments like any other reviewer’s: evidence to weigh, not a status check to require.
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.
Copy-paste prompts for code quality gates
Section titled “Copy-paste prompts for code quality gates”How do you know the gate works?
Section titled “How do you know the gate works?”A gate that is always green proves nothing on its own. Track five measures per repository, monthly, and read the trend:
| Measure | Definition | What a bad trend tells you |
|---|---|---|
| Suppressions added | New suppression comments per merged pull request, counted from the diff | Rising: the agent is silencing the gate instead of fixing code. Tighten the rules in step 2 and the definition of done |
| Gate escapes | Production defects that an existing lint, type or static-analysis rule could have caught, divided by all production defects | Above zero: a check is not running where you think it is. Re-run the red-team pull request from step 7 |
| Rules promoted | AI-review findings turned into deterministic rules this month | Zero for months: the gate has stopped learning, and review carries the load |
| Time to green in the loop | Median agent turns between a hook failure and a passing check | Rising: failure messages are too long or unclear for the agent to act on |
| Overrides | Merges with a required check failing, through an admin bypass | Any: 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.
Where to go next with code quality gates
Section titled “Where to go next with code quality gates”- How strong is your oracle? measures whether the tests inside
npm run checkcatch bugs at all. - Protecting the oracle locks the gate’s files and runs checks from a workflow the pull request cannot edit.
- Architecture fitness functions add layering, complexity and duplication budgets to the same required checks.
- The evidence bundle defines how the gate’s results travel with the pull request to the reviewer.
- Pipeline automation with AI covers the rest of the CI pipeline these jobs live in.
- Security scanning and vulnerability testing adds SAST and secret scanning next to the quality checks.
- Testing excellence with AI is the overview of the whole verification section.