feat: lake: add support for running text linters from lake lint (#13513)

This PR extends `lake lint --builtin-lint` to also support text linters
(i.e. those using `logLint`/`logLintIf`), in addition to the environment
linters added in #13431. Text-linter warnings emitted during the build
are persisted into each module's `.olean` via a new
`Lean.Linter.lintLogExt` environment extension; `lake lint` re-runs the
build for the target modules and reads the entries back, reporting them
alongside the environment linter output.

---------

Co-authored-by: Mac Malone <tydeu@hatpress.net>
Co-authored-by: Thomas R. Murrills <68410468+thorimur@users.noreply.github.com>
This commit is contained in:
Wojciech Różowski 2026-04-28 16:09:04 +01:00 committed by GitHub
parent 432d11541b
commit 1a15db69ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 407 additions and 42 deletions

View file

@ -10,6 +10,7 @@ public import Lean.Language.Lean
public import Lean.Server.References
public import Lean.Util.Profiler
import Lean.Compiler.Options
import Lean.Linter.PersistentLintLog
public section
@ -195,6 +196,9 @@ def runFrontend
if let some oleanFileName := oleanFileName? then
profileitIO ".olean serialization" finalOpts do
let allMessages := snaps.getAll.foldl
(init := (.empty : MessageLog)) (fun acc s => acc ++ s.diagnostics.msgLog)
let env ← Linter.recordLints env allMessages
writeModule (writeIR := !Compiler.compiler.postponeCompile.get finalOpts) env oleanFileName
if let some ileanFileName := ileanFileName? then

View file

@ -20,3 +20,4 @@ public import Lean.Linter.UnusedSimpArgs
public import Lean.Linter.Coe
public import Lean.Linter.GlobalAttributeIn
public import Lean.Linter.EnvLinter
public import Lean.Linter.PersistentLintLog

View file

@ -65,6 +65,15 @@ register_builtin_option linter.all : Bool := {
descr := "enable all linters"
}
register_builtin_option linter.clippy : Bool := {
defValue := false
descr := "enables the set of clippy linters — linters that are turned off by default and \
only available via `lake lint`. A clippy linter early-returns unless this option is true."
}
def getLinterClippy (o : LinterOptions) : Bool :=
o.get linter.clippy.name linter.clippy.defValue
def getLinterAll (o : LinterOptions) (defValue := linter.all.defValue) : Bool :=
o.get linter.all.name defValue

View file

@ -0,0 +1,41 @@
/-
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
Released under Apache 2.0 license as described in the file LICENSE.
Authors: Wojciech Różowski
-/
module
prelude
public import Lean.Environment
public import Lean.Message
public section
namespace Lean.Linter
structure LintEntry where
linter : Name
message : SerialMessage
builtin_initialize lintLogExt :
PersistentEnvExtension LintEntry LintEntry (Array LintEntry) ←
registerPersistentEnvExtension {
mkInitial := pure #[]
addImportedFn := fun _ => pure #[]
addEntryFn := Array.push
exportEntriesFn := id
}
def getAllLints (env : Environment) : Array (Name × Array LintEntry) :=
env.header.moduleNames.mapIdx fun i mod =>
(mod, lintLogExt.getModuleEntries env i)
def recordLints (env : Environment) (messages : MessageLog) : BaseIO Environment := do
messages.reportedPlusUnreported.foldlM (init := env) fun env m => do
let kind := m.data.kind
if kind.isAnonymous then
return env
let sm ← m.serialize
return lintLogExt.addEntry env { linter := kind, message := sm }
end Lean.Linter

View file

@ -27,6 +27,17 @@ public structure BuildConfig extends LogConfig where
showSuccess : Bool := false
/-- File to save input-to-output mappings from the build of the workspace's root -/
outputsFile? : Option FilePath := none
/--
Per-package Lean option overrides, applied to every module whose owning
package's `baseName` appears as a key. When `recFetchSetup` builds module
`M`, the `LeanOptions` associated with `M.pkg.baseName` (if any) are appended
to `M.leanOptions`, overriding clashing entries.
Used by `lake lint` to inject `linter.clippy`/`linter.all` into every module
of a target package (so transitively-imported first-party modules capture
linter-tagged warnings), without touching dependencies.
-/
leanOptOverrides : Lean.NameMap Lean.LeanOptions := {}
/--
Whether the build should show progress information.
@ -91,3 +102,7 @@ public instance [Pure m] : MonadLift LakeM (BuildT m) where
@[inline] public def getIsQuiet [Functor m] [MonadBuild m] : m Bool :=
(· == .quiet) <$> getVerbosity
@[inline] public def getLeanOptOverrides [Functor m] [MonadBuild m]
: m (Lean.NameMap Lean.LeanOptions) :=
(·.leanOptOverrides) <$> getBuildConfig

View file

@ -540,6 +540,7 @@ def Module.recFetchSetup (mod : Module) : FetchM (Job ModuleSetup) := ensureJob
| some false => addTrace depTrace; addTrace libTrace; addPlatformTrace
| some true => addTrace depTrace
let {dynlibs, plugins} ← computeModuleDeps impLibs externLibs dynlibs plugins
let extra := (← getLeanOptOverrides).find? mod.pkg.baseName |>.getD {}
return {
name := mod.name
isModule := header.isModule
@ -548,7 +549,7 @@ def Module.recFetchSetup (mod : Module) : FetchM (Job ModuleSetup) := ensureJob
importArts := info.directArts
dynlibs := dynlibs.map (·.path)
plugins := plugins.map (·.path)
options := mod.leanOptions
options := mod.leanOptions ++ extra
}
/-- The `ModuleFacetConfig` for the builtin `setupFacet`. -/

View file

@ -7,6 +7,7 @@ module
prelude
public import Lean.Linter.EnvLinter
public import Lean.Linter.PersistentLintLog
import Lean.CoreM
import Lake.Config.Workspace
@ -20,16 +21,37 @@ public structure Args where
scope : Linter.EnvLinter.LintScope := .default
/-- Run only the specified linters. -/
only : Array Name := #[]
/-- Skip the up-to-date build check. -/
force : Bool := false
/-- The list of root modules to lint. -/
mods : Array Name := #[]
/--
Run the builtin environment linters on the given modules.
public def leanOptOverrides (args : Args) : LeanOptions :=
let enableAll : Array LeanOption :=
#[⟨`linter.clippy, .ofBool true⟩, ⟨`linter.all, .ofBool true⟩]
if !args.only.isEmpty then
LeanOptions.ofArray enableAll
else
match args.scope with
| .default => {}
| .clippy => LeanOptions.ofArray #[⟨`linter.clippy, .ofBool true⟩]
| .all => LeanOptions.ofArray enableAll
private def collectTextLints
(env : Environment) (args : Args) (pkgRoot : Name) : Array Linter.LintEntry :=
let matchOnly (linter : Name) : Bool :=
args.only.isEmpty || args.only.any (fun n => n.isSuffixOf linter)
let matchScope (linter : Name) : Bool :=
if !args.only.isEmpty then true
else match args.scope with
| .default => linter != `linter.clippy
| .clippy => linter == `linter.clippy
| .all => true
Linter.getAllLints env |>.foldl (init := #[]) fun acc (mod, entries) =>
if pkgRoot.isPrefixOf mod then
entries.foldl (init := acc) fun acc e =>
if matchOnly e.linter && matchScope e.linter then acc.push e else acc
else
acc
Assumes Lean's search path has already been properly configured.
-/
public def run (args : Args) : IO UInt32 := do
let mods := args.mods
if mods.isEmpty then
@ -44,11 +66,19 @@ public def run (args : Args) : IO UInt32 := do
for mod in mods do
unsafe Lean.enableInitializersExecution
let env ← importModules #[{ module := mod }, envLinterModule] {} (trustLevel := 1024) (loadExts := true)
let (result, _) ← CoreM.toIO (ctx := { fileName := "", fileMap := default }) (s := { env }) do
let textEntries := collectTextLints env args mod.getRoot
let textFailed := !textEntries.isEmpty
if textFailed then
IO.println s!"-- Text linter diagnostics in {mod}:"
for e in textEntries do
IO.print e.message.toString
let (declFailed, _) ← CoreM.toIO (ctx := { fileName := "", fileMap := default }) (s := { env }) do
let decls ← Linter.EnvLinter.getDeclsInPackage mod.getRoot
let linters ← Linter.EnvLinter.getChecks (scope := scope) (runOnly := runOnly)
if linters.isEmpty then
IO.println s!"-- No linters registered for {mod}."
IO.println s!"-- No environment linters registered for {mod}."
return false
let results ← Linter.EnvLinter.lintCore decls linters
let failed := results.any (!·.2.isEmpty)
@ -58,10 +88,11 @@ public def run (args : Args) : IO UInt32 := do
(groupByFilename := true) (useErrorFormat := true)
s!"in {mod}" (scope := if args.only.isEmpty then scope else .all) .medium linters.size
IO.print (← fmtResults.toString)
else
else unless textFailed do
IO.println s!"-- Linting passed for {mod}."
return failed
if result then
if textFailed || declFailed then
anyFailed := true
return if anyFailed then 1 else 0

View file

@ -251,18 +251,23 @@ USAGE:
By default, runs the package's configured lint driver. If `builtinLint` is
set to `true` in the package configuration, builtin lints also run.
Builtin linting (`--builtin-lint`, `--builtin-only`, `--clippy`, `--lint-all`,
`--lint-only`, or `builtinLint = true` in the package configuration) drives a
build of the targeted modules with the requested linter options enabled.
The lint driver path on its own does not trigger a build.
Positional `MODULE` arguments narrow only the builtin lints; if omitted,
the workspace's default target roots are used. The lint driver is invoked
with `lintDriverArgs` from the package config plus any arguments after
`--`; the `MODULE` list is not passed to it.
OPTIONS:
--builtin-lint run builtin environment linters
--builtin-lint run builtin environment and text linters
--builtin-only run only builtin linters, skip the lint driver
--clippy run only non-default (clippy) builtin linters
--lint-all run all builtin linters (default + clippy)
--lint-only <name> run only the specified builtin linter (repeatable)
--force skip the up-to-date build check
--lint-all run all registered linters, including defaults, clippy,
and any other disabled-by-default linters
--lint-only <name> run only the specified linter (repeatable)
A lint driver can be configured by either setting the `lintDriver` package
configuration option or by tagging a script or executable `@[lint_driver]`.

View file

@ -322,7 +322,7 @@ def lakeLongOption : (opt : String) → CliM PUnit
modifyThe LakeOptions fun opts =>
{opts with runBuiltinLint := true, builtinLint.only := opts.builtinLint.only.push name.toName}
-- Shared options
| "--force" => modifyThe LakeOptions ({· with shake.force := true, builtinLint.force := true})
| "--force" => modifyThe LakeOptions ({· with shake.force := true})
-- Shake options
| "--keep-implied" => modifyThe LakeOptions ({· with shake.keepImplied := true})
| "--keep-prefix" => modifyThe LakeOptions ({· with shake.keepPrefix := true})
@ -972,16 +972,29 @@ protected def checkTest : CliM PUnit := do
noArgsRem do exit <| if pkg.testDriver.isEmpty then 1 else 0
private def runBuiltinLint
(ws : Workspace) (args : BuiltinLint.Args) (mods : Array Lean.Name) : CliM UInt32 := do
let mods := if mods.isEmpty then ws.defaultTargetRoots else mods
(opts : LakeOptions) (ws : Workspace) (specifiedMods : Array Lean.Name)
: CliM UInt32 := do
let mods := if specifiedMods.isEmpty then ws.defaultTargetRoots else specifiedMods
if mods.isEmpty then
error "no modules specified and there are no applicable default targets"
let args := opts.builtinLint
let args := {args with mods}
unless args.force do
let specs ← parseTargetSpecs ws (mods.map (s!"+{·}") |>.toList)
let upToDate ← ws.checkNoBuild <| buildSpecs specs
unless upToDate do
error "there are out of date oleans; run `lake build` or fetch them from a cache first"
let specs ← parseTargetSpecs ws (mods.map (s!"+{·}") |>.toList)
let lintOpts := BuiltinLint.leanOptOverrides args
let overrides : Lean.NameMap Lean.LeanOptions :=
if lintOpts.values.isEmpty then
{}
else
mods.foldl (init := ({} : Lean.NameMap Lean.LeanOptions))
fun m modName =>
match ws.findTargetModule? modName with
| some mod => m.insert mod.pkg.baseName lintOpts
| none => m
let buildCfg := { mkBuildConfig opts with
outLv := .error
leanOptOverrides := overrides
}
ws.runBuild (buildSpecs specs) buildCfg
Lean.searchPathRef.set ws.augmentedLeanPath
BuiltinLint.run args
@ -995,7 +1008,7 @@ protected def lint : CliM PUnit := do
let mut exitCode : UInt32 := 0
if doBuiltinLint then
let mods := (← takeArgs).toArray.map (·.toName)
exitCode ← runBuiltinLint ws opts.builtinLint mods
exitCode ← runBuiltinLint opts ws mods
if hasDriver then
let driverExitCode ← noArgsRem do
ws.root.lint opts.subArgs (mkBuildConfig opts) |>.run (mkLakeContext ws)

View file

@ -0,0 +1,65 @@
import Lean
open Lean
/-! ## Tests for the persistent lint log extension -/
/--
Builds a `MessageLog` containing a single tagged warning whose `MessageData.kind` is
`linter.dummy`, runs `Linter.recordLints`, and returns the resulting extension state.
-/
def testRecordLints : CoreM (Array (Name × String)) := do
let env ← getEnv
let tagged : MessageData := .tagged `linter.dummy (m!"unused variable 'x'")
let msg : Message := {
fileName := "Test.lean"
pos := ⟨3, 5⟩
severity := .warning
data := tagged
}
let log : MessageLog := MessageLog.empty.add msg
let env ← Linter.recordLints env log
return (Linter.lintLogExt.getState env).map fun e =>
(e.linter, e.message.data)
/-- info: #[(`linter.dummy, "unused variable 'x'")] -/
#guard_msgs in
#eval testRecordLints
/-- Untagged messages must be ignored. -/
def testRecordLintsIgnoresUntagged : CoreM Nat := do
let env ← getEnv
let msg : Message := {
fileName := "Test.lean"
pos := ⟨1, 1⟩
severity := .error
data := m!"plain error with no tag"
}
let log : MessageLog := MessageLog.empty.add msg
let env ← Linter.recordLints env log
return (Linter.lintLogExt.getState env).size
/-- info: 0 -/
#guard_msgs in
#eval testRecordLintsIgnoresUntagged
/-- Multiple tagged messages are all recorded, in log order. -/
def testRecordLintsMultiple : CoreM (Array Name) := do
let env ← getEnv
let mk (kind : Name) (txt : String) : Message := {
fileName := "Test.lean"
pos := ⟨1, 1⟩
severity := .warning
data := .tagged kind (m!"{txt}")
}
let log : MessageLog :=
MessageLog.empty
|>.add (mk `linter.a "a")
|>.add (mk `linter.b "b")
|>.add (mk `linter.a "a2")
let env ← Linter.recordLints env log
return (Linter.lintLogExt.getState env).map (·.linter)
/-- info: #[`linter.a, `linter.b, `linter.a] -/
#guard_msgs in
#eval testRecordLintsMultiple

