Static Analysis in the Loop: Linters as Feedback, Not Just Gates
A gate tells you the work is wrong. A signal stops it being wrong. Most teams have configured the first and not the second.
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.
| Tool | Agent-friendly invocation |
|---|---|
| ruff | ruff check --output-format=concise . |
| mypy | mypy src/ --no-error-summary --no-pretty --no-color-output |
| eslint | eslint . -f compact |
| golangci-lint | golangci-lint run --out-format=line-number |
| clippy | cargo 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.
| Suppression | Looks like |
|---|---|
| Inline comment | # noqa: E501, # type: ignore, // eslint-disable-next-line |
| Weakened annotation | Changing a precise type to Any |
| Config edit | Adding a rule to the ignore list |
| File exclusion | Adding a path to the exclude glob |
| Structural dodge | Splitting 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.
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.
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.