Generated banner for Writing Lean Proofs
Generated logo for Writing Lean Proofs

Writing Lean Proofs

Trail of BitsOfficialPublished by Trail of Bits

  • Skill
  • Tool integration
  • Software development
  • CC-BY-SA-4.0
  • No issues found

From the author

Writes and reviews structured Lean 4 proofs and designs Lean libraries following Mathlib conventions. Use when proving theorems in Lean, formalizing mathematics or specifications in Lean 4, defining new types or definitions in a Lean library, reviewing Lean proofs for readability and maintainability, refactoring long tactic proofs into lemmas, filling in sorry placeholders in a Lean development, setting up CI or linters for a Lean project, diagnosing slow proofs or maxHeartbeats timeouts, or writing custom tactics, macros, or linters.
— the description by Trail of Bits

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

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/trailofbits/writing-lean-proofs/download
  2. Unzip 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, maxHeartbeats timeouts, or expensive reduction

  • Writing 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_gt linting, discrete_field) are obsolete

  • Verified-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 Option in 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 FunLike instance; new subobject kinds use SetLike; carry property proofs as structure fields, not separate IsHom-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, never unfold/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. Each sorry is 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/calc skeleton with sorry justifications, 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     := sorry
3. 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's multiGoal linter enforces this). This is what kills fragile goal-ordering dependence.

  • Open each block with a redundant show stating its goal. The proof works without it; reviewers and future editors need it. If show would change the goal, use change instead — keep stated goals honest.

  • Chained rewrites of (in)equalities become calc blocks, relations aligned vertically.

  • have for forward stepping stones ("we first establish X"); suffices for 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_state at the point of interest or a deliberate done where goals should be closed, then run lake 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 myTheorem for a spot check; for CI, collect axioms per declaration with Lean.collectAxioms and assert the whole expected footprint ([propext, Classical.choice, Quot.sound] unless deliberately widened), so a stray sorry or a new trust assumption like native_decide fails 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.mathlibStandardSet wholesale 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 with linter.auxLemma, linter.style.maxHeartbeats, linter.style.multiGoal, linter.style.setOption, and linter.style.show. A reusable library should additionally enable linter.flexible, linter.style.missingEnd, linter.style.openClassical, and the two unused*InType checks. Treat nativeDecide as 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 #lint checks, including simpNF, separately. Verify every option against the pinned Mathlib source and with a known-trigger fixture: a misspelled weak. 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?

  1. Before extracting, state the fragment's type and search by shape. Put the proposed statement in a scratch example, run exact? and apply? 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.

  2. 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 a
  3. The 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.

  4. 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

RuleWhyEnforced by
Never unfold definitions downstream; erw or trailing rfl = missing APIAPI lemmas are the abstraction boundaryreview ("missing API" smell)
Terminal simp stays unsqueezed; non-terminal simp becomes simp only [...]squeezed terminal calls bury the key lemmas and break on renamesstyle guide
One focused goal at a time (· blocks)kills goal-ordering fragilitylinter.style.multiGoal
show must not change the goal (use change)stated goals stay honestlinter.style.show
No set_option debug/trace/profiler or unscoped maxHeartbeats in final codedebugging scaffoldinglinter.style.setOption
State lemmas in simp-normal form, < not >simp matches syntacticallysimpNF linter
Golf only when the result is at least as readable; trivial results exemptshort ≠ betterreview
Fact instances are local, never globalglobal instances degrade all typeclass searchreview
Name lemmas from their statements (see naming reference)names become guessable without searchlinter.style.nameCheck catches only __; #lint defsWithUnderscore and review cover more
Search a bare goal by shape before writing a helper or claiming an API gapnames are not always guessable from the targetexact?, apply?, type/source search
Generally one tactic invocation per line; a one-line closing proof is the exceptionpreserves readable proof structure without inventing an absolute rulestyle guide
Gate sorry with collectAxioms/#print axioms, never grepgrep matches comments, misses unproved helpersaxiom audit in CI
Prefer simp-lemma LHSs keyed on structure, not numerals; one spelling per constant2 ^ 32 never matches a goal normalized to 4294967296simpNF, review
Re-derive every simp only list with simp? at its own sitelists do not transfer between look-alike goalslinter.flexible
Every maxHeartbeats override is an unproven claim — measure before believingcopy-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 diagnosticdiagnosis (proof-style, simp discipline)
Every project-specific convention gets a custom linter, in CI from day onereview 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

ExcuseReality
"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

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.

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.

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.