View file

@ -1,6 +1,6 @@
import Lean.Linter.EnvLinter
open Lean Meta
open Lean Meta Lean.Linter Lean.Elab.Command
-- A dummy clippy linter that flags any declaration whose name ends with "Clippy".
@[builtin_env_linter clippy] public meta def dummyClippy : Lean.Linter.EnvLinter.EnvLinter where
@ -10,3 +10,14 @@ open Lean Meta
if declName.toString.endsWith "Clippy" then
return some "declaration name ends with 'Clippy'"
return none
-- A dummy clippy text linter: fires on every `declaration` command, but only
-- when `linter.clippy = true`. Tags its warnings with `linter.clippy` via
-- `logLint`, which is how `lake lint --clippy` identifies clippy-scope entries.
def dummyClippyTextLinter : Linter where
run cmdStx := do
unless getLinterClippy (← getLinterOptions) do return
unless cmdStx.getKind == ``Lean.Parser.Command.declaration do return
logLint linter.clippy cmdStx m!"clippy text linter saw a declaration"
initialize addLinter dummyClippyTextLinter

View file

@ -1,3 +1,5 @@
import Main.Sub
-- This uses `def` for a Prop — the `defLemma` linter should flag this.
def shouldBeTheorem : 1 = 1 := rfl

View file

