Skip to content

feat: add the haveILetI linter, flagging haveI/letI in tactic proofs.#41562

Open
kbuzzard wants to merge 3 commits into
leanprover-community:masterfrom
kbuzzard:haveILetI-linter
Open

feat: add the haveILetI linter, flagging haveI/letI in tactic proofs.#41562
kbuzzard wants to merge 3 commits into
leanprover-community:masterfrom
kbuzzard:haveILetI-linter

Conversation

@kbuzzard

@kbuzzard kbuzzard commented Jul 10, 2026

Copy link
Copy Markdown
Member

The tactics haveI and letI differ from have and let only in that they inline the given value into the term being constructed (in current core they are literally have +zeta and let +zeta, sharing a single elaborator). In a tactic proof of a proposition this difference is invisible by proof irrelevance, so have/let should be used instead.

The linter walks the InfoTree and flags each user-written haveI/letI tactic whose main goal is a proposition, checked by running Meta.isProp on the goal type in the recorded metavariable/local context. Term-mode haveI/letI and macro-generated syntax are not flagged (documented TODO). The option linter.style.haveILetI is off by default; mathlib currently contains ~2,300 sites the linter would flag.


Open in Gitpod

This PR is 100% vibe-coded with Claude Fable and I (Kevin Buzzard) can not vouch for any of it, not even the PR description.

…of propositions

The tactics `haveI` and `letI` differ from `have` and `let` only in that they
inline the given value into the term being constructed (in current core they
are literally `have +zeta` and `let +zeta`, sharing a single elaborator). In a
tactic proof of a proposition this difference is invisible by proof
irrelevance, so `have`/`let` should be used instead.

The linter walks the InfoTree and flags each user-written `haveI`/`letI`
tactic whose main goal is a proposition, checked by running `Meta.isProp` on
the goal type in the recorded metavariable/local context. Term-mode
`haveI`/`letI` and macro-generated syntax are not flagged (documented TODO).
The option `linter.style.haveILetI` is off by default; mathlib currently
contains ~2,300 sites the linter would flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kbuzzard kbuzzard added WIP Work in progress LLM-generated PRs with substantial input from LLMs - review accordingly labels Jul 10, 2026
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

PR summary 3ed3c10cef

Import changes for modified files

No significant changes to the import graph

Import changes for all files
Files Import difference
../mathlib-ci/scripts/pr_summary/import_trans_difference.sh all
There are 7255 files with changed transitive imports taking up over 325477 characters: this is too many to display!
You can run this locally from your mathlib4 directory:

git clone https://github.com/leanprover-community/mathlib-ci.git ../mathlib-ci


Declarations diff (regex)

+ Candidate
+ auxLaterTrue.{v}
+ auxSortData
+ auxSortTrue
+ dataFromMixed
+ haveILetILinter
+ oneAsSubtype
+ propEvidence
+ replacement?
+ subsingletonInstance
+ tacticGoalIsProp?
+ termIsProof?
+ trivialTheorem
+ zero
+ zero'

You can run this locally as follows
## from your `mathlib4` directory:
git clone https://github.com/leanprover-community/mathlib-ci.git ../mathlib-ci

## summary with just the declaration names:
../mathlib-ci/scripts/pr_summary/declarations_diff.sh <optional_commit>

## more verbose report:
../mathlib-ci/scripts/pr_summary/declarations_diff.sh long <optional_commit>

The doc-module for scripts/pr_summary/declarations_diff.sh in the mathlib-ci repository contains some details about this script.

Declarations diff (Lean)

Lean-aware diff — post-build, computed from the Lean environment (commit 3ed3c10).

  • +1 new declarations
  • −0 removed declarations
+Mathlib.Linter.linter.style.haveILetI

No changes to strong technical debt.

No changes to weak technical debt.

Current commit 3ed3c10cef
Reference commit 32ea179db3

This script lives in the mathlib-ci repository. To run it locally, from your mathlib4 directory:

git clone https://github.com/leanprover-community/mathlib-ci.git ../mathlib-ci
../mathlib-ci/scripts/reporting/technical-debt-metrics.sh pr_summary
  • The relative value is the weighted sum of the differences with weight given by the inverse of the current value of the statistic.
  • The absolute value is the relative value divided by the total sum of the inverses of the current values (i.e. the weighted average of the differences).

@grunweg

grunweg commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

I believe we had such a linter in the past. @adomani Did you write that one? Do you remember if that was worth it?

@adomani

adomani commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

I don't remember this linter, but I do remember writing one to detect haves that should be lets (or the other way round, or both ways!).

