Tooling & Integration

Static Analysis in the Loop: Linters as Feedback, Not Just Gates

David Guzenburg/ / 8 min read

A gate tells you the work is wrong. A signal stops it being wrong. Most teams have configured the first and not the second.

lintingstatic analysisCIfeedback loops

Two things a linter can be to an agent

A linter can play one of two roles in an agent workflow, and they produce very different outcomes.

As a gate, it runs after the agent finishes and rejects the result. The agent learns nothing during the work; it discovers at the end that something is wrong and has to unpick it.

As a feedback signal, it runs inside the loop. The agent makes a change, sees the diagnostics, and corrects before moving on. Errors are caught while their cause is still in context.

Both matter, and the second is where the leverage is. A gate tells you the work is wrong. A signal stops it being wrong.

Getting output an agent can use

Most linters default to a human-friendly format — colour, context lines, decorative rules — which is noise to something parsing text. Every serious tool has a machine mode.

ToolAgent-friendly invocation
ruffruff check --output-format=concise .
mypymypy src/ --no-error-summary --no-pretty --no-color-output
eslinteslint . -f compact
golangci-lintgolangci-lint run --out-format=line-number
clippycargo clippy --message-format=short

The common shape is file:line:col: message, one issue per line. That is what lets an agent go straight to the location instead of searching for it, and it is a one-line change in your context file.

## Static analysis
Run these after every change, in this order:
  1. `ruff check --output-format=concise . --fix`
  2. `ruff format .`
  3. `mypy src/ --no-error-summary --no-pretty`

The ruff and mypy configs in pyproject.toml are authoritative.
Do not edit them. Do not add `# noqa` or `# type: ignore`.

The suppression problem

This is the failure mode that defines the whole topic. An agent told to make the linter pass has two paths to that goal: fix the code, or silence the check.

SuppressionLooks like
Inline comment# noqa: E501, # type: ignore, // eslint-disable-next-line
Weakened annotationChanging a precise type to Any
Config editAdding a rule to the ignore list
File exclusionAdding a path to the exclude glob
Structural dodgeSplitting a function purely to satisfy a length rule

Rows three and four are the serious ones, because they affect the whole repository rather than one line, and they are easy to miss in a diff full of source changes.

Close it mechanically

Do not rely on the instruction alone. A CI check that fails when suppression comments increase, or when linter config files are touched outside an explicitly labelled PR, is a few lines and it holds when the instruction does not.

#!/bin/bash
# Fail if this branch adds suppression comments.
set -e
ADDED=$(git diff origin/main...HEAD -U0 \
  | grep -E '^\+' \
  | grep -cE '(# ?noqa|# ?type: ?ignore|eslint-disable|@ts-ignore|nolint)' \
  || true)

if [ "$ADDED" -gt 0 ]; then
  echo "This branch adds $ADDED suppression comment(s)."
  echo "Fix the underlying issue, or justify each one in the PR"
  echo "description and add the 'suppression-reviewed' label."
  exit 1
fi

Note the escape route. A rule with no legitimate override gets disabled the first time someone genuinely needs a suppression. A visible, labelled override survives, because using it is possible but conspicuous.

Which rules earn their place

A linter configuration tuned for humans is not automatically right for a workflow that includes agents. Two adjustments are usually worth making.

Turn on the rules you previously left off because they were noisy. Rules that generate many small mechanical fixes — import ordering, unused variables, missing return types — were annoying when a human had to action each one. An agent fixes them for free, and each one is a small increment of consistency.

Turn off rules that are purely stylistic and contested. If a rule generates argument rather than agreement, it will also generate churn in agent output. Anything a formatter can settle should be settled by the formatter, not debated by a linter.

Custom rules encode your specific mistakes

The highest-value rules are not the generic ones. They are the ones encoding mistakes your codebase actually makes, and they are usually cheap to write.

# scripts/lint_custom.py — project-specific rules
import ast, pathlib, sys

FAILURES = []