@ -0,0 +1,15 @@
-- Env-linter violation: `def` on a Prop — should be caught by `defLemma`
-- regardless of build-time options, since env linters run post-build.
def shouldBeTheoremInSub : 1 = 1 := rfl
-- Default text-linter violation: `linter.unusedVariables` has `defValue := true`,
-- so it fires on any build and the warning lands in Main.Sub.olean unconditionally.
def unusedVarInSub : Nat :=
let unusedInSub := 7
3
-- Non-default text-linter violation: `linter.missingDocs` has `defValue := false`,
-- so it only fires when the module was compiled with `linter.all = true`. Under
-- the current per-module override scheme, Main.Sub does NOT get that flag
-- (only `Main` does), so this warning will be absent from Main.Sub.olean.
def undocumentedInSub : Nat := 99

View file

@ -0,0 +1,11 @@
-- `linter.unusedVariables` has `defValue := true`, so it fires during the normal
-- build that backs `lake lint --builtin-lint`. The warning is captured in the
-- olean via `lintLogExt` and re-emitted by `lake lint`.
def unusedVarFixture : Nat :=
let unusedLet := 5
3
-- `linter.missingDocs` has `defValue := false`, so it only fires when Lake's
-- build is invoked with `linter.all = true` (i.e. `--lint-all` or `--lint-only`).
-- This public def has no docstring and will be flagged accordingly.
def undocumentedPublicDef : Nat := 42

View file

@ -1,3 +1,5 @@
#!/usr/bin/env bash
rm -rf .lake
rm -f lakefile.toml lake-manifest.json Main.lean Clean.lean Linters.lean ClippyViolations.lean
rm -f lakefile.toml lake-manifest.json Main.lean Clean.lean Linters.lean ClippyViolations.lean TextLints.lean
rm -rf Main
rm -rf dep

View file

@ -0,0 +1,5 @@
-- A public def without a docstring. `linter.missingDocs` (defValue=false) only
-- fires when `linter.all=true` is injected during the build. This file lives in
-- a non-root package (`dep`), so the option override must be keyed by the
-- `dep` package's baseName for the warning to be captured.
def undocumentedInDep : Nat := 7

View file

@ -0,0 +1,4 @@
name = "dep"
[[lean_lib]]
name = "Dep"

View file

@ -1,6 +1,6 @@
import Lean.Linter.EnvLinter
open Lean Meta
open Lean Meta Lean.Linter Lean.Elab.Command
-- A dummy clippy linter that flags any declaration whose name ends with "Clippy".
@[builtin_env_linter clippy] public meta def dummyClippy : Lean.Linter.EnvLinter.EnvLinter where
@ -10,3 +10,14 @@ open Lean Meta
if declName.toString.endsWith "Clippy" then
return some "declaration name ends with 'Clippy'"
return none
-- A dummy clippy text linter: fires on every `declaration` command, but only
-- when `linter.clippy = true`. Tags its warnings with `linter.clippy` via
-- `logLint`, which is how `lake lint --clippy` identifies clippy-scope entries.
def dummyClippyTextLinter : Linter where
run cmdStx := do
unless getLinterClippy (← getLinterOptions) do return
unless cmdStx.getKind == ``Lean.Parser.Command.declaration do return
logLint linter.clippy cmdStx m!"clippy text linter saw a declaration"
initialize addLinter dummyClippyTextLinter

View file

@ -1,3 +1,5 @@
import Main.Sub
-- This uses `def` for a Prop — the `defLemma` linter should flag this.
def shouldBeTheorem : 1 = 1 := rfl

View file

@ -0,0 +1,15 @@
-- Env-linter violation: `def` on a Prop — should be caught by `defLemma`
-- regardless of build-time options, since env linters run post-build.
def shouldBeTheoremInSub : 1 = 1 := rfl
-- Default text-linter violation: `linter.unusedVariables` has `defValue := true`,
-- so it fires on any build and the warning lands in Main.Sub.olean unconditionally.
def unusedVarInSub : Nat :=
let unusedInSub := 7
3
-- Non-default text-linter violation: `linter.missingDocs` has `defValue := false`,
-- so it only fires when the module was compiled with `linter.all = true`. Under
-- the current per-module override scheme, Main.Sub does NOT get that flag
-- (only `Main` does), so this warning will be absent from Main.Sub.olean.
def undocumentedInSub : Nat := 99

View file

@ -0,0 +1,11 @@
-- `linter.unusedVariables` has `defValue := true`, so it fires during the normal
-- build that backs `lake lint --builtin-lint`. The warning is captured in the
-- olean via `lintLogExt` and re-emitted by `lake lint`.
def unusedVarFixture : Nat :=
let unusedLet := 5
3
-- `linter.missingDocs` has `defValue := false`, so it only fires when Lake's
-- build is invoked with `linter.all = true` (i.e. `--lint-all` or `--lint-only`).
-- This public def has no docstring and will be flagged accordingly.
def undocumentedPublicDef : Nat := 42

