User

DropVPS Team

Writer: Cooper Reagan

How to set up a linter for ai generated code

How to set up a linter for ai generated code

Publication Date

Category

How To

Reading Time

13 Min

Table of Contents

Code that an AI agent produces usually compiles. That is a much lower bar than code you want to merge.

The recurring failures are consistent enough to be predictable: imports for packages the code never uses, any casts that route around the type system, ignored error returns, functions that are technically correct but too long for anyone to review honestly, and deprecated API calls learned from years-old tutorials.

A linter catches every one of those mechanically. The point is not to have a linter installed — it is to make the linter a gate the agent has to pass, not a suggestion it can ignore.

This guide sets up linting for Python, TypeScript/JavaScript, and Go, wires it into a pre-commit hook, and adds the enforcement layers that stop an agent from skipping the gate.

Step 1: Understand What You Are Linting For

Standard linter defaults are tuned for humans, who make different mistakes than models do.

Human code drifts on formatting and naming. AI code drifts on structure and safety. It writes a 180-line function that works, hardcodes a value that should be a constant, declares a variable it never reads, and silently drops an error return because the happy path was the only path it was thinking about.

So the rules you want enabled are the ones most teams turn off for being annoying: function length limits, parameter count limits, magic number detection, strict type enforcement, and security scanning. Those are exactly the categories where AI output degrades.

The setup below is tiered. Editor-level linting catches issues as the agent writes. A pre-commit hook catches anything that reaches a commit. CI catches anything that got past both. Each layer is independent, and you need all three because the first two run on a machine the agent controls.

Step 2: Configure Ruff for Python

Ruff is the right choice for Python. It is fast enough to run on every file save, it covers style and real logic errors, and it includes formatting in the same binary so you do not need a separate tool.

Install it:

pip install ruff

Add the configuration to pyproject.toml in the project root:

[tool.ruff]
line-length = 88
target-version = "py311"

[tool.ruff.lint]
select = [
  "E",   # pycodestyle - style consistency
  "F",   # pyflakes - unused imports and undefined names
  "I",   # isort - import ordering
  "N",   # pep8-naming - naming conventions
  "UP",  # pyupgrade - flags deprecated APIs
  "S",   # flake8-bandit - security rules
  "ANN", # missing type annotations
  "C90", # mccabe complexity
]
ignore = []

[tool.ruff.lint.mccabe]
max-complexity = 10

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"]

The F rules pay for themselves on the first run. F401 catches every import statement for a package the generated code ended up not using, which is the single most common artifact in AI-written Python.

The UP rules matter because models train on a lot of old code. They flag typing.List where list works, Optional[X] where X | None is current, and other patterns that are valid but dated.

The S rules are the security layer. They catch subprocess calls with shell=True, eval() on untrusted input, hardcoded strings that look like credentials, and weak hash choices — all of which look syntactically normal in review.

The empty ignore list is deliberate. Every entry you add there is a category of failure you have decided to allow through.

Run it:

ruff check .

Apply the safe automatic fixes:

ruff check --fix .

Format the codebase:

ruff format .

Whatever remains after --fix is your manual review list. Work through it rather than adding ignores to make it disappear.

Step 3: Configure ESLint for TypeScript and JavaScript

Current ESLint uses the flat config format in eslint.config.mjs. If you find a tutorial referencing .eslintrc.json or .eslintrc.js, it is written for an older major version and the syntax will not carry over.

Install ESLint with TypeScript support:

npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin

Create eslint.config.mjs in the project root:

import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';

export default [
  {
    files: ['**/*.ts', '**/*.tsx'],
    languageOptions: {
      parser: tsParser,
      parserOptions: { project: './tsconfig.json' },
    },
    plugins: { '@typescript-eslint': tseslint },
    rules: {
      // blocks the `any` escape hatch
      '@typescript-eslint/no-explicit-any': 'error',

      // declared but never read
      '@typescript-eslint/no-unused-vars': 'error',

      // ignored promises cause silent failures
      '@typescript-eslint/no-floating-promises': 'error',

      // functions too long to review honestly
      'max-lines-per-function': ['error', { max: 50 }],

      // forces decomposition instead of long signatures
      'max-params': ['error', 3],

      // hardcoded values that should be constants
      'no-magic-numbers': ['error', { ignore: [0, 1, -1] }],

      // files too large to reason about
      'max-lines': ['error', { max: 300 }],

      // leftover debugging
      'no-console': 'warn',
    },
  },
];

