4.1. Linting
In one glance
- You will: Run the core formatter and static checks, then see how maintainers extend the same vocabulary to infrastructure.
- You need:
mise run installdone andmise run doctorpassing. - Time: about 10 minutes, hands-on.
What does this page own?
Formatting changes how code looks and never what it means; linting reads what it means and flags what is likely wrong. Both are static, both cost milliseconds, and neither replaces tests. That much is ordinary Python hygiene and this page assumes it.
What is worth your time here is the part specific to this repository: which tool owns which file, the fact that you, your git hooks, and CI all invoke the same mise run task, the two Ruff rule families that matter for an agent, and the structural checks that keep every course page readable as part of one course.
How do you format and check everything?
Run them now; the rest of the page explains what just happened. From the repository root:
mise run format:core
mise run check:core
format:core may modify files; review the diff afterward. check:core runs the docs rules and build, dprint, Python lint/types, core license profiles, shell checks, and workflow lint without intentionally rewriting sources.
check:core is the deterministic, account-free, model-, container-, cluster-, cloud-, and advisory-network-free half of the gate. The full root mise run check adds infrastructure validation, all five license profiles, and the network-backed check:vuln advisory audit.
If either comes back red, the diagnostic names the rule, the file, and the line — a Ruff finding and a docs-structure finding read the same way:
T201 `print` found
--> src/agent/tools.py:42:5
docs/4. Quality/4.1. Linting.md: FAQ heading must end with ?: ## How strict is Ruff
mise run format fixes neither: the first is a code change you make, the second a heading you edit.
Never silence a warning to get a green result
The temptation, under deadline, is # noqa, a loosened type, or a weakened assertion. That does not fix anything — it deletes the evidence and leaves the defect, and it teaches the next reader that the gates are decorative. Fix the cause, or leave it failing and say so. A suppression is a claim that you know better than the rule; write down why, in the code, or do not make the claim. What a legitimate exception looks like shows the standard this repo holds itself to.
Which tools own which files?
Every file in the repository has exactly one owner. This table is the map:
| Files | Formatter/checker |
|---|---|
| Python | Ruff format and import sorting; Ruff lint; ty types |
| Markdown/JSON/TOML/YAML/Dockerfile | dprint |
| Shell | shfmt and ShellCheck |
| GitHub Actions | actionlint |
| Python metadata/lock | validate-pyproject and uv lock --check |
The root tasks delegate to the component task instead of duplicating flags. Inside agents/python, mise run check is not a single command: it depends on three sub-tasks—check:format, check:lint, and check:types—that mise runs in parallel. That is why the whole static gate returns in seconds and stays cheap enough to run on every commit:
check:lintrunsuv run ruff check, whilecheck:typesrunsuv run ty check; ty is pre-1.0, so it is range-pinned until it stabilizes.check:formatis more than style. It runsvalidate-pyproject,ruff format --check, anduv lock --check— the last fails ifuv.lockhas drifted frompyproject.toml. Reproducibility is linted here, not just indentation: a dependency edit that was never re-locked is a warning, exactly like a strayprint.
The root mise run check wraps this Python gate together with docs, links, shell, workflow, infrastructure, full license, and vulnerability checks so one maintainer command covers the whole repository.
How strict is Ruff?
Very. The project selects a broad set of rule families, and two of them matter most for an agent:
S(flake8-bandit) is the security family: hardcoded secrets,subprocesswithshell=True, unsafe deserialization,assertin production paths. It is the static half of the story 4.6. Security tells at runtime.ASYNC(flake8-async) flags a blocking call insideasync def. An ADK agent's callbacks and tools run on an event loop; one accidental synchronous read there stalls the whole turn.
Six more are worth knowing once you have hit them, and none of them changes what you run today.
Deeper: the other rule families and why they are selected
LOG(flake8-logging) catches misused logging. The safe-error guardrail in 4.5. Guardrails depends on logging the real exception for operators while returning a stable message to the client — this family keeps that discipline honest.T20(flake8-print) rejects strayprint(), which would otherwise leak into the agent's stdout and telemetry.ARG(flake8-unused-arguments) flags a parameter a function ignores — often a callback that forgot to use thetool_contextor args ADK hands it.SLF(flake8-self) rejects reaching into another object's_privatemembers, keeping module boundaries real.ERA(eradicate) deletes commented-out code before it rots into confusion.PGH(pygrep-hooks), among other checks, rejects a blanket# noqaor# type: ignorewith no rule code — it enforces the exact discipline the next section preaches.
The format side is small and non-negotiable; the complete select list lives in pyproject.toml and prose should not re-paste all thirty-one families:
[tool.ruff]
line-length = 120
[tool.ruff.format]
docstring-code-format = true
line-ending = "lf"
quote-style = "double"
What does a legitimate, documented exception look like?
Zero suppressions is unrealistic; undocumented, blanket suppressions are the problem. A defensible exception is scoped to a file or a single finding, names the specific rule or vulnerability id, and states why in a comment right there.
The repo's own per-file-ignores — a rule switched off for one path only — are the model. Each ignore is one code with a written reason, not a file-wide # noqa:
[tool.ruff.lint.per-file-ignores]
"src/agent/config_check.py" = [
"T201", # the config:check CLI prints its report to stdout by design
]
"tests/**" = [
"S101", # assert
"T201", # print statement allowed in tests
]
config_check.py is a CLI whose whole job is to print the resolved configuration, so T201 (no print) is genuinely wrong for that file only; tests legitimately use bare assert (S101) and diagnostic print (T201). Nothing else in src/ gets those passes.
The same standard applies to the dependency audit. Root mise run check:vuln calls check-vulnerabilities.sh, which exports five hash-pinned profiles from the locks and runs pip-audit --strict over each one. Every profile is exported again with a deliberately pre-populated ambient Python environment, and the clean and pre-populated lock-export verdicts must be byte-identical before the advisory call. It has no finding-specific advisory ignore; its single non-PyPI model-wheel carve-out verifies the exact source and hash before excluding that unauditable requirement. If a future exception is unavoidable, its justification belongs beside the script and must name the id, reachability, residual risk, and removal condition. 4.6. Security owns that policy.
A reviewer can audit an exception like these in one line: which rule, which file or id, why, and when it should be revisited. That is the bar — if you cannot write that sentence, you have not earned the suppression.
How do the same checks run locally and in CI?
There is no separate CI configuration duplicating flags. Three callers invoke the same mise run tasks: you, the git hooks, and GitHub Actions. A green pre-commit therefore means a green CI for the same reasons, not by coincidence:
flowchart TD
subgraph vocab["One vocabulary — root tasks delegate to the component task"]
FMT["mise run format:core"]
FMTC["mise run format:dprint {staged}<br/>+ format:python"]
SCOPED["ten globbed check:* tasks"]
CHK["mise run check:core"]
TST["mise run test"]
SEC["mise run secure:staged"]
end
DEV["Developer<br/>runs on demand"] --> FMT
DEV --> CHK
DEV --> TST
PC["lefthook pre-commit<br/>parallel: false"] --> FMTC
FMTC --> SCOPED --> SEC
PP["lefthook pre-push<br/>parallel: false"] --> CHK --> TST
CI["ci.yml (push + pull_request + weekly)<br/>install:validation"] --> DOC["mise run doctor"] --> FULL["mise run format + check<br/>core + infrastructure"]
CI --> TST
CI --> EXTRA["smoke:host · redteam · eval:validate<br/>then: git status --porcelain must be empty"]
Diagram in words: Developers run format, check, and test directly. Pre-commit orders two formatters, ten path-scoped checks, and the staged security scan. Pre-push runs the complete core check before tests. CI installs the validation profile, runs the base doctor, then the full format, check, test, host smoke, red-team, evaluation-validation, and clean-tree gates.
lefthookpre-commit runsformat:dprint,format:python, ten globbed checks, thensecure:staged; pre-push runscheck:corebeforetest. Priorities keep formatting ahead of checks and the staged scan.
ci.ymlruns the narrowinstall:validationprofile, thendoctor, the fullformat,check, andtest, followed bysmoke:host,redteam, andeval:validate.- The last CI step is what makes formatting non-optional:
test -z "$(git status --porcelain)"fails the build if anything is uncommitted — including a file theformatstep would have rewritten. You cannot skip the formatter and still merge; CI runs it and rejects the diff if you left work for it. The same step guards regenerated artifacts (lockfiles, generated docs), so "generated files are committed" is a merge gate, not a convention.
Why do docs have structural checks?
A site that renders can still have a broken teaching contract. scripts/check_conventions.py runs before Zensical builds and enforces structure a Markdown parser would not. Concretely, every course page must:
- Start with YAML front matter carrying a
descriptionfield — parsed as real YAML, so an unquotedcolon-spaceinside it fails the check rather than silently corrupting the render. - Contain at least one
##heading, and every##heading must end with a literal?(the FAQ contract these pages follow). - Avoid machine-specific absolute paths (a home directory, a local
file://URL) and the obsolete local registry hostname, so the rendered text stays portable across every reader's machine. - Carry the page frame: an "In one glance" block after the H1, one of three closing headings, and the collapsible, link-label, and snippet rules that keep every page reading as one course (8.4. Documentation lists them all).
Break one and the failure names the page and the rule, so you can self-diagnose without reading the script. These checks are why this very page is structured as questions.
Deeper: why a config block pasted into a page drifts from its source
dprint formats embedded TOML/JSON/YAML/Dockerfile blocks inside Markdown, so a hand-pasted config excerpt can drift from the source's exact bytes on the next format — and a drifted excerpt on a page teaching that source is worse than no excerpt. Prefer a --8<-- snippet include that pulls verbatim from the real file at build time, and only paste an excerpt when it is already in the formatter's canonical form.
What proves this page worked?
mise run format:core
git diff --check
mise run check:core
Continue only after reviewing formatter changes and resolving every warning at its cause. A warning-free gate is part of correctness, not optional polish.
You are done when:
mise run check:coreexits green, with every warning fixed at its cause rather than suppressed.- Re-running
mise run format:corechanges nothing, andgit diff --checkreports no whitespace errors. - You can name the tool that owns a
.py, a.md, a.sh, and a workflow file without re-reading the table. - Any suppression you wrote names one rule or one id and carries a written reason beside it.
Continue to 4.2. Testing when mise run check:core is green and you can justify every suppression in the repository in one sentence each.