View file

@ -0,0 +1,5 @@
-- A public def without a docstring. `linter.missingDocs` (defValue=false) only
-- fires when `linter.all=true` is injected during the build. This file lives in
-- a non-root package (`dep`), so the option override must be keyed by the
-- `dep` package's baseName for the warning to be captured.
def undocumentedInDep : Nat := 7

View file

@ -0,0 +1,4 @@
name = "dep"
[[lean_lib]]
name = "Dep"

View file

@ -1,8 +1,13 @@
name = "lint"
defaultTargets = ["Main", "Clean", "Linters", "ClippyViolations"]
defaultTargets = ["Main", "Clean", "Linters", "ClippyViolations", "TextLints"]
[[require]]
name = "dep"
path = "dep"
[[lean_lib]]
name = "Main"
globs = "Main.*"
[[lean_lib]]
name = "Clean"
@ -12,3 +17,6 @@ name = "Linters"
[[lean_lib]]
name = "ClippyViolations"
[[lean_lib]]
name = "TextLints"

View file

@ -1,8 +1,13 @@
name = "lint"
defaultTargets = ["Main", "Clean", "Linters", "ClippyViolations"]
defaultTargets = ["Main", "Clean", "Linters", "ClippyViolations", "TextLints"]
[[require]]
name = "dep"
path = "dep"
[[lean_lib]]
name = "Main"
globs = "Main.*"
[[lean_lib]]
name = "Clean"
@ -12,3 +17,6 @@ name = "Linters"
[[lean_lib]]
name = "ClippyViolations"
[[lean_lib]]
name = "TextLints"

View file

