
After building a security scanning toolkit, I wanted the same pattern for linting. Tedious rules that slow developers down are trivial for a coding agent to stick to, and it'll help provide some baseline quality.
I also don't want the agent to ever make a mess, and knowing they love to git commit constantly, I'm leaning again on pre-commit hooks to keep things deterministic.
I tried MegaLinter first. It's a popular containerized linter that bundles 100+ tools. The full image is 17GB. The "cupcake" flavor with common languages is 10GB. Even the language-specific flavors are 5GB on disk.
The scan took over five minutes on a monorepo with a few thousand files. That's not something I want AI agents running on demand. So I built my own.
Cool thing about agentic coding is you can roll your own anything with basically no effort. I asked it to make me my own faster one, and to run it then fix up my own project.
Ended up with an image containing four linters, each the fastest in its category:
| Language | Tool | Written in |
|---|---|---|
| Python | ruff | Rust |
| JS / TS | biome | Rust |
| Dockerfile | hadolint | Haskell |
| Terraform | tflint | Go |
All four are single compiled binaries. No Python runtime, no Node.js, no JVM. The final image is 282MB.
Ruff replaces flake8, pylint, isort, and black in one tool. Biome replaces eslint and prettier. Both are written in Rust and finish in under a second on this monorepo.
Multi-stage build. Copy hadolint and tflint from their official images, download ruff and biome as release binaries.
FROM hadolint/hadolint:v2.14.0-alpine AS hadolint
FROM ghcr.io/terraform-linters/tflint:v0.61.0 AS tflint
FROM alpine:3.21
ARG RUFF_VERSION=0.15.5
ARG BIOME_VERSION=2.4.6
ARG TARGETARCH
RUN apk upgrade --no-cache
COPY --from=hadolint /bin/hadolint /usr/local/bin/hadolint
COPY --from=tflint /usr/local/bin/tflint /usr/local/bin/tflint
# ruff: Python linter (Rust binary)
RUN if [ "$TARGETARCH" = "arm64" ]; then \
RUFF_ARCH="aarch64"; else RUFF_ARCH="x86_64"; fi \
&& wget -qO- \
"https://github.com/astral-sh/ruff/releases/download/\
${RUFF_VERSION}/ruff-${RUFF_ARCH}-unknown-linux-musl.tar.gz" \
| tar xz --strip-components=1 -C /usr/local/bin
# biome: JS/TS linter (Rust binary)
RUN if [ "$TARGETARCH" = "arm64" ]; then \
BIOME_ARCH="linux-arm64-musl"; \
else BIOME_ARCH="linux-x64-musl"; fi \
&& wget -qO /usr/local/bin/biome \
"https://github.com/biomejs/biome/releases/download/\
%40biomejs%2Fbiome%40${BIOME_VERSION}/biome-${BIOME_ARCH}" \
&& chmod +x /usr/local/bin/biome
RUN addgroup -S linter && adduser -S linter -G linter
RUN mkdir -p /workspace && chown linter:linter /workspace
WORKDIR /workspace
USER linter
ENTRYPOINT ["/bin/sh"]
The TARGETARCH arg is set automatically by Docker BuildKit,
so the same Dockerfile works on both amd64 and arm64. A
non-root linter user runs the tools. The image builds in
under 10 seconds because every tool is a precompiled binary.
Source: GitHub
Same approach as the
security toolkit. A Ruler
file provides the commands, then ruler apply injects them into CLAUDE.md and any
other tool config.
Note that this is nondetermistic and such not dependable.
# Linting
The Docker image `kpericak/ai-lint-toolkit:0.1` bundles
ruff, biome, hadolint, and tflint in one Alpine container.
Run from the repo root. Mount the project directory
as /workspace.
## Python (ruff)
docker run --rm -v "$(pwd):/workspace:ro" \
kpericak/ai-lint-toolkit:0.1 \
-c "ruff check /workspace --no-cache"
## JavaScript / TypeScript (biome)
docker run --rm -v "$(pwd):/workspace:ro" \
kpericak/ai-lint-toolkit:0.1 \
-c "biome lint /workspace/path/to/src"
## Dockerfiles (hadolint)
docker run --rm -v "$(pwd):/workspace:ro" \
kpericak/ai-lint-toolkit:0.1 \
-c "find /workspace -name 'Dockerfile*' \
-not -path '*/node_modules/*' | xargs hadolint"
## Terraform (tflint)
docker run --rm -v "$(pwd):/workspace:ro" \
kpericak/ai-lint-toolkit:0.1 \
-c "cd /workspace/path/to/tf && tflint"
The volume is mounted read-only. Linters don't need to write
to your source tree. Ruff needs --no-cache to skip writing
a cache directory since the mount is read-only.
For biome, point it at specific source directories rather
than the repo root. Otherwise it crawls node_modules and
takes forever.
Here's what each tool found on the first scan of this monorepo.
46 F401 [*] unused-import
13 F841 [-] unused-variable
11 F541 [*] f-string-missing-placeholders
1 F821 [ ] undefined-name
Found 71 errors.
[*] 59 fixable with the `--fix` option.
71 findings across all the Python in the monorepo. Most are
in older projects (kytrade, mods) that haven't been touched
in a while. The [*] markers mean ruff can auto-fix them.
Unused imports and f-string placeholders are the bulk of it.
Ruff scanned the entire repo in 0.5 seconds.
7 lint/suspicious/noDoubleEquals
6 lint/style/noNonNullAssertion
4 lint/style/useConst
1 lint/correctness/useParseIntRadix
1 lint/correctness/noUnusedImports
19 findings. The double-equals warnings are in the blog's
route handler (== instead of ===). The non-null
assertions are in test files for a game project. All fixable.
Biome scanned the JS/TS source in 0.7 seconds.
Hadolint found 14 findings across 5 Dockerfiles. The interesting ones:
apk addpip installSHELL with -o pipefail before piped RUNThe security toolkit's own Dockerfile got flagged for the
pipefail issue in its wget | tar pipe. Fair catch.
Warning: terraform "required_version" attribute is required
Warning: Missing version constraint for provider "google"
Warning: [Fixable] data "aws_caller_identity" "current"
is declared but not used
4 warnings across 2 Terraform directories. Missing version constraints and an unused data source. Standard Terraform hygiene stuff.
| Image | Size on disk | Run time |
|---|---|---|
| MegaLinter (full) | 17 GB | 5+ min |
| MegaLinter (cupcake) | 10 GB | untested |
| MegaLinter (javascript) | 5 GB | untested |
| ai-lint-toolkit | 282 MB | < 5 sec |
The custom image is 60x smaller than MegaLinter's full image
and runs in seconds. The tradeoff is that it only covers four
languages. But those are the four I need, and adding more is
one wget or COPY away.
I already had semgrep and gitleaks running as pre-commit hooks from the security toolkit. Adding ruff and biome follows the same pattern.
repos:
- repo: local
hooks:
- id: ruff
name: Ruff
entry: >-
bash -c 'docker run
-v "$(pwd):/workspace:ro" --rm
kpericak/ai-lint-toolkit:0.1
-c "ruff check /workspace --no-cache"'
language: system
stages: ["pre-commit"]
- id: biome
name: Biome
entry: >-
bash -c 'docker run
-v "$(pwd):/workspace:ro" --rm
kpericak/ai-lint-toolkit:0.1
-c "biome lint /workspace/apps/blog/blog/pages
/workspace/apps/blog/blog/components
/workspace/apps/blog/blog/utils"'
language: system
stages: ["pre-commit"]
- id: semgrep
name: Semgrep
entry: >-
bash -c 'docker run
-v "$(pwd):/workspace:ro" --rm
kpericak/ai-security-toolkit-1:0.2
-c "semgrep scan --config auto
--error /workspace"'
language: system
stages: ["pre-commit", "pre-push"]
- id: gitleaks
name: Gitleaks
entry: >-
bash -c 'docker run
-v "$(pwd):/workspace:ro" --rm
kpericak/ai-security-toolkit-1:0.2
-c "cd /workspace && gitleaks detect
--source ."'
language: system
stages: ["pre-commit", "pre-push"]
Every commit now gets linted for Python and JS/TS issues, scanned for security bugs by semgrep, and checked for leaked secrets by gitleaks. Four tools, two Docker images, no CI server required.
Hadolint and tflint aren't in the hooks. Dockerfiles and Terraform configs change rarely enough that running them on demand through the AI agent is fine.