Skip to content

Commit cc503a5

Browse files
authored
chore: make checkUnivs linter into Lean.Linter (#13832)
This PR makes `checkUnivs` linter into `Lean.Linter`. Stacked on top of #13803.
1 parent 4e59cdf commit cc503a5

10 files changed

Lines changed: 170 additions & 139 deletions

File tree

src/Lean/Linter.lean

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ module
88
prelude
99
public import Lean.Linter.Util
1010
public import Lean.Linter.Builtin
11+
public import Lean.Linter.CheckUnivs
1112
public import Lean.Linter.ConstructorAsVariable
1213
public import Lean.Linter.DefProp
1314
public import Lean.Linter.Deprecated

src/Lean/Linter/CheckUnivs.lean

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/-
2+
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Authors: Wojciech Różowski
5+
6+
Copyright (c) 2020 Floris van Doorn. All rights reserved.
7+
Released under Apache 2.0 license as described in the file LICENSE.
8+
Authors: Floris van Doorn, Robert Y. Lewis, Gabriel Ebner
9+
-/
10+
module
11+
12+
prelude
13+
public import Lean.Linter.Basic
14+
public import Lean.Linter.Util
15+
public import Lean.Util.CollectLevelParams
16+
public import Lean.Util.ForEachExpr
17+
18+
public section
19+
20+
namespace Lean.Linter
21+
open Elab Command Meta
22+
23+
/--
24+
Enables the `checkUnivs` linter, which warns when a declaration has a universe parameter
25+
that only ever occurs in a `max u v` together with another parameter, never on its own.
26+
This usually means that the type contains a `max u v` where neither `u` nor `v` occur by
27+
themselves; the fix is to provide the universe level explicitly.
28+
-/
29+
register_builtin_option linter.checkUnivs : Bool := {
30+
defValue := false
31+
descr := "enable the `checkUnivs` linter, which warns when a declaration has a universe \
32+
parameter that only ever occurs in a `max u v` together with another parameter, never \
33+
on its own."
34+
}
35+
36+
namespace CheckUnivs
37+
38+
/--
39+
`univParamsGrouped e nm₀` computes for each `Level` occurring in `e` the set of level parameters
40+
that appear in it, returning the collection of such parameter sets.
41+
In pseudo-mathematical form, this returns `{{p : parameter | p ∈ u} | (u : level) ∈ e}`.
42+
Ignores `nm₀.proof_*` sub-constants.
43+
-/
44+
private def univParamsGrouped (e : Expr) (nm₀ : Name) : Std.HashSet (Array Name) :=
45+
runST fun _ =>
46+
let go : StateRefT (Std.HashSet (Array Name)) (ST _) Unit := e.forEach fun
47+
| .sort u =>
48+
modify (·.insert (CollectLevelParams.visitLevel u {}).params)
49+
| .const n us => do
50+
if let .str n s := n then
51+
if n == nm₀ && s.startsWith "proof_" then return
52+
modify (us.foldl (·.insert <| CollectLevelParams.visitLevel · {} |>.params))
53+
| _ => pure ()
54+
Prod.snd <$> go.run {}
55+
56+
/--
57+
The good parameters are the parameters that occur somewhere in the set as a singleton or
58+
(recursively) with only other good parameters.
59+
All other parameters in the set are bad.
60+
-/
61+
private partial def badParams (l : Array (Array Name)) : Array Name :=
62+
let goodLevels := l.filterMap fun
63+
| #[u] => some u
64+
| _ => none
65+
if goodLevels.isEmpty then
66+
l.flatten.foldl (init := #[]) fun acc x => if acc.contains x then acc else acc.push x
67+
else
68+
badParams <| l.map (·.filter (!goodLevels.contains ·))
69+
70+
@[inherit_doc linter.checkUnivs]
71+
def checkUnivsLinter : Linter where run := withSetOptionIn fun _ => do
72+
unless getLinterValue linter.checkUnivs (← getLinterOptions) do return
73+
if (← get).messages.hasErrors then return
74+
let env ← getEnv
75+
let mut seen : NameSet := {}
76+
for t in ← getInfoTrees do
77+
for declName in getNewDecls t do
78+
if seen.contains declName then continue
79+
seen := seen.insert declName
80+
let some info := env.find? declName | continue
81+
let bad := badParams (univParamsGrouped info.type declName).toArray
82+
unless bad.isEmpty do
83+
let univs := MessageData.joinSep (bad.toList.map fun u => m!"`{u}`") ", "
84+
logLintIf linter.checkUnivs (← getRef)
85+
m!"`{.ofConstName declName}`: universes {univs} only occur together. \
86+
This usually means there is a `max` expression in the type where none of these \
87+
universes appear on their own."
88+
89+
builtin_initialize addLinter checkUnivsLinter
90+
91+
end CheckUnivs
92+
end Lean.Linter

src/Lean/Linter/EnvLinter.lean

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,3 @@ module
88
prelude
99
public import Lean.Linter.EnvLinter.Basic
1010
public import Lean.Linter.EnvLinter.Frontend
11-
public import Lean.Linter.EnvLinter.Builtin

src/Lean/Linter/EnvLinter/Builtin.lean

Lines changed: 0 additions & 90 deletions
This file was deleted.

src/Lean/Linter/Util.lean

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,20 @@ def getDeclsByBody (t : InfoTree) : List Name :=
5757
else decls
5858
else decls
5959
| _ => decls
60+
61+
/-- Get the names of declarations introduced by elaborating `t`.
62+
63+
A declaration introduces a `TermInfo` with `isBinder := true` whose `expr` is the constant
64+
being declared. This covers `def`/`theorem`/`axiom`, inductive types and their constructors,
65+
and structure fields and parent projections — including mutual blocks where each member
66+
emits its own binder.
67+
-/
68+
def getNewDecls (t : InfoTree) : List Name :=
69+
t.collectNodesBottomUp fun _ i _ acc =>
70+
match i with
71+
| .ofTermInfo ti =>
72+
if ti.isBinder && ti.expr.isConst then
73+
ti.expr.constName! :: acc
74+
else
75+
acc
76+
| _ => acc

tests/elab/env_linter.lean

Lines changed: 7 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ def testGetChecksDefault : CoreM (Array Name) := do
130130
let checks ← getChecks (scope := .default) (runOnly := none)
131131
return checks.map (·.name)
132132

133-
-- dummyBadName and checkUnivs are default, dummyExtraLinter is not
134-
/-- info: #[`checkUnivs, `dummyBadName] -/
133+
-- dummyBadName is default, dummyExtraLinter is not
134+
/-- info: #[`dummyBadName] -/
135135
#guard_msgs in
136136
#eval testGetChecksDefault
137137

@@ -140,7 +140,7 @@ def testGetChecksExtra : CoreM (Array Name) := do
140140
let checks ← getChecks (scope := .extra) (runOnly := none)
141141
return checks.map (·.name)
142142

143-
/-- info: #[`checkUnivs, `dummyBadName, `dummyExtraLinter] -/
143+
/-- info: #[`dummyBadName, `dummyExtraLinter] -/
144144
#guard_msgs in
145145
#eval testGetChecksExtra
146146

@@ -149,7 +149,7 @@ def testGetChecksAll : CoreM (Array Name) := do
149149
let checks ← getChecks (scope := .all) (runOnly := none)
150150
return checks.map (·.name)
151151

152-
/-- info: #[`checkUnivs, `dummyBadName, `dummyExtraLinter] -/
152+
/-- info: #[`dummyBadName, `dummyExtraLinter] -/
153153
#guard_msgs in
154154
#eval testGetChecksAll
155155

@@ -181,7 +181,7 @@ def testLintCore : CoreM (Array (Name × Nat)) := do
181181
let results ← lintCore #[`badDef, `goodDef, `badButNolinted] linters
182182
return results.map fun (linter, msgs) => (linter.name, msgs.size)
183183

184-
/-- info: #[(`checkUnivs, 0), (`dummyBadName, 1)] -/
184+
/-- info: #[(`dummyBadName, 1)] -/
185185
#guard_msgs in
186186
#eval testLintCore
187187

@@ -210,7 +210,7 @@ def testFormatResults : CoreM Format := do
210210
return (← msg.format)
211211

212212
/--
213-
info: -- Found 1 error in 2 declarations (plus 0 automatically generated ones) in test with 2 linters
213+
info: -- Found 1 error in 2 declarations (plus 0 automatically generated ones) in test with 1 linters
214214
215215
/- The `dummyBadName` linter reports:
216216
found bad names -/
@@ -229,39 +229,7 @@ def testFormatResultsClean : CoreM Format := do
229229
return (← msg.format)
230230

231231
/--
232-
info: -- Found 0 errors in 1 declarations (plus 0 automatically generated ones) in test with 2 linters
232+
info: -- Found 0 errors in 1 declarations (plus 0 automatically generated ones) in test with 1 linters
233233
-/
234234
#guard_msgs in
235235
#eval testFormatResultsClean
236-
237-
/-! ## Test: checkUnivs -/
238-
239-
-- Good: each universe parameter occurs alone somewhere
240-
universe u v in
241-
def goodUnivs (α : Type u) (β : Type v) : Type (max u v) := α × β
242-
243-
-- Good: one universe dominates the other (max u v where u occurs alone)
244-
universe u v in
245-
def goodUnivsDominated (α : Type u) (β : Type (max u v)) : Type (max u v) := α × β
246-
247-
-- Bad: neither u nor v occur alone
248-
universe u v in
249-
def badUnivs (α : Type (max u v)) : Type (max u v) := α
250-
251-
def testCheckUnivs (declName : Name) : MetaM Bool := do
252-
let some (linterDeclName, _) := (envLinterExt.getState (← getEnv)).find? `checkUnivs
253-
| throwError "not found"
254-
let linter ← getEnvLinter `checkUnivs linterDeclName
255-
return (← linter.test declName).isSome
256-
257-
/-- info: false -/
258-
#guard_msgs in
259-
#eval testCheckUnivs `goodUnivs
260-
261-
/-- info: false -/
262-
#guard_msgs in
263-
#eval testCheckUnivs `goodUnivsDominated
264-
265-
/-- info: true -/
266-
#guard_msgs in
267-
#eval testCheckUnivs `badUnivs

tests/elab/linterCheckUnivs.lean

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
module
2+
3+
/-!
4+
Tests for the `linter.checkUnivs` linter, which warns when a declaration has a universe
5+
parameter that only ever occurs in a `max u v` together with another parameter, never
6+
on its own.
7+
-/
8+
9+
set_option linter.checkUnivs true
10+
11+
universe u v
12+
13+
-- Good: each universe parameter occurs alone somewhere.
14+
def goodUnivs (α : Type u) (β : Type v) : Type (max u v) := α × β
15+
16+
-- Good: `u` occurs alone in `α`, so the `max u v` is fine.
17+
def goodUnivsDominated (α : Type u) (β : Type (max u v)) : Type (max u v) := α × β
18+
19+
-- Bad: neither `u` nor `v` occur alone in `badUnivs`'s type.
20+
/--
21+
warning: `badUnivs`: universes `u`, `v` only occur together. This usually means there is a `max` expression in the type where none of these universes appear on their own.
22+
23+
Note: This linter can be disabled with `set_option linter.checkUnivs false`
24+
-/
25+
#guard_msgs in
26+
def badUnivs (α : Type (max u v)) : Type (max u v) := α
27+
28+
-- `set_option ... false in` suppresses the warning locally.
29+
#guard_msgs in
30+
set_option linter.checkUnivs false in
31+
def badUnivsSuppressed (α : Type (max u v)) : Type (max u v) := α
32+
33+
-- Inductives are also checked.
34+
/--
35+
warning: `BadInd`: universes `u`, `v` only occur together. This usually means there is a `max` expression in the type where none of these universes appear on their own.
36+
37+
Note: This linter can be disabled with `set_option linter.checkUnivs false`
38+
-/
39+
#guard_msgs in
40+
inductive BadInd : Type (max u v) where
41+
| mk

tests/lake/tests/builtin-lint-module/Linters.lean

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
module
22

3-
public import Lean.Linter.EnvLinter
4-
public import Lean.Elab.Command
3+
public meta import Lean.Linter.EnvLinter
54

6-
open Lean Meta Lean.Linter Lean.Elab.Command
5+
open Lean Meta Lean.Linter
76

87
/-- Dummy env linter for the `lake lint --builtin-lint` module-system test.
98
Default scope (no `extra`), and the test reads only the declaration name so

tests/lake/tests/builtin-lint/Main.lean

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import Main.Sub
22

3-
-- `linter.defProp` is off by default for bootstrapping reasons; enable it
4-
-- here so the test scenarios that exercise default lint mode still trigger it.
3+
-- `linter.defProp` and `linter.checkUnivs` are off by default for bootstrapping
4+
-- reasons; enable them here so the test scenarios that exercise default lint
5+
-- mode still trigger them.
56
set_option linter.defProp true
7+
set_option linter.checkUnivs true
68

79
-- This uses `def` for a Prop — the `defProp` linter should flag this.
810
def shouldBeTheorem : 1 = 1 := rfl
@@ -24,7 +26,7 @@ instance plainInstIsOk : Nonempty String := ⟨""⟩
2426
universe u v in
2527
def badUnivDecl (α : Type (max u v)) : Type (max u v) := α
2628

27-
-- Annotated to be skipped by `checkUnivs`.
29+
-- `set_option` disables `checkUnivs` locally so this violation is not flagged.
30+
set_option linter.checkUnivs false in
2831
universe u v in
29-
@[builtin_nolint checkUnivs]
3032
def badUnivSkipped (α : Type (max u v)) : Type (max u v) := α

tests/lake/tests/builtin-lint/test.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,9 +217,11 @@ match_pat 'shouldBeTheorem' produced.out
217217

218218
# --- builtinLint = true with a lint driver ---
219219

220-
# builtinLint = true + lint driver + clean module: both builtin lints and driver run
220+
# builtinLint = true + lint driver + clean module: both builtin lints and driver run.
221+
# With no default env linters registered in core, the builtin-lint pass reports
222+
# "No environment linters registered" rather than "Linting passed".
221223
lake_out lint -f with-driver.lean Clean || true
222-
match_pat 'Linting passed for Clean' produced.out
224+
match_pat 'No environment linters registered for Clean' produced.out
223225
match_pat 'lint-driver:' produced.out
224226

225227
# builtinLint = true + lint driver + violations: both run, exit code is nonzero

0 commit comments

Comments
 (0)