@ -4,19 +4,32 @@ source ../common.sh
./clean.sh
cp -r input/* .
# --builtin-lint should fail with a clear message when oleans are not built
lake_out lint --builtin-lint || true
match_pat 'out of date oleans' produced.out
# up-to-date check is per-module: building only Clean should let us lint Clean
test_run build Clean
# --builtin-lint drives the build itself; we do not need to `lake build` first.
# Linting Clean should succeed (no violations) and implicitly build Clean.
test_run lint --builtin-only Clean
# but linting Main (not yet built) should still fail the up-to-date check
lake_out lint --builtin-only Main || true
match_pat 'out of date oleans' produced.out
# --- Text linter capture (persistent lint log) ---
test_run build
# Default scope: `linter.unusedVariables` (defValue=true) fires during the build,
# is captured in `lintLogExt`, and is re-emitted by `lake lint` post-build.
# `linter.missingDocs` (defValue=false) must NOT fire without --lint-all/--lint-only.
lake_out lint --builtin-only TextLints || true
match_pat 'unused variable `unusedLet`' produced.out
no_match_pat 'missing doc string' produced.out
# --lint-all enables all linters, so missingDocs fires too.
lake_out lint --lint-all TextLints || true
match_pat 'unused variable `unusedLet`' produced.out
match_pat 'missing doc string for public def undocumentedPublicDef' produced.out
# --lint-only filters entries by suffix match against the linter name.
lake_out lint --lint-only missingDocs TextLints || true
match_pat 'missing doc string for public def undocumentedPublicDef' produced.out
no_match_pat 'unused variable' produced.out
lake_out lint --lint-only unusedVariables TextLints || true
match_pat 'unused variable `unusedLet`' produced.out
no_match_pat 'missing doc string' produced.out
# --builtin-lint should detect the defLemma violation in Main (the default target)
lake_out lint --builtin-lint || true
@ -38,23 +51,63 @@ lake_out lint --lint-only defLemma || true
match_pat 'shouldBeTheorem' produced.out
no_match_pat 'badUnivDecl' produced.out
# --- Transitive-import behaviour ---
# `Main` (a default target) imports `Main.Sub`. Both live under the `Main.*`
# module-name prefix, so `getDeclsInPackage Main` covers them and
# `collectTextLints` filters by `Main.isPrefixOf mod`. Overrides are keyed by
# package, so passing any module of a package flips the flag for every module
# in that package.
# Env linters run post-build against `importModules`-loaded decls, so
# `defLemma` catches `shouldBeTheoremInSub` regardless of override scope.
lake_out lint --builtin-lint Main || true
match_pat 'shouldBeTheoremInSub' produced.out
# `linter.unusedVariables` (defValue=true) fires on every build, so its entry
# lands in `Main.Sub.olean` unconditionally.
match_pat 'unused variable `unusedInSub`' produced.out
# Explicit arg with --lint-all: the override applies to the whole package of
# `Main`, so `Main.Sub` is also built with `linter.all=true` and the
# missingDocs warning IS captured.
lake_out lint --lint-all Main || true
match_pat 'missing doc string for public def undocumentedInSub' produced.out
# No args: override is keyed by the root package; same effect on Main.Sub.
lake_out lint --lint-all || true
match_pat 'missing doc string for public def undocumentedInSub' produced.out
# Clean module has no violations; exit code should be 0
test_run lint --builtin-only Clean
# Without --clippy, the clippy linter should not run
# Without --clippy, the clippy linters (both the env linter and the dummy clippy
# text linter in Linters.lean) must not run.
lake_out lint --builtin-only ClippyViolations || true
no_match_pat 'badNameClippy' produced.out
no_match_pat 'clippy text linter saw a declaration' produced.out
# --clippy should run only non-default (clippy) linters
# --clippy should run only non-default (clippy) linters, including the clippy
# text linter which tags its warnings with `linter.clippy`.
lake_out lint --clippy ClippyViolations || true
match_pat 'badNameClippy' produced.out
match_pat "declaration name ends with 'Clippy'" produced.out
match_pat 'clippy text linter saw a declaration' produced.out
# --clippy should not run default linters
no_match_pat 'shouldBeTheorem' produced.out
# --lint-all should run both default and clippy linters
# --clippy on TextLints: the default `linter.unusedVariables` entry is filtered
# out because its tag is not `linter.clippy`. The file has no clippy-tagged
# linter, so the clippy-scope text-linter output should be empty.
lake_out lint --clippy TextLints || true
no_match_pat 'unused variable' produced.out
no_match_pat 'missing doc string' produced.out
# --lint-all should run both default and clippy linters, for both the
# declaration-linter flow (badNameClippy from `dummyClippy`) and the text-linter
# flow (the `linter.clippy`-tagged warning from `dummyClippyTextLinter`).
lake_out lint --lint-all ClippyViolations || true
match_pat 'badNameClippy' produced.out
match_pat 'clippy text linter saw a declaration' produced.out
# Multiple --lint-only flags accumulate: both named linters should run
lake_out lint --lint-only defLemma --lint-only checkUnivs || true
@ -146,3 +199,16 @@ match_pat 'lint-driver:' produced.out
# builtinLint = true + lint driver: check-lint succeeds
test_run -f with-driver.lean check-lint
# --- Non-root package as a lint target ---
# `Dep` lives in a path-based dependency (`dep`), not in the root package.
# Specifying it on the command line must key the linter option override by
# the *dep* package's baseName, not the root's, so that `linter.all=true`
# reaches `Dep` during build and `missingDocs` is captured in its olean.
lake_out lint --lint-all Dep || true
match_pat 'missing doc string for public def undocumentedInDep' produced.out
# Baseline: without `--lint-all`, no override is injected, so `missingDocs`
# stays at its default (off) and produces no entry for `Dep`.
lake_out lint --builtin-only Dep || true
no_match_pat 'missing doc string for public def undocumentedInDep' produced.out