class Check(ast.NodeVisitor):
    def __init__(self, path):
        self.path = path

    def visit_Call(self, node):
        # datetime.now() breaks time-dependent tests; use util.clock
        if (isinstance(node.func, ast.Attribute)
                and node.func.attr == "now"
                and getattr(node.func.value, "id", "") == "datetime"):
            FAILURES.append(
                f"{self.path}:{node.lineno}: use util.clock.now(), "
                f"not datetime.now()")
        self.generic_visit(node)

for p in pathlib.Path("src").rglob("*.py"):
    Check(p).visit(ast.parse(p.read_text()))

for f in FAILURES:
    print(f)
sys.exit(1 if FAILURES else 0)

Thirty lines, and it permanently prevents a mistake that would otherwise be caught in review some of the time. Every rule like this converts a recurring human judgement into a mechanical check, which is exactly the trade you want as change volume increases.

Type checkers deserve separate treatment

A type checker is the highest-value static analysis an agent can have, because it is the only one that reports semantic errors rather than stylistic ones. A linter says the code is untidy; a type checker says it is wrong.

Two consequences follow.

Strictness pays more than it used to. Settings that were tedious for humans — requiring annotations everywhere, disallowing implicit Any — produce sharper feedback, and the annotation burden falls on something that does not mind.

[tool.mypy]
strict = true
warn_unreachable = true
warn_return_any = true
disallow_untyped_defs = true
# Every relaxation below is a place agents will produce weaker code.
# Remove them as the codebase catches up.
[[tool.mypy.overrides]]
module = ["src.legacy.*"]
ignore_errors = true

Widening a type is a suppression. Changing a parameter from a precise type to Any silences the checker exactly as effectively as a comment, and it does not look like suppression in a diff. Worth adding to the CI check alongside the comment patterns:

WIDENED=$(git diff origin/main...HEAD -U0 | grep -E '^\+'   | grep -cE ': *Any|-> *Any' || true)
[ "$WIDENED" -gt 0 ] && echo "warning: $WIDENED new Any annotation(s)"

Ordering the checks

Sequence matters, because a formatter that runs last will undo work the agent did to satisfy a line-length rule.

#!/bin/bash
set -e
ruff check --fix .                  # autofix what is mechanical
ruff format .                       # then normalise formatting
ruff check --output-format=concise . # re-check: format can create issues
mypy src/ --no-error-summary        # types, on formatted code
python3 scripts/lint_custom.py      # project rules last
pytest tests/unit -q --tb=line

The second ruff check is not redundant. Formatting can introduce violations — a reflowed line, a moved comment — and without the re-check those reach CI and the agent never sees them.

Takeaway

Put the linter inside the loop, not only at the gate, and give it a machine-readable output format so the agent goes straight to the line. Assume it will try to suppress rather than fix, and block that with a CI check that has a visible override. Enable the noisy mechanical rules you previously disabled — an agent actions them for free.

Keep reading
Tooling & Integration

Types as the Contract: A Decision Procedure Downstream of a Generator

Why strict typing pays more now that annotations are written for free, exhaustiveness checking as the guard against missed variants, signatures as specification, and where types stop helping.

Tooling & Integration

Running Coding Agents in CI: Guardrails for Unattended Work

What changes when no human is watching, which tasks are safe to automate, and the five controls that turn an unattended agent job into a bad pull request rather than a bad deployment.

Tooling & Integration

Build System Integration: Your Toolchain Is the Agent's Feedback Loop

Why build quality bounds agent output, four properties that matter, the failure modes specific to agents, and why you should verify your check command actually fails on failure.

Tooling & Integration

Event-Driven Agent Jobs: Fire on Change, Produce Information

Why scheduled agent jobs waste tokens and arrive detached from context, which events are worth wiring, the permissions design that keeps them safe, and how to stop them becoming noise.

← Normalising Agent Commits: The Only Surviving Record of Intent  ·  IDE Extensions for an Internal Agent: Keep the Plugins Thin →

All tooling & integration articles  ·  Every article