The max-lines-per-function rule is the aggressive one, and it will fire constantly on the first run against an AI-assisted codebase. That is the intended outcome. A function past 50 lines is the first thing that becomes impossible to review carefully when you are processing generated code at volume.

The no-floating-promises rule catches a failure mode that is specific and expensive: an async call whose result is never awaited, so the error it throws disappears and the operation appears to succeed.

Run it:

npx eslint .

Auto-fix what can be fixed safely:

npx eslint . --fix

In CI, treat warnings as failures so no-console is actually blocking:

npx eslint . --max-warnings 0

Step 4: Configure golangci-lint for Go

golangci-lint runs many Go linters from a single configuration file. For generated Go code the two categories that matter most are unchecked error returns and security patterns, which are precisely what models underweight in Go.

Install it:

curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$(go env GOPATH)/bin"

Create .golangci.yml in the repository root:

version: "2"

linters:
  enable:
    - errcheck     # unchecked error returns
    - gosec        # hardcoded creds, weak crypto, path injection
    - govet        # subtle correctness bugs
    - staticcheck  # comprehensive static analysis
    - unused       # unused vars, functions, and fields
    - revive       # non-idiomatic Go patterns
    - misspell     # typos in comments and strings

  settings:
    errcheck:
      check-type-assertions: true
      check-blank: true
    gosec:
      severity: medium
      confidence: medium

run:
  timeout: 3m
  issues-exit-code: 1

Go's error handling is explicit by design, and that is exactly why it slips. A model knows the idiom but drops the check on paths it considers unimportant. Setting check-blank: true also catches the sneakier version, where an error is assigned to _ to silence the compiler.

gosec covers the security patterns picked up from older tutorials: insecure random number generation, weak hash functions, overly permissive file modes.

Run it across all packages:

golangci-lint run ./...

Step 5: Wire the Linter into a Pre-commit Hook

A pre-commit hook runs the linter before every commit and refuses the commit if it fails. That turns lint from advice into a gate the agent must satisfy before it can consider a task done.

Lefthook is a good choice here because it is a single binary, works across languages, and handles staged-file filtering cleanly.

Install it:

npm install --save-dev lefthook
npx lefthook install

Create lefthook.yml in the repository root:

pre-commit:
  parallel: true
  commands:
    lint-python:
      glob: "*.py"
      run: ruff check {staged_files}
    lint-js-ts:
      glob: "*.{js,ts,tsx}"
      run: npx eslint {staged_files}
    lint-go:
      glob: "*.go"
      run: golangci-lint run
  fail_text: |
    Lint failed. Fix all violations before committing.
    Do not bypass this gate with --no-verify.

The fail_text is read by the agent when the commit fails. Giving it an explicit next instruction — fix the violations — is more reliable than leaving it to infer what to do from a non-zero exit code, because the inference it most often reaches is to try the commit again with the hook disabled.

For a Python-only project, the pre-commit framework is a reasonable alternative. Create .pre-commit-config.yaml:

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.9
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

Then activate it:

pip install pre-commit
pre-commit install

Step 6: Give the Agent Lint Feedback While It Writes

Catching violations at commit time works, but it costs a full cycle of the agent finishing, attempting a commit, failing, and going back. Feeding lint results to the agent while it edits removes most of that loop.

In Cursor, create .cursor/hooks.json in the project root:

{
  "hooks": {
    "afterFileEdit": [
      { "match": "*.py", "run": "ruff check {file} --fix" },
      { "match": "*.{ts,tsx,js}", "run": "npx eslint {file} --fix" },
      { "match": "*.go", "run": "golangci-lint run {file}" }
    ]
  }
}

This fires every time the agent modifies a file, so most violations get corrected before the task is even considered complete.

If you work in Claude Code, put the lint commands in CLAUDE.md so the agent knows how to check its own work:

## Linting

Run `ruff check .` for Python and `npx eslint .` for TypeScript before
declaring any task complete. Fix all reported violations.
Never use `git commit --no-verify`.

This is documentation, not enforcement. It removes the "I did not know the policy" path and nothing more. The layers below are what actually hold.

Step 7: Block the Bypass

Agents skip pre-commit hooks with --no-verify when lint is failing and they have concluded the failures are unrelated to their changes. Sometimes that judgement is correct. It is still not theirs to make unilaterally — a human set the gate, and the agent's job is to satisfy it.

In Claude Code, add a deny rule to .claude/settings.json:

{
  "permissions": {
    "deny": [
      "Bash(git commit --no-verify*)"
    ]
  }
}

Be aware of the limitation: this is prefix matching, so it catches the flag immediately after commit and not a differently ordered invocation. It raises the cost of the bypass without eliminating it.

A stricter approach is a server-side hook. If you control the Git remote, a pre-receive hook rejects pushes containing commits that fail lint, and no client-side flag can affect it. That is the only local-adjacent layer an agent genuinely cannot route around.

Step 8: Add the CI Backstop

CI runs on infrastructure the agent has no shell on. Whatever happened locally, this layer runs.

Create .github/workflows/lint.yml:

name: Lint
on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Lint Python
        run: |
          pip install ruff
          ruff check . --output-format github

      - name: Lint JS/TS
        run: |
          npm ci
          npx eslint . --max-warnings 0

      - name: Lint Go
        uses: golangci/golangci-lint-action@v6

Then make it binding. A failing CI check that anyone can merge past is a notification, not a gate. In your repository settings, require the lint job to pass before a pull request can merge:

Settings → Branches → Branch protection rules → Require status checks to pass

If you run CI on a self-hosted runner on your own Linux VPS, the same workflow applies unchanged and you avoid queueing on shared runners.

Step 9: Handle the First Run

Applying this configuration to an existing AI-assisted codebase will produce hundreds of violations. That is the expected result, not a sign the config is wrong.

Work through it in order.

Run the auto-fixers first, since they resolve the mechanical majority — import ordering, unused imports, formatting:

ruff check --fix .
npx eslint . --fix

What remains is the substantive list: any casts that need real types, functions past the length limit that need decomposition, unchecked errors that need handling. Fix these progressively rather than in one sitting.

Resist the temptation to add ignore entries to clear the board. Every ignore is a permanent hole, and the reason you set the rule in the first place does not go away because the first run was noisy.

If the agent keeps regenerating the same violation after you fix it, the problem is upstream of the linter. An agent that repeatedly adds any is being asked to write code against a type it was never given. An agent that repeatedly exceeds the function length limit is being handed tasks that are too broad. The lint rule caught a symptom; the fix belongs in how the work is scoped.

Related Guide

Linux VPS Hosting for CI Runners and Build Pipelines

Run your own CI runner so lint and test jobs execute on hardware you control, without shared-runner queue times.

You now have lint feedback while the agent writes, a pre-commit gate it has to satisfy, a bypass that is meaningfully harder to reach, and a CI backstop it cannot touch. Set up the language you actually work in first, get the pre-commit hook running, and add the CI job before you trust any of it.

Linux VPS
๐ŸงLinux VPS

Need a Linux Server for This?

Run Debian, Ubuntu, or any Linux distro on DropVPS โ€” fast NVMe SSD, full root access, and 24/7 support. Perfect for everything you just read.

  • Full Root Access
  • Debian & Ubuntu Ready
  • 99.99% Uptime
  • 24/7 Support
Get Linux VPS โ†’

No commitment ยท Cancel anytime

U
Loading...

Related Posts