Writing Lean Proofs
Trail of BitsOfficialPublished by Trail of Bits
- Skill
- Tool integration
- Software development
- CC-BY-SA-4.0
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a rigorous methodology for formalizing mathematics and software specifications in Lean 4. It emphasizes a top-down design approach where definitions and API statements are established before proofs are written. By treating statements as stable interfaces and proofs as disposable, it enables scalable, parallel development. The skill covers the entire lifecycle of a Lean project, from initial sorry-based skeleton construction and API design to mechanical verification, linter configuration, and performance optimization.
What it does
- Provides patterns for designing Lean 4 libraries and definitions.
- Outlines a top-down workflow for building proof skeletons with sorry placeholders.
- Defines best practices for proof structure, including calc blocks and focusing.
- Details strategies for mechanical verification and CI integration.
- Offers guidance on diagnosing performance issues and maxHeartbeats timeouts.
- Provides rules for writing custom linters and macros.
When to use it
- When starting a new formalization project in Lean 4.
- When refactoring long, fragile tactic proofs into maintainable lemmas.
- When designing new types or structures for a Lean library.
- When setting up CI pipelines and linters for Lean projects.
- When diagnosing slow proofs or unexpected timeouts.
Advantages
- Promotes highly maintainable and readable proof code.
- Enables parallel development through independent sorry-based work units.
- Reduces technical debt by enforcing API-first design.
- Provides clear, actionable criteria for proof extraction and refactoring.
- Ensures project stability through automated linter and axiom verification.
Usage examples
- “How should I structure a new Lean 4 library for group theory?”
- The agent will advise defining the structures first, bundling properties using FunLike or SetLike, and writing the full API (ext, simp, coercion lemmas) in the same file before implementing any theorems.
- “My proof is failing with a maxHeartbeats timeout. How do I fix it?”
- The agent will guide you to measure the cost using #count_heartbeats, suggest decomposing the goal into smaller lemmas, or optimizing the definition's reduction behavior rather than simply increasing the budget.
- “How can I ensure my Lean project is ready for multiple contributors?”
- The agent will recommend creating a sorry-based skeleton, setting up a leanblueprint for dependency tracking, and configuring a strict CI environment with project-specific linters.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/trailofbits/writing-lean-proofs/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip writing-lean-proofs.zip -d .agents/skills
Documentation by Trail of Bits
Writing Lean Proofs
Contents
When to Use
When NOT to Use
The workflow
The extraction ladder
Quick reference
Rationalizations to reject
References
Structured Lean 4 proof writing and library design, distilled from Mathlib's style and review conventions and from the methodology of large formalization projects (Liquid Tensor Experiment, PFR, Fermat's Last Theorem).
Core principle: design top-down, prove bottom-up. Lean propositions are
proof-irrelevant — only a theorem's statement can affect later declarations.
Statements are the stable interface; proofs are disposable and freely
replaceable. Put design effort into definitions and statements, then fill in
proofs against skeletons that already compile (modulo sorry).
When to Use
Proving theorems in Lean 4, from single lemmas to multi-file developments
Formalizing mathematics, protocols, or software specifications in Lean
Defining new types, structures, or functions in a Lean library
Reviewing Lean code for readability, maintainability, or Mathlib readiness
Refactoring a long or fragile tactic proof into lemmas
Setting up a formalization project that several people or agents will contribute to in parallel
Setting up CI, linters, or verification gates for a Lean project — do this at project start, before patterns propagate
Diagnosing slow proofs,
maxHeartbeatstimeouts, or expensive reductionWriting custom tactics, macros, or project-specific linters
When NOT to Use
Lean 4 as a general-purpose programming language (no proofs involved) — most of this skill targets proof and API structure
Coq, Isabelle, Agda, or Lean 3 — conventions and tactic names differ; Lean 3 idioms (
ge_or_gtlinting,discrete_field) are obsoleteVerified-software Lean projects with their own house style (e.g. spec-traceability-first codebases): Mathlib conventions are the community default, but check the project's CONTRIBUTING first and defer to it
The workflow
1. Design definitions and their API first
Definitions carry the design weight. Before proving anything about a new concept:
Prefer total functions with junk values over subtypes or
Optionin signatures (Mathlib:(0 : ℝ)⁻¹ = 0). Side conditions then appear only on the lemmas that need them, not at every use site.Bundle: new morphism kinds are structures with a
FunLikeinstance; new subobject kinds useSetLike; carry property proofs as structure fields, not separateIsHom-style predicates.Pick the canonical spelling (simp-normal form) for every concept with multiple equivalent forms, and state all API lemmas for that form only.
Write the API in the same file, immediately:
ext,@[simp], coercion, and injectivity lemmas — before the definition is used anywhere. Downstream proofs use the API, neverunfold/show ... from rfl.
See library-design.md for the full set of design rules with rationale.
2. Build a sorry skeleton
State everything before proving anything, at every scale:
Project scale: state the target theorem and the lemmas it needs, all with
:= sorry, and make the file compile. Eachsorryis now an independent work unit — a contributor (human or LLM) can discharge one without understanding the rest. This is how LTE, PFR, and FLT scale to dozens of parallel contributors.Proof scale: inside a proof, lay out the
have/suffices/calcskeleton withsorryjustifications, get Lean to accept the structure, then fill each step. Keeping the structure intact is what produces useful error messages while you work.
example (a b c d : ℝ) (h : c = d * a + b) (h' : b = a * d) : c = 2 * a * d := by
calc
c = d * a + b := sorry
_ = d * a + a * d := sorry
_ = 2 * a * d := sorry3. Fill goals, one focused goal at a time
Every new subgoal gets a focusing dot
·with an indented block — never leave several goals active in unfocused sequence (Mathlib'smultiGoallinter enforces this). This is what kills fragile goal-ordering dependence.Open each block with a redundant
showstating its goal. The proof works without it; reviewers and future editors need it. Ifshowwould change the goal, usechangeinstead — keep stated goals honest.Chained rewrites of (in)equalities become
calcblocks, relations aligned vertically.havefor forward stepping stones ("we first establish X");sufficesfor backward reduction ("it suffices to show X").While drafting, annotate the goal state as a comment before non-obvious tactics — emitted by Lean, never imagined. In a headless workflow, insert
trace_stateat the point of interest or a deliberatedonewhere goals should be closed, then runlake env lean Path/To/File.lean; copy the reported hypotheses, case name, and target. Strip routine probes after the proof works. This is the single most effective technique for LLM-written proofs (see llm-techniques.md).
See proof-style.md for the full tactic-style rules, and naming-conventions.md for naming lemmas so their names are guessable from their statements.
4. Verify mechanically
Do not eyeball-check style — run the checkers. lake build is the floor,
and it is only the floor: sorry is a warning, so a green build exits 0
with sorries still present.
Gate unproved obligations by asking the kernel, never by grepping.
#print axioms myTheoremfor a spot check; for CI, collect axioms per declaration withLean.collectAxiomsand assert the whole expected footprint ([propext, Classical.choice, Quot.sound]unless deliberately widened), so a straysorryor a new trust assumption likenative_decidefails loudly. Grep is wrong in both directions: it matches the word in comments, and it misses a theorem whose own text is clean but which applies an unproved helper. Working script in linting.md.Choose lints by project role and put them in CI at project start. Do not enable
linter.mathlibStandardSetwholesale in a downstream project: it combines proof-maintenance checks with public-API checks, house style, and Mathlib-specific repository policy. For a self-contained proof, start withlinter.auxLemma,linter.style.maxHeartbeats,linter.style.multiGoal,linter.style.setOption, andlinter.style.show. A reusable library should additionally enablelinter.flexible,linter.style.missingEnd,linter.style.openClassical, and the twounused*InTypechecks. TreatnativeDecideas a trust-policy choice and formatting or deprecated-syntax checks as project style. No warning gates anything unless warnings fail the build. Run Batteries' declaration-level#lintchecks, includingsimpNF, separately. Verify every option against the pinned Mathlib source and with a known-trigger fixture: a misspelledweak.option is intentionally ignored. The complete 26-member audit and lakefile profiles are in linting.md.Write a custom linter for every project-specific convention (simp-set discipline, summary-lemma coverage, required attributes) — a declaration-level
@[env_linter]is one structure, and it is the only thing that reliably catches "the attribute is missing on 29 of 30 declarations". See linting.md for the recipe and the engineering rules (vacuity anchors, prove-it-can-fail, allowlists).
The extraction ladder
When does proof structure graduate into separate lemmas?
Before extracting, state the fragment's type and search by shape. Put the proposed statement in a scratch
example, runexact?andapply?on the bare goal, then try a type-pattern and source search. If an existing theorem fits, use it. Do not report an API gap without recording the searches that failed.A sub-argument repeats within one proof → name it as a local
have.theorem min_comm (a b : ℝ) : min a b = min b a := by have h : ∀ x y : ℝ, min x y ≤ min y x := by intro x y apply le_min · show min x y ≤ y exact min_le_right x y · show min x y ≤ x exact min_le_left x y apply le_antisymm · show min a b ≤ min b a exact h a b · show min b a ≤ min a b exact h b aThe statement is independently interesting, or extraction sheds hypotheses the sub-argument does not need → standalone lemma. Dropping unneeded hypotheses is the stronger trigger: the extracted lemma becomes more general than the proof it came from.
The proof reads as "long and unwieldy" → split it. This is Mathlib's review criterion, and it is deliberately qualitative — there is no line threshold. Resolve doubt by attempting the extraction: if a fragment has a clean statement, it wanted to be a lemma.
Quick reference
| Rule | Why | Enforced by |
|---|---|---|
Never unfold definitions downstream; erw or trailing rfl = missing API | API lemmas are the abstraction boundary | review ("missing API" smell) |
Terminal simp stays unsqueezed; non-terminal simp becomes simp only [...] | squeezed terminal calls bury the key lemmas and break on renames | style guide |
One focused goal at a time (· blocks) | kills goal-ordering fragility | linter.style.multiGoal |
show must not change the goal (use change) | stated goals stay honest | linter.style.show |
No set_option debug/trace/profiler or unscoped maxHeartbeats in final code | debugging scaffolding | linter.style.setOption |
State lemmas in simp-normal form, < not > | simp matches syntactically | simpNF linter |
| Golf only when the result is at least as readable; trivial results exempt | short ≠ better | review |
Fact instances are local, never global | global instances degrade all typeclass search | review |
| Name lemmas from their statements (see naming reference) | names become guessable without search | linter.style.nameCheck catches only __; #lint defsWithUnderscore and review cover more |
| Search a bare goal by shape before writing a helper or claiming an API gap | names are not always guessable from the target | exact?, apply?, type/source search |
| Generally one tactic invocation per line; a one-line closing proof is the exception | preserves readable proof structure without inventing an absolute rule | style guide |
Gate sorry with collectAxioms/#print axioms, never grep | grep matches comments, misses unproved helpers | axiom audit in CI |
| Prefer simp-lemma LHSs keyed on structure, not numerals; one spelling per constant | 2 ^ 32 never matches a goal normalized to 4294967296 | simpNF, review |
Re-derive every simp only list with simp? at its own site | lists do not transfer between look-alike goals | linter.flexible |
Every maxHeartbeats override is an unproven claim — measure before believing | copy-pasted budgets carry no information | #count_heartbeats, bisection |
Conditional simp lemma fires shallow but not deep → raise maxDischargeDepth (default 2) | chained side conditions truncate silently, no diagnostic | diagnosis (proof-style, simp discipline) |
| Every project-specific convention gets a custom linter, in CI from day one | review misses the 29-of-30 failure mode | @[env_linter] + #lint |
Full rationale for each row, plus the library-level anti-patterns, in anti-patterns.md.
Rationalizations to reject
| Excuse | Reality |
|---|---|
| "The proof compiles, ship it" | Compiling is the floor. A monolithic tactic block that only Lean can read will break silently at the next Mathlib bump and no one will be able to repair it. |
| "Unfolding the definition is simpler than writing API lemmas" | Every downstream unfold couples a proof to the implementation. The first refactor breaks all of them at once. Write the missing lemma. |
| "Squeezing every simp makes the proof faster and more robust" | Backwards for terminal simp calls: the squeezed list breaks on every rename and drowns the signal. Squeeze non-terminal calls only. |
| "It's shorter, therefore better" | Mathlib review policy: golfing is fine only when it does not sacrifice readability. Length is not the target; legibility is. |
| "I'll restructure it into lemmas after it works" | After it works, the structure is load-bearing and tangled. State the skeleton first; the lemmas fall out for free. |
"Adding show lines is redundant noise" | They are redundant to the kernel and essential to every human or model that reads the proof next. |
| "This helper is too specific to be a lemma" | If it has a clean statement, extract it — dropping the hypotheses it doesn't need usually reveals it was general all along. |
| "We'll add linters once the library stabilizes" | Backwards: patterns propagate by copy-paste, so a deferred linter meets a 400-warning backlog instead of one bad line. Enable what is already clean and gate it now. |
| "The check passed, so we're clean" | A check that can't fail proves nothing — sweeps reach zero files, misspelled weak. options are ignored, pipelines swallow exit codes. Prove every gate can fail before trusting that it passes. |
| "The proof is slow, raise maxHeartbeats" | An unmeasured budget is a claim, not a fix — and it masks the regression the next reader needs to see. Measure with #count_heartbeats; restructure the definition or decompose the goal. |
References
library-design.md — definitions, APIs, bundling, abstraction boundaries, spec-driven project decomposition
proof-style.md — tactic proof structure: calc, have/suffices, focusing, and simp discipline including the why-doesn't-this-lemma-fire diagnoses (discharge depth, traversal order, numeral spellings)
naming-conventions.md — Mathlib naming so lemma names are computable from statements
anti-patterns.md — recognized anti-patterns, why each is harmful, and which linter catches it
llm-techniques.md — evidence-based techniques specific to LLM-written proofs
linting.md — axiom-based sorry gates, enabling project-specific linter profiles in CI early, the full Mathlib standard-set audit, adopting linters with a backlog, writing custom linters for project-specific constructs, and proving every gate can fail
performance.md — measuring per-declaration cost, where reduction cost comes from, optimizing definitions without losing semantics
tactics.md — metaprogramming discipline: extension-point selection, metavariable and recovery safeguards, bounded search, actionable errors, structured tracing, generated declarations, and failure-surface testing
Files
- ATTRIBUTION.mdAdded by AutoGineer951 B
- LICENSELicence file added by AutoGineer19.7 KiB
- SKILL.md15.6 KiB
- references/anti-patterns.md6.9 KiB
- references/library-design.md7.7 KiB
- references/linting.md22.1 KiB
- references/llm-techniques.md5.4 KiB
- references/naming-conventions.md2.5 KiB
- references/performance.md5.3 KiB
- references/proof-style.md7.8 KiB
- references/tactics.md16.1 KiB
references/linting.md
Linting: gates in CI early, custom linters for your own constructs
Contents
The sorry gate: ask the kernel, not grep
Choose a project-specific linter profile
Adopting a linter that is not yet clean
Custom linters
Prove every check can fail
Set up an appropriate linter set, gated in CI, at the start of the project — and
write a custom linter for every project-specific convention. Bugs and
unidiomatic proof patterns propagate by copy-paste: the first bad simp call
becomes the template for the next fifty, and a linter adopted late meets a
backlog instead of a single bad line (measured in one project: one deferred
linter had accumulated 438 warnings across 25 files; adopted on day one it
would have flagged one). Review does not substitute — the first simp-normal-
form linter found 100+ redundant simp lemmas in Mathlib that had all passed
expert maintainer review. This matters doubly when a model writes the Lean:
models follow a convention almost everywhere and forget the step with no
visible symptom; only a linter reliably catches "the attribute is missing on
29 of 30 declarations".
The sorry gate: ask the kernel, not grep
Gate unproved obligations with axiom collection, never grep sorry. Grep is
wrong in both directions: it matches the word in comments and docstrings, and
it misses a theorem whose own text is clean but which applies an unproved
helper. Lean.collectAxioms catches exactly the real cases:
-- Save as scripts/AxiomCheck.lean in YOUR project; CI runs:
-- lake env lean scripts/AxiomCheck.lean
import MyProject
open Lean
/-- Axioms the library is known and intended to depend on. Anything else —
a new `sorry`, a stray `native_decide` — fails loudly. -/
def expectedAxioms : List Name := [``propext, ``Classical.choice, ``Quot.sound]
/-- Is this declaration defined in one of our own modules (not Mathlib etc.)? -/
def isOurs (env : Environment) (n : Name) : Bool :=
match env.getModuleIdxFor? n with
| none => false -- defined in the current file, not the library
| some idx =>
match env.header.moduleNames[idx.toNat]? with
| some m => (`MyProject).isPrefixOf m
| none => false
run_cmd Elab.Command.liftCoreM do
let env ← getEnv
let mut checked := 0
for (n, _) in env.constants.toList do
unless isOurs env n && !n.isInternal && !n.hasMacroScopes do continue
checked := checked + 1
let axs ← collectAxioms n
if axs.contains ``sorryAx then throwError "unproved: {n}"
let bad := axs.filter (!expectedAxioms.contains ·)
unless bad.isEmpty do throwError "unexpected axioms on {n}: {bad.toList}"
if checked == 0 then
throwError "module filter matched nothing — the check is vacuous"Assert the whole footprint, not just sorryAx: listing the expected
axioms turns "we think nothing else crept in" into a checked claim, so a new
trust assumption (someone adding native_decide) fails instead of landing
silently. And note the final line — see "Prove every check can fail" below.
Choose a project-specific linter profile
Do not enable linter.mathlibStandardSet wholesale just because a project
depends on Mathlib. At Mathlib commit
50a1a360,
the set contains 26 lints with four different jobs:
proof robustness and debugging;
reusable public-API design;
optional source-formatting conventions; and
Mathlib's own repository policy.
Those are appropriate defaults for Mathlib, not one indivisible policy for every downstream package. Select individual options according to who will consume the code, and re-audit the list when updating the pinned Mathlib revision.
Recommended profile: self-contained project or proof
For a finished proof, executable specification, verification artifact, or application whose declarations are not a downstream API, start with the checks that prevent fragile proof scripts and leftover debugging state:
leanOptions := #[
⟨`weak.linter.auxLemma, true⟩,
⟨`weak.linter.style.maxHeartbeats, true⟩,
⟨`weak.linter.style.multiGoal, true⟩,
⟨`weak.linter.style.setOption, true⟩,
⟨`weak.linter.style.show, true⟩
]Add linter.flexible if the artifact will be maintained across dependency
updates; it makes intermediate broad automation such as bare simp more
explicit, but can impose substantial cleanup on a short-lived proof. Add
the deprecated-syntax checks only when the project deliberately follows
Mathlib's tactic style. Do not enable public-API or documentation lints
merely to make the profile resemble Mathlib.
Recommended profile: reusable library
For a library intended to be imported by other projects, also protect API generality, module structure, and upgrade resilience:
leanOptions := #[
⟨`weak.linter.auxLemma, true⟩,
⟨`weak.linter.flexible, true⟩,
⟨`weak.linter.style.maxHeartbeats, true⟩,
⟨`weak.linter.style.missingEnd, true⟩,
⟨`weak.linter.style.multiGoal, true⟩,
⟨`weak.linter.style.openClassical, true⟩,
⟨`weak.linter.style.setOption, true⟩,
⟨`weak.linter.style.show, true⟩,
⟨`weak.linter.unusedDecidableInType, true⟩,
⟨`weak.linter.unusedFintypeInType, true⟩
]This is a starting profile, not another aggregate to copy blindly. A library
with intentionally broad automation may omit linter.flexible; a package
whose modules are generated may omit structural style checks.
linter.style.nameCheck is not a member of mathlibStandardSet; it defaults
to true and catches double underscores, not the whole Mathlib naming
convention. A library adopting that convention should also run Batteries'
#lint defsWithUnderscore and use review for the rules no linter covers.
Apply the classification per target or module, not merely per repository. A reusable library commonly includes terminal examples, tests, and regression fixtures for which its public-source policy is inappropriate.
Audit of every standard-set member
Enable means it belongs in the profile above. Consider means its value
depends on expected maintenance or local conventions. Policy requires an
explicit trust or repository decision. Avoid means that enabling it without
narrow scoping is likely to reject legitimate downstream code.
| Lint | What it protects | Reusable library | Self-contained project / proof |
|---|---|---|---|
linter.auxLemma | Avoids references to generated names such as _proof_1 that can change after unrelated edits | Enable | Enable |
linter.flexible | Makes non-terminal broad tactics such as bare simp explicit, reducing dependence on a changing environment | Enable for maintained libraries | Consider when upgrades matter |
linter.hashCommand | Flags silent # commands, and all # commands under warningAsError | Consider only in production modules that forbid such probes | Usually omit: #guard may be an intentional assertion |
linter.oldObtain | Replaces a Lean 3-style obtain form with an explicit proof block | Consider as readability style | Consider as readability style |
linter.privateModule | Flags nonempty modules that expose only private declarations | Consider for modules meant to be imported; omit terminal and test modules | Avoid for terminal test and proof modules |
linter.style.cases | Rejects Mathlib's discouraged cases' syntax | Consider when adopting Mathlib tactic style | Consider when adopting Mathlib tactic style |
linter.style.induction | Rejects Mathlib's discouraged induction' syntax | Consider when adopting Mathlib tactic style | Consider when adopting Mathlib tactic style |
linter.style.refine | Rejects Mathlib's discouraged refine' syntax | Consider when adopting Mathlib tactic style | Consider when adopting Mathlib tactic style |
linter.style.cdot | Enforces Mathlib's spelling of the centered dot | Project style | Project style |
linter.style.docString | Enforces Mathlib docstring formatting, not documentation coverage | Consider when adopting Mathlib documentation style | Usually omit |
linter.style.dollarSyntax | Prefers `< | over$` | Project style |
linter.style.emptyLine | Rejects blank lines within declarations | Project style; can be noisy | Project style; can be noisy |
linter.style.header | Enforces Mathlib's license header, module docstring, and import restrictions | Avoid unless deliberately matching the exact Mathlib repository policy | Avoid |
linter.style.lambdaSyntax | Prefers fun over λ | Project style | Project style |
linter.style.longLine | Enforces a configurable line-length limit | Project style | Project style |
linter.style.longFile | Enforces a chosen file-length limit; this option is a number, not a Boolean | Consider only with a locally chosen limit | Usually omit; generated artifacts may be long |
linter.style.multiGoal | Prevents tactics from depending silently on the order of several active goals | Enable | Enable for finished, maintainable proofs |
linter.style.nativeDecide | Warns about compiler-backed native_decide and decide +native | Policy: enable if the trust model forbids them | Policy: choose explicitly |
linter.style.openClassical | Discourages global classical scope that can hide unnecessarily strong theorem assumptions | Enable for public APIs | Consider when theorem statements are reused |
linter.style.maxHeartbeats | Requires an explanation next to scoped heartbeat overrides | Enable | Consider when budgets are allowed |
linter.style.missingEnd | Makes namespace and section boundaries explicit | Enable | Consider for multi-section files |
linter.style.setOption | Catches leftover traces, profilers, pretty-printer flags, and unscoped sensitive options | Enable outside dedicated fixtures | Enable outside dedicated fixtures |
linter.style.show | Prevents show from silently changing the target; requires honest change | Enable | Enable |
linter.style.whitespace | Enforces Mathlib declaration spacing | Project style | Project style |
linter.unusedDecidableInType | Removes an unnecessary Decidable assumption from a theorem's public type | Enable | Consider only when statements are reused |
linter.unusedFintypeInType | Removes or weakens an unnecessary Fintype assumption in a theorem's public type | Enable | Consider only when statements are reused |
The behavior classifications above come from the corresponding current
Mathlib implementations: auxiliary names,
flexible tactics,
# commands,
legacy obtain,
private modules,
deprecated syntax,
general style,
docstrings,
empty lines,
headers,
multiple goals,
whitespace,
and unused instances.
Make the selected profile an actual gate
Most standard-set members are off by default in dependent projects. Five mechanics matter:
leanOptionsreach every module, and-Don an unregistered option is a hard error — a Mathlib-providedlinter.style.*option breaks any module in your package that imports only core Lean. Theweak.prefix is the escape hatch:⟨weak.linter.style.multiGoal, true⟩` sets the option where it is registered and ignores it where it is not.Use fully qualified individual option names. Note that
linter.flexibleand the twolinter.unused*InTypeoptions are not underlinter.style. Reconfirm every name against the project's pinned Mathlib revision. Then add a known-trigger fixture for every option: theweak.prefix deliberately ignores unknown options, so a typo otherwise produces a green build with no linter running.A linter is only a gate if warnings fail the build. Check what you are already passing before adopting anything new: ~30 linters can be on and clean while nothing protects that compliance. Locking in what you already satisfy is cheaper and higher-value than any new adoption. Mechanically, warnings-as-errors is a CI step:
set -o pipefail # or the build failure vanishes into tee lake build MyProject 2>&1 | tee build.log # Vacuity anchor: Lake caches per-module artifacts and linter warnings are # emitted only when a module is *recompiled*. On a restored cache with # nothing to rebuild, build.log is empty, the grep below matches nothing, # and the gate reports clean over live warnings and sorries. Prove the # sweep reached something before trusting its silence. grep -qE "^(info: )?\[[0-9]+/[0-9]+\]" build.log \ || { echo "gate is vacuous: lake compiled no module" >&2; exit 1; } ! grep -n "warning:" build.log # any warning (linter or sorry) fails CIThe anchor is what makes this a gate rather than a report; the alternative is to build the gating job from a cold cache. This is the same discipline the
checked == 0guard enforces inAxiomCheck.leanabove, and the same one the sweep-counting rule below states in general — a check that cannot observe its own reach cannot fail.Anything outside the default build target rots silently. A test or regression file that nothing builds stops compiling and nobody notices. Give such files their own
lean_libtarget in the lakefile and build that target explicitly in CI.Run declaration-level linters too. The
leanOptionsprofiles above activate syntax and command linters; they do not replace Batteries'#lint, whose checks includesimpNF. Give the linter file a build target and execute it explicitly in CI.
When policy enables a lint, exempt a sanctioned violation locally, never
globally: set_option linter.style.nativeDecide false in immediately above
the one permitted use, with a comment saying why. The linter stays on
library-wide, so a new violation still fails, and the exemption is visible
exactly where a reviewer needs it.
Adopting a linter that is not yet clean
A gate that fails is not a gate. Enable a linter only once it reports zero; until then run it advisory with the measured backlog recorded next to it, and promote it when it reaches zero. Writing the count down is what keeps the backlog actionable and stops a later reader assuming the linter was rejected on principle.
Counts from a failed build are meaningless. Modules that never compiled were never linted; a partial build reports a partial count that reads exactly like a clean result. Confirm the build reached the end first.
Reported counts can be lower bounds.
linter.flexiblepropagates a "stain" that stops at the first fix, so pinning one flaggedsimpunmasks the next in the same ladder (measured undercounts of 3×). When a file has a repeated idiom, fix every occurrence in one pass.Never paste a linter's own
Try this:suggestion. The flexible linter's suggestedsimp only [...]list comes from re-running a defaultsimp, dropping the arguments the original call passed. Runsimp?in place instead. A tool's suggested fix is a hint about the shape of the fix, not a patch.Rank the backlog by warnings-per-edit, not warnings. One line can carry 37 warnings and cost one token; a copy-pasted six-line idiom can hold a third of the total. Sort the work by idiom, most-duplicated first.
Adopt for your consumers: Mathlib's set is calibrated for a million-line library with thousands of downstream users. A verification project with a small team should weight correctness- and trust-protecting linters up and house-style linters down (e.g.
hashCommandis wrong for a test suite whose#guards are the assertions).
Custom linters: every project convention worth having is worth one
Any new construct your project introduces — a simp-set discipline, a naming scheme for summary lemmas, an attribute that downstream automation consumes — will be misused, because nothing enforces it. Defining a linter is genuinely easy; write one alongside the construct, not after the first regression.
The declaration-level kind (what Batteries' #lint runs) is one structure:
@[env_linter] def mySummaryTagged : Batteries.Tactic.Lint.Linter where
noErrorsFound := "all execution summaries are tagged"
errorsFound := "summaries missing @[my_summary]:"
test n := do
-- full MetaM access: inspect the type, attributes, docstring, environment
...return (some msg) to flag, none to pass@[nolint mySummaryTagged] gives per-declaration exemptions for free. (The
other kind — a syntax-level linter à la linter.style.* — needs a
register_option, a Linter where run, and an initialize addLinter; the
extra boilerplate buys file-local set_option … false in opt-outs. Reach for
it when exemptions must be positional rather than per-declaration.) The
highest-value checks are coverage invariants no generic linter can
express — "every constructor the executor handles has a corresponding
soundness lemma" — where the failure mode (extend the executor, forget the
lemma) compiles cleanly and leaves no trace. Implement those as a
declaration-level linter that does its work anchored on one declaration and
returns none for everything else.
Engineering rules, each learned the hard way:
Prefer a linter over a script that greps source. Anything checked by scraping text (attributes, naming, doc comments) is checkable against the declaration, where comments, formatting, and renames cannot fool it — the same argument that makes
collectAxiomsbeatgrep sorry.Test the shape of a declaration, not its name. A suffix-keyed linter flags look-alikes and misses restatements; match the actual conclusion.
Know which side you are checking. A rewrite's LHS must match the goal; its RHS may deliberately use a different spelling that downstream lemmas expect. Any statement-syntax check must distinguish the two.
Private declarations are not where you think:
private theorem foolives at_private.<module>.0.foo, soenv.contains ``foo``reports it missing. UsemkPrivateNameCoreper candidate module.Do no expensive per-constant work while iterating
env.constants. Real work per foreign constant will not finish against a Mathlib-sized environment (a string-processing probe over it timed out at 400 s). A cheap module-membership filter per constant is fine — the axiom audit above does exactly that — but anything heavier belongs behind targetedenv.find?lookups, so cost is O(things you care about), not O(everything Mathlib defines).Self-police allowlists: report entries that no longer exist and entries that now satisfy the rule, or the list quietly outlives its justification.
Budget the check itself. A linter can exhaust a module's heartbeat budget normalizing a 110-equation match — turning a green module red. Prefer a documented local opt-out over abandoning the gate everywhere.
Say what the linter does not protect, in its docstring, so a future reader does not over-trust it.
Prove every check can fail before trusting that it passes
A check that silently never fires is indistinguishable from a clean codebase. For every new gate — linter, sweep, axiom audit:
Introduce a deliberate violation and a near-miss control; confirm the first is flagged and the second is not. This regularly finds vacuous linters (e.g. a normalization step that never matches, so every candidate "passes").
Build the vacuity check in permanently: anchor a sanity assertion on one known declaration ("this attribute must exist", "checked > 0"), so the gate fails loudly rather than reporting a codebase it never examined.
Watch the plumbing: plain
lean -Dname=valuerejects an unknown option, whilelean -Dweak.name=valueintentionally ignores one so packages can span modules with different imports. Verify a weak option in a module that imports its linter and include a known violation, or a typo can report zero findings. A#lintrun piped throughteewithoutpipefailexits 0 even when Lean crashed;git ls-files 'Proj/**/*.lean'silently excludes top-level files (*in a git pathspec matches/;**/requires an intervening directory — the more explicit-looking spelling is the narrower one). Print and assert the number of files each sweep reached.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Trail of Bits
- Publisher
- Trail of Bits (Official)
- Source
- trailofbits/skills · plugins/writing-lean-proofs/skills/writing-lean-proofs · commit e4dd0bb
- Licence
- Creative Commons Attribution-ShareAlike 4.0 International (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Trail of Bits.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download writing-lean-proofs.zip