Comment thread Mathlib/Tactic/Linter/HaveILetI.lean Outdated
let mut seen : Std.HashSet (Option String.Pos.Raw × Option String.Pos.Raw) := {}
for t in ← getInfoTrees do
for (ctx, i, kw, repl) in candidates t do
let range := (i.stx.getPos?, i.stx.getTailPos?)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a bit awkward: can't you use directly getRange?? (Talking to Claude here!)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude says you're right and claims that 3ed3c10 implements this.

@kbuzzard kbuzzard changed the title feat: add the haveILetI linter, flagging haveI/letI in tactic proofs … feat: add the haveILetI linter, flagging haveI/letI in tactic proofs. Jul 12, 2026
@kbuzzard

kbuzzard commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

Rather than give some kind of AI-generated response to Damiano I'll note that #41657 is a human-expert-written version of this linter and I would imagine that this is what we will end up merging. I'll maybe leave this open and WIP until #41657 gets merged.

…argeted suggestion

The linter now walks the command's source syntax for user-written haveI/letI
(both the tactic and the term versions), looks up the TacticInfo resp.
(Partial)TermInfo elaboration records for each occurrence by source range and
syntax kind, and flags an occurrence when at least one recorded elaboration is
confirmed to have a propositional goal (tactic) resp. to construct a proof of
a proposition (term), and none is confirmed otherwise. In particular a haveI
run by <;> against both a data goal and a Prop goal is no longer flagged: the
single source replacement would also affect the data-goal run.

Each flagged occurrence gets one interactive hint (click-to-apply in the
InfoView) replacing just the haveI/letI keyword token with have/let,
sidestepping any pretty-printing or reformatting of the rest of the syntax.
(Lightbulb code actions cannot be offered from linters: runLinters discards
info-tree additions made by linters, see leanprover/lean4#4363.)

Prop-ness is checked giving every chance to succeed: negative Meta.isProp
answers are distrusted while metavariables remain in the type or its sort, and
inconclusive checks are retried in the final metavariable context of the
surrounding declaration. The latter requires a custom info-tree fold: unlike
InfoTree.foldInfo, it does not narrow the context at each info node to the
enclosing tactic's own mctxAfter, which would hide metavariable assignments
made by later sibling tactic steps (e.g. in later branches of a <;>).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
return
unless (← getInfoState).enabled do
return
let cands := candidates stx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems a bit strange to loop through the entire syntax once looking for haveI/letI, followed by looping through all the infotrees trying to refind the haveI/letIs associated to a piece of syntax, finishing by looping through all the haveI/letIs again. I would expect a single loop through the infotrees that does the selection and reporting at once.

I'd hope that a TacticAnalysis pass could even do this looping for us, but perhaps that's expecting too much of my work :)

@kbuzzard kbuzzard Jul 13, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude says:

  • The unit of reporting is a source location, not an elaboration event. One written haveI
    produces several elaboration records (one per goal under <;>/all_goals, plus macro-expansion
    copies and postponement retries), and the flag condition is "at least one run confirmed
    propositional and none confirmed otherwise" — you can't decide that until you've grouped all
    records per occurrence, which is exactly what the syntax-first pass provides. The previous
    single-loop version needed a seen-set to dedup and could only express "some run is a Prop", which
    is genuinely wrong: for a mixed data/Prop <;>, applying the one source edit changes the data
    run's term (there's now a test locking this down).
  • The suggestion is a source edit of the keyword token, so candidates must be things the user
    literally wrote (.original token) — a property of the source syntax, not of info-tree nodes.
  • TacticAnalysis wouldn't cover the term-mode haveI/letI this PR now lints (including inside
    exact/refine and in instance bodies), though a tactic-only fragment could plausibly be expressed
    there.
  • Cost-wise it's still effectively one info-tree fold; the syntax walk is trivial and
    short-circuits on commands containing no haveI/letI.

Let me know if you don't want to talk to an AI. This is Fable 5, the new top model from Anthropic, and I will be losing access to it on Sunday so I am just trying various experiments while I can (e.g. "is it good enough to write a linter")

@thorimur thorimur Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, looping through syntax first is at my request in the zulip thread that led to the prompt :) My reasoning is more or less claude's, because I think it's just filling in details of the prompt! XD Not sure about the last loop. And I've only glanced, but in general this code seems waaay more complicated than I think it would need to be... 👀 I wonder if ultracode on relatively straightforward tasks might translate to "more complicated code", not necessarily better code.

…getRange?

The docBlame environment linter flagged the auto-generated
instInhabitedCandidate.default. The instance was only needed for a
`cands[idx]!` lookup, which disappears by storing the candidate itself in the
range-keyed map. Also key that map by `Syntax.Range` obtained from
`Syntax.getRange?` instead of a hand-rolled pair of positions, as requested
in review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

LLM-generated PRs with substantial input from LLMs - review accordingly WIP Work in progress

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants