Skip to content

Commit 97bc5f6

Browse files
Vierkantorgrunweg
andcommitted
feat(Tactic/Linter/DirectoryDependency): add an allow-list for directories with few imports (leanprover-community#24109)
Add an allow-list mechanism to the `directoryDependency` linter: modules covered by that list may only have imports listed in that list Co-authored-by: Michael Rothgang <rothgang@math.uni-bonn.de> Co-authored-by: grunweg <rothgami@math.hu-berlin.de>
1 parent f6b3bf4 commit 97bc5f6

File tree

5 files changed

+211
-30
lines changed

5 files changed

+211
-30
lines changed

Mathlib/Tactic/Linter/DirectoryDependency.lean

Lines changed: 169 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE.
44
Authors: Anne Baanen
55
-/
66
import Lean.Elab.Command
7+
import Lean.Elab.ParseImportsFast
78

89
/-! # The `directoryDependency` linter
910
@@ -12,6 +13,14 @@ independent. By specifying that one directory does not import from another, we c
1213
modularity of Mathlib.
1314
-/
1415

16+
-- XXX: is there a better long-time place for this
17+
/-- Parse all imports in a text file at `path` and return just their names:
18+
this is just a thin wrapper around `Lean.parseImports'`.
19+
Omit `Init` (which is part of the prelude). -/
20+
def findImports (path : System.FilePath) : IO (Array Lean.Name) := do
21+
return (← Lean.parseImports' (← IO.FS.readFile path) path.toString).imports
22+
|>.map (fun imp ↦ imp.module) |>.erase `Init
23+
1524
/-- Find the longest prefix of `n` such that `f` returns `some` (or return `none` otherwise). -/
1625
def Lean.Name.findPrefix {α} (f : Name → Option α) (n : Name) : Option α := do
1726
f n <|> match n with
@@ -26,6 +35,13 @@ def Lean.Name.prefixes (n : Name) : NameSet :=
2635
| str n' _ => n'.prefixes
2736
| num n' _ => n'.prefixes
2837

38+
/-- Return the immediate prefix of `n` (if any). -/
39+
def Lean.Name.prefix? (n : Name) : Option Name :=
40+
match n with
41+
| anonymous => none
42+
| str n' _ => some n'
43+
| num n' _ => some n'
44+
2945
/-- Collect all prefixes of names in `ns` into a single `NameSet`. -/
3046
def Lean.Name.collectPrefixes (ns : Array Name) : NameSet :=
3147
ns.foldl (fun ns n => ns.union n.prefixes) ∅
@@ -117,11 +133,95 @@ def findAny (r : NamePrefixRel) (n₁ : Name) (ns : Array Name) : Option (Name
117133
pure ()
118134
none
119135

136+
/-- Does `r` contain any entries with key `n`? -/
137+
def containsKey (r : NamePrefixRel) (n : Name) : Bool := NameMap.contains r n
138+
120139
/-- Is a prefix of `n₁` related to a prefix of `n₂`? -/
121140
def contains (r : NamePrefixRel) (n₁ n₂ : Name) : Bool := (r.find n₁ n₂).isSome
122141

142+
/-- Look up all names `m` which are values of some prefix of `n` under this relation. -/
143+
def getAllLeft (r : NamePrefixRel) (n : Name) : NameSet := Id.run do
144+
let matchingPrefixes := n.prefixes.filter (fun prf ↦ r.containsKey prf)
145+
let mut allRules := NameSet.empty
146+
for prfix in matchingPrefixes do
147+
let some rules := RBMap.find? r prfix | unreachable!
148+
allRules := allRules.append rules
149+
allRules
150+
123151
end NamePrefixRel
124152

153+
-- TODO: add/extend tests for this linter, to ensure the allow-list works
154+
-- TODO: move the following three lists to a JSON file, for easier evolution over time!
155+
-- Future: enforce that allowed and forbidden keys are disjoint
156+
-- Future: move further directories to use this allow-list instead of the blocklist
157+
158+
/-- `allowedImportDirs` relates module prefixes, specifying that modules with the first prefix
159+
are only allowed to import modules in the second directory.
160+
For directories which are low in the import hierarchy, this opt-out approach is both more ergonomic
161+
(fewer updates needed) and needs less configuration.
162+
163+
We always allow imports of `Init`, `Lean`, `Std`, `Qq` and
164+
`Mathlib.Init` (as well as their transitive dependencies.)
165+
-/
166+
def allowedImportDirs : NamePrefixRel := .ofArray #[
167+
-- This is used to test the linter.
168+
(`MathlibTest.DirectoryDependencyLinter, `Mathlib.Lean),
169+
-- Mathlib.Tactic has large transitive imports: just allow all of mathlib,
170+
-- as we don't care about the details a lot.
171+
(`MathlibTest.Header, `Mathlib),
172+
(`MathlibTest.Header, `Aesop),
173+
(`MathlibTest.Header, `ImportGraph),
174+
(`MathlibTest.Header, `LeanSearchClient),
175+
(`MathlibTest.Header, `Plausible),
176+
(`MathlibTest.Header, `ProofWidgets),
177+
(`MathlibTest.Header, `Qq),
178+
-- (`MathlibTest.Header, `Mathlib.Tactic),
179+
-- (`MathlibTest.Header, `Mathlib.Deprecated),
180+
(`MathlibTest.Header, `Batteries),
181+
(`MathlibTest.Header, `Lake),
182+
183+
(`Mathlib.Util, `Batteries),
184+
(`Mathlib.Util, `Mathlib.Lean),
185+
(`Mathlib.Util, `Mathlib.Tactic),
186+
-- TODO: reduce this dependency by upstreaming `Data.String.Defs to batteries
187+
(`Mathlib.Util.FormatTable, `Mathlib.Data.String.Defs),
188+
189+
(`Mathlib.Lean, `Batteries.CodeAction),
190+
(`Mathlib.Lean, `Batteries.Tactic.Lint),
191+
-- TODO: decide if this is acceptable or should be split in a more fine-grained way
192+
(`Mathlib.Lean, `Batteries),
193+
(`Mathlib.Lean.Expr, `Mathlib.Util),
194+
(`Mathlib.Lean.Meta.RefinedDiscrTree, `Mathlib.Util),
195+
-- Fine-grained exceptions: TODO decide if these are fine, or should be scoped more broadly.
196+
(`Mathlib.Lean.CoreM, `Mathlib.Tactic.ToExpr),
197+
(`Mathlib.Lean.CoreM, `Mathlib.Util.WhatsNew),
198+
(`Mathlib.Lean.Meta.RefinedDiscrTree, `Mathlib.Tactic.Lemma),
199+
(`Mathlib.Lean.Meta.RefinedDiscrTree, `Mathlib.Tactic.TypeStar),
200+
(`Mathlib.Lean.Meta.RefinedDiscrTree, `Mathlib.Tactic.ToAdditive),
201+
(`Mathlib.Lean.Meta.RefinedDiscrTree, `Mathlib.Tactic), -- split this up further?
202+
(`Mathlib.Lean.Meta.RefinedDiscrTree, `Mathlib.Data), -- split this up further?
203+
(`Mathlib.Lean.Meta.RefinedDiscrTree, `Mathlib.Algebra.Notation),
204+
(`Mathlib.Lean.Meta.RefinedDiscrTree, `Mathlib.Data.Notation),
205+
(`Mathlib.Lean.Meta.RefinedDiscrTree, `Mathlib.Data.Array),
206+
207+
(`Mathlib.Lean.Meta.CongrTheorems, `Mathlib.Data),
208+
(`Mathlib.Lean.Meta.CongrTheorems, `Mathlib.Logic),
209+
(`Mathlib.Lean.Meta.CongrTheorems, `Mathlib.Order.Defs),
210+
(`Mathlib.Lean.Meta.CongrTheorems, `Mathlib.Tactic),
211+
212+
(`Mathlib.Lean.Expr.ExtraRecognizers, `Mathlib.Data),
213+
(`Mathlib.Lean.Expr.ExtraRecognizers, `Mathlib.Order),
214+
(`Mathlib.Lean.Expr.ExtraRecognizers, `Mathlib.Logic),
215+
(`Mathlib.Lean.Expr.ExtraRecognizers, `Mathlib.Tactic),
216+
217+
(`Mathlib.Tactic.Linter, `Batteries),
218+
-- The Mathlib.Tactic.Linter *module* imports all linters, hence requires all the imports.
219+
-- For more fine-grained exceptions of the next two imports, one needs to rename that file.
220+
(`Mathlib.Tactic.Linter, `ImportGraph),
221+
(`Mathlib.Tactic.Linter, `Mathlib.Tactic.MinImports),
222+
(`Mathlib.Tactic.Linter.TextBased, `Mathlib.Data.Nat.Notation),
223+
]
224+
125225
/-- `forbiddenImportDirs` relates module prefixes, specifying that modules with the first prefix
126226
should not import modules with the second prefix (except if specifically allowed in
127227
`overrideAllowedImportDirs`).
@@ -496,25 +596,81 @@ end DirectoryDependency
496596

497597
open DirectoryDependency
498598

499-
@[inherit_doc Mathlib.Linter.linter.directoryDependency]
500-
def directoryDependencyCheck (mainModule : Name) : CommandElabM (Option MessageData) := do
501-
unless Linter.getLinterValue linter.directoryDependency (← getOptions) do
502-
return none
503-
let env ← getEnv
504-
let imports := env.allImportedModuleNames
599+
/-- Check if one of the imports `imports` to `mainModule` is forbidden by `forbiddenImportDirs`;
600+
if so, return an error describing how the import transitively arises. -/
601+
private def checkBlocklist (env : Environment) (mainModule : Name) (imports : Array Name) : Option MessageData := Id.run do
505602
match forbiddenImportDirs.findAny mainModule imports with
506603
| some (n₁, n₂) => do
507604
if let some imported := n₂.prefixToName imports then
508605
if !overrideAllowedImportDirs.contains mainModule imported then
509-
let mut msg := m!"Modules starting with {n₁} are not allowed to import modules starting with {n₂}.
510-
This module depends on {imported}\n"
606+
let mut msg := m!"Modules starting with {n₁} are not allowed to import modules starting with {n₂}. \
607+
This module depends on {imported}\n"
511608
for dep in env.importPath imported do
512609
msg := msg ++ m!"which is imported by {dep},\n"
513-
return some <| msg ++ m!"which is imported by this module.
514-
(Exceptions can be added to `overrideAllowedImportDirs`.)"
610+
return some (msg ++ m!"which is imported by this module. \
611+
(Exceptions can be added to `overrideAllowedImportDirs`.)")
612+
else none
515613
else
516-
return some m!"Internal error in `directoryDependency` linter: this module claims to depend on a module starting with {n₂} but a module with that prefix was not found in the import graph."
517-
| none => pure ()
518-
return none
614+
return some m!"Internal error in `directoryDependency` linter: this module claims to depend \
615+
on a module starting with {n₂} but a module with that prefix was not found in the import graph."
616+
| none => none
617+
618+
@[inherit_doc Mathlib.Linter.linter.directoryDependency]
619+
def directoryDependencyCheck (mainModule : Name) : CommandElabM (Array MessageData) := do
620+
unless Linter.getLinterValue linter.directoryDependency (← getOptions) do
621+
return #[]
622+
let env ← getEnv
623+
let imports := env.allImportedModuleNames
624+
625+
-- If this module is in the allow-list, we only allow imports from directories specified there.
626+
-- Collect all prefixes which have a matching entry.
627+
let matchingPrefixes := mainModule.prefixes.filter (fun prf ↦ allowedImportDirs.containsKey prf)
628+
if matchingPrefixes.isEmpty then
629+
-- Otherwise, we fall back to the blocklist `forbiddenImportDirs`.
630+
if let some msg := checkBlocklist env mainModule imports then return #[msg] else return #[]
631+
else
632+
-- We always allow imports in the same directory (for each matching prefix),
633+
-- and from `Init`, `Lean`, `Std` and `Qq`.
634+
-- We also allow transitive imports of Mathlib.Init, as well as Mathlib.Init itself.
635+
let initImports := (← findImports ("Mathlib" / "Init.lean")).append
636+
#[`Mathlib.Init, `Mathlib.Tactic.DeclarationNames]
637+
let exclude := [
638+
`Init, `Std, `Lean, `Qq,
639+
]
640+
let importsToCheck := imports.filter (fun imp ↦ !exclude.any (·.isPrefixOf imp))
641+
|>.filter (fun imp ↦ !matchingPrefixes.any (·.isPrefixOf imp))
642+
|>.filter (!initImports.contains ·)
643+
644+
-- Find all prefixes which are allowed for one of these directories.
645+
let allRules := allowedImportDirs.getAllLeft mainModule
646+
-- Error about those imports which are not covered by allowedImportDirs.
647+
let mut messages := #[]
648+
for imported in importsToCheck do
649+
if !allowedImportDirs.contains mainModule imported then
650+
let importPath := env.importPath imported
651+
let mut msg := m!"Module {mainModule} depends on {imported},\n\
652+
but is only allowed to import modules starting with one of {allRules.toArray.qsort (·.toString < ·.toString)}.\n\
653+
Note: module {imported}"
654+
let mut superseded := false
655+
match importPath.toList with
656+
| [] => msg := msg ++ " is directly imported by this module"
657+
| a :: rest =>
658+
-- Only add messages about imports that aren't themselves transitive imports of
659+
-- forbidden imports.
660+
-- This should prevent redundant messages.
661+
if !allowedImportDirs.contains mainModule a then
662+
superseded := true
663+
else
664+
msg := msg ++ s!" is imported by {a},\n"
665+
for dep in rest do
666+
if !allowedImportDirs.contains mainModule dep then
667+
superseded := true
668+
break
669+
msg := msg ++ m!"which is imported by {dep},\n"
670+
msg := msg ++ m!"which is imported by this module."
671+
msg := msg ++ "(Exceptions can be added to `allowedImportDirs`.)"
672+
if !superseded then
673+
messages := messages.push msg
674+
return messages
519675

520676
end Mathlib.Linter

Mathlib/Tactic/Linter/Header.lean

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,8 @@ def headerLinter : Linter where run := withSetOptionIn fun stx ↦ do
304304
return val
305305
-- The linter skips files not imported in `Mathlib.lean`, to avoid linting "scratch files".
306306
-- It is however active in the test file `MathlibTest.Header` for the linter itself.
307-
unless inMathlib? || mainModule == `MathlibTest.Header do return
307+
unless inMathlib? ||
308+
mainModule == `MathlibTest.Header || mainModule == `MathlibTest.DirectoryDependencyLinter.Test do return
308309
unless Linter.getLinterValue linter.style.header (← getOptions) do
309310
return
310311
if (← get).messages.hasErrors then
@@ -332,9 +333,12 @@ def headerLinter : Linter where run := withSetOptionIn fun stx ↦ do
332333
-- Report on broad or duplicate imports.
333334
broadImportsCheck importIds mainModule
334335
duplicateImportsCheck importIds
335-
if let some msg ← directoryDependencyCheck mainModule then
336-
Linter.logLint linter.directoryDependency stx msg
337-
336+
let errors ← directoryDependencyCheck mainModule
337+
if errors.size > 0 then
338+
let mut msgs := ""
339+
for msg in errors do
340+
msgs := msgs ++ "\n\n" ++ (← msg.toString)
341+
Linter.logLint linter.directoryDependency stx msgs.trimLeft
338342
let afterImports := firstNonImport? upToStx
339343
if afterImports.isNone then return
340344
let copyright := match upToStx.getHeadInfo with
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/-
2+
Copyright (c) 2025 Michael Rothgang. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Authors: Michael Rothgang
5+
-/
6+
import Mathlib.Init
7+
import Qq
8+
import Mathlib.Util.AssertExists
9+
10+
/--
11+
warning: Module MathlibTest.DirectoryDependencyLinter.Test depends on Mathlib.Util.AssertExists,
12+
but is only allowed to import modules starting with one of [Mathlib.Lean].
13+
Note: module Mathlib.Util.AssertExists is directly imported by this module
14+
note: this linter can be disabled with `set_option linter.directoryDependency false`
15+
---
16+
warning: The module doc-string for a file should be the first command after the imports.
17+
Please, add a module doc-string before `/-!# Tests for the `directoryDependency` linter
18+
-/
19+
`.
20+
note: this linter can be disabled with `set_option linter.style.header false`
21+
-/
22+
#guard_msgs in
23+
set_option linter.style.header true in
24+
25+
/-!
26+
# Tests for the `directoryDependency` linter
27+
-/
28+
29+
-- Some unit-tests for internal functions.
30+
#guard Lean.Name.isPrefixOf `Mathlib.Util `Mathlib.Util.Basic == true
31+
#guard Lean.Name.isPrefixOf `Mathlib.Util `Mathlib.Util.Nested.Basic == true
32+
#guard Lean.Name.isPrefixOf `Mathlib.Util `Mathlib.Utils.Basic == false
33+
#guard Lean.Name.isPrefixOf `Mathlib.Foo `Mathlib.Util.Foo == false
34+
#guard Lean.Name.isPrefixOf `Mathlib.Util `Mathlib.Utils == false

MathlibTest/Header.lean

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,6 @@ note: this linter can be disabled with `set_option linter.style.header false`
2424
warning: Duplicate imports: 'Mathlib.Tactic.Linter.Header' already imported
2525
note: this linter can be disabled with `set_option linter.style.header false`
2626
---
27-
warning: Modules starting with MathlibTest.Header are not allowed to import modules starting with Mathlib.Deprecated.
28-
This module depends on Mathlib.Deprecated.Aliases
29-
which is imported by this module.
30-
(Exceptions can be added to `overrideAllowedImportDirs`.)
31-
note: this linter can be disabled with `set_option linter.directoryDependency false`
32-
---
3327
warning: The module doc-string for a file should be the first command after the imports.
3428
Please, add a module doc-string before `/-!# Tests for the `docModule` linter
3529
-/

scripts/lint-style.lean

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,6 @@ In addition, this checks that
2323

2424
open Cli Lean.Linter Mathlib.Linter.TextBased System.FilePath
2525

26-
/-- Parse all imports in a text file at `path` and return just their names:
27-
this is just a thin wrapper around `Lean.parseImports'`.
28-
Omit `Init (which is part of the prelude). -/
29-
def findImports (path : System.FilePath) : IO (Array Lean.Name) := do
30-
return (← Lean.parseImports' (← IO.FS.readFile path) path.toString).imports
31-
|>.map (fun imp ↦ imp.module) |>.erase `Init
32-
3326
/-- Additional imports generated by `mk_all`. -/
3427
def explicitImports : Array Lean.Name := #[`Batteries, `Std]
3528

0 commit comments

Comments
 (0)