LintWhat it protectsReusable librarySelf-contained project / proof
linter.auxLemmaAvoids references to generated names such as _proof_1 that can change after unrelated editsEnableEnable
linter.flexibleMakes non-terminal broad tactics such as bare simp explicit, reducing dependence on a changing environmentEnable for maintained librariesConsider when upgrades matter
linter.hashCommandFlags silent # commands, and all # commands under warningAsErrorConsider only in production modules that forbid such probesUsually omit: #guard may be an intentional assertion
linter.oldObtainReplaces a Lean 3-style obtain form with an explicit proof blockConsider as readability styleConsider as readability style
linter.privateModuleFlags nonempty modules that expose only private declarationsConsider for modules meant to be imported; omit terminal and test modulesAvoid for terminal test and proof modules
linter.style.casesRejects Mathlib's discouraged cases' syntaxConsider when adopting Mathlib tactic styleConsider when adopting Mathlib tactic style
linter.style.inductionRejects Mathlib's discouraged induction' syntaxConsider when adopting Mathlib tactic styleConsider when adopting Mathlib tactic style
linter.style.refineRejects Mathlib's discouraged refine' syntaxConsider when adopting Mathlib tactic styleConsider when adopting Mathlib tactic style
linter.style.cdotEnforces Mathlib's spelling of the centered dotProject styleProject style
linter.style.docStringEnforces Mathlib docstring formatting, not documentation coverageConsider when adopting Mathlib documentation styleUsually omit
linter.style.dollarSyntaxPrefers `<over$`Project style
linter.style.emptyLineRejects blank lines within declarationsProject style; can be noisyProject style; can be noisy
linter.style.headerEnforces Mathlib's license header, module docstring, and import restrictionsAvoid unless deliberately matching the exact Mathlib repository policyAvoid
linter.style.lambdaSyntaxPrefers fun over λProject styleProject style
linter.style.longLineEnforces a configurable line-length limitProject styleProject style
linter.style.longFileEnforces a chosen file-length limit; this option is a number, not a BooleanConsider only with a locally chosen limitUsually omit; generated artifacts may be long
linter.style.multiGoalPrevents tactics from depending silently on the order of several active goalsEnableEnable for finished, maintainable proofs
linter.style.nativeDecideWarns about compiler-backed native_decide and decide +nativePolicy: enable if the trust model forbids themPolicy: choose explicitly
linter.style.openClassicalDiscourages global classical scope that can hide unnecessarily strong theorem assumptionsEnable for public APIsConsider when theorem statements are reused
linter.style.maxHeartbeatsRequires an explanation next to scoped heartbeat overridesEnableConsider when budgets are allowed
linter.style.missingEndMakes namespace and section boundaries explicitEnableConsider for multi-section files
linter.style.setOptionCatches leftover traces, profilers, pretty-printer flags, and unscoped sensitive optionsEnable outside dedicated fixturesEnable outside dedicated fixtures
linter.style.showPrevents show from silently changing the target; requires honest changeEnableEnable
linter.style.whitespaceEnforces Mathlib declaration spacingProject styleProject style
linter.unusedDecidableInTypeRemoves an unnecessary Decidable assumption from a theorem's public typeEnableConsider only when statements are reused
linter.unusedFintypeInTypeRemoves or weakens an unnecessary Fintype assumption in a theorem's public typeEnableConsider 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:

  • leanOptions reach every module, and -D on an unregistered option is a hard error — a Mathlib-provided linter.style.* option breaks any module in your package that imports only core Lean. The weak. 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.flexible and the two linter.unused*InType options are not under linter.style. Reconfirm every name against the project's pinned Mathlib revision. Then add a known-trigger fixture for every option: the weak. 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 CI

    The 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 == 0 guard enforces in AxiomCheck.lean above, 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_lib target in the lakefile and build that target explicitly in CI.

  • Run declaration-level linters too. The leanOptions profiles above activate syntax and command linters; they do not replace Batteries' #lint, whose checks include simpNF. 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.flexible propagates a "stain" that stops at the first fix, so pinning one flagged simp unmasks 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 suggested simp only [...] list comes from re-running a default simp, dropping the arguments the original call passed. Run simp? 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. hashCommand is 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 collectAxioms beat grep 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 foo lives at _private.<module>.0.foo, so env.contains ``foo`` reports it missing. Use mkPrivateNameCore per 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 targeted env.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=value rejects an unknown option, while lean -Dweak.name=value intentionally 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 #lint run piped through tee without pipefail exits 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 or Trail of Bits.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download writing-lean-proofs.zip