rules_lean
Bazel rules for Lean 4 with Lake integration (rules_lean). Reuses Lake's mathlib cache via lake_workspace repository rule.
| Latest | 0.6.2 |
|---|---|
| Versions | 24 |
| Category | Bazel rules |
| Compat level | 1 |
| Maintainers | Matt Marshall |
| Registry | https://registry.tbzl.dev/modules/rules_lean/ |
| Source | github.com/tomato-bazel/rules_lean |
bazel_dep(name = "rules_lean", version = "0.6.2")
View source & releases on GitHub ↗
Bazel rules for Lean 4, with native Lake integration that reuses Mathlib’s upstream Reservoir cache instead of forcing each consumer to self-host a multi-gigabyte olean tarball.
- rules:
lean_test,lean_axiom_test,lean_emit,lean_library,lean_binary,lean_prebuilt_library,lean_toolchain— see docs/lean.md. - proof gates (0.7.0): a
sorryfails the build (forbid_sorry, defaultTrue), andlean_axiom_testfails it when a theorem’s transitive axiom dependencies leave an allowlist. See Gating a proof. - lake integration:
lake_workspacerepository rule +lakemodule extension — see docs/lake.md. - RulesLean Lean library (
lean/lib/): structured introspection of.oleanfiles (RulesLean.Olean) and Lake workspaces (RulesLean.Workspace). Internal helpers underRulesLean.Internal.*are unstable; treat them as opt-in and expect API churn between releases. See lean/lib/RulesLean.lean for the entry-point doc. - lake_imports_manifest target (opt-in): set
emit_imports_manifest = Trueon yourlake.workspaceandlake_workspacebuilds the RulesLean library +oleanImportsCLI and runs it over every olean in the workspace. Result lands at@<your-lake-deps>//:lake_imports_manifest— a TSV of<path>\t<imported-module>edges (~5MB / 42k edges for full mathlib), for import-graph analysis, tree-shaking, dead-code detection. Off by default since 0.6.0: compiling the CLI leaves a ~149MB.lake/build/behind (measured, darwin/arm64) in everylake_workspace, whether or not anything reads the manifest. The target still exists when off — it is just empty.
Install
Add the registry to your .bazelrc:
common --registry=https://registry.fastverk.com/
common --registry=https://bcr.bazel.build/
In your MODULE.bazel:
bazel_dep(name = "rules_lean", version = "0.3.0")
lake = use_extension("@rules_lean//lean:lake.bzl", "lake")
lake.workspace(
name = "lake_deps",
lean_toolchain = "//:lean-toolchain",
lakefile = "//:lakefile.lean",
lake_manifest = "//:lake-manifest.json",
)
use_repo(lake, "lake_deps")
register_toolchains("@lake_deps//:lean_toolchain_def")
Quick start
Your repo root needs three Lake-convention files:
lean-toolchain — pins the Lean version (Lake and Bazel both honor it):
leanprover/lean4:v4.29.1
lakefile.lean — a deps-only lakefile listing Lake packages you want:
import Lake
open Lake DSL
package «my-project» where
require mathlib from git
"https://github.com/leanprover-community/mathlib4.git" @ "v4.29.1"
lake-manifest.json — generate once with elan-installed lake, then commit:
lake update # produces lake-manifest.json with all transitive revs pinned
Now any BUILD.bazel can typecheck Lean code against the resolved packages:
load("@rules_lean//lean:lean.bzl", "lean_test")
lean_test(
name = "smoke",
srcs = ["Smoke.lean"],
entry = "Smoke.lean",
deps = [
"@lake_deps//:mathlib",
"@lake_deps//:batteries",
],
)
-- Smoke.lean
import Mathlib.Data.Finset.Basic
example : (∅ : Finset Nat).card = 0 := Finset.card_empty
bazel test //:smoke will, on first run: download the Lean toolchain, run
lake update, run lake exe cache get (Reservoir-cached mathlib oleans),
and typecheck Smoke.lean.
Gating a proof
A green lean_test means the code type-checks. It does not mean anything
was proved: sorry is a warning, lean exits 0 on it, and until 0.7.0 that
warning was discarded. Two rules close the gap, and neither needs Mathlib.
load("@rules_lean//lean:lean.bzl", "lean_axiom_test", "lean_test")
# `forbid_sorry` defaults True — an admitted goal anywhere in `srcs` is red.
lean_test(
name = "proofs_test",
srcs = glob(["**/*.lean"]),
entry = "Root.lean",
)
# Every named theorem's TRANSITIVE axiom dependencies must be in the allowlist.
lean_axiom_test(
name = "axioms_test",
srcs = glob(["**/*.lean"]),
theorems = [
"MyProject.main_theorem",
"MyProject.confluence",
],
# allowed_axioms defaults to [propext, Classical.choice, Quot.sound].
)
lean_axiom_test generates a Lean module and checks at elaboration time with
Lean.collectAxioms — the same API behind #print axioms, so it agrees with a
hand audit by construction. The default allowlist is Lean’s three standard
axioms; what it excludes is the point:
| Axiom | What its presence means |
|---|---|
sorryAx | An admitted goal. The theorem is not proved. |
Lean.ofReduceBool | A native_decide — the claim rests on the compiler and runtime, not the kernel. |
Tightening the allowlist is the interesting direction. A theorem that needs
only [propext, Quot.sound] today and quietly picks up Classical.choice
tomorrow has changed, and allowed_axioms = ["propext", "Quot.sound"] is what
reports it.
Auditing a compiled dep instead of sources — name the modules to import:
lean_axiom_test(
name = "axioms_test",
deps = [":my_library"],
imports = ["MyProject"],
theorems = ["MyProject.main_theorem"],
)
See examples/axiom_audit for both, with the negative
tests that prove each gate actually fires.
How it works
lake_workspace is a Bazel repository rule that:
- Reads
lean-toolchain, downloads the matching Lean tarball (sha256-pinned for known versions). - Stages your
lakefile+lake-manifest.jsoninto the external repo. - Runs
lake updateto materialize all transitive Lake package checkouts at the manifest-pinned revs. - If mathlib is in the dep graph, runs
lake exe cache getto fetch prebuilt oleans from the upstream Reservoir cache. - Generates a
BUILD.bazelexposing each resolved Lake package as its ownlean_prebuilt_library(target name = Lake’s directory name::mathlib,:batteries,:Cli,:LeanSearchClient, …).
What’s hermetic
| Layer | Pinned by |
|---|---|
| Lean toolchain | sha256 in lean/private/known_lean_versions.bzl |
| Lake dep git revs | Your committed lake-manifest.json (Lake’s lockfile) |
| Mathlib oleans | Content-addressed by mathlib commit in Reservoir cache (verified by Lake) |
For Lake packages not covered by the Reservoir cache (anything outside
mathlib’s transitive deps), pass allow_source_build = True to lake.workspace
— the rule then runs lake build <pkg> to compile oleans from source. Slow
but unavoidable for custom deps.
What’s not (yet) hermetic
- Lean versions that aren’t pinned in
known_lean_versions.bzldownload unverified (with a warning). Add an entry — one line — for any new version you need. lake updatereaches the network. The lake-manifest.json constrains what gets resolved, but the network has to be there. Bazel’s normal repository-cache mitigates the cost on rebuilds.
Compatibility
- Bazel: 7.4+, bzlmod required.
- Lean: 4.29.1 and 4.32.2 exercised;
lean_axiom_testand thesorrygate are verified on both. Other versions: add the platform sha256 tolean/private/known_lean_versions.bzl(compute withcurl -fsSL <url> | shasum -a 256). - Platforms: darwin_aarch64, darwin_x86_64, linux_x86_64, linux_aarch64.
Contributing
Rule reference docs (docs/lean.md, docs/lake.md) are stardoc-generated
from the .bzl docstrings and committed to source. After editing a rule
docstring, regenerate:
bazel run //docs:update
CI gates this via bazel test //docs/... (diff_test against the committed
output).
License
MIT.
Usage#
Real usage, taken from the module’s examples/.
examples/axiom_audit/BUILD.bazel
load("@rules_lean//lean:lean.bzl", "lean_axiom_test", "lean_library", "lean_test")
# `lean_axiom_test`, and the `forbid_sorry` gate, with their failure paths.
#
# Every claim a gate makes is only worth what its NEGATIVE test is worth, so
# each pass here has a matching `_is_rejected` target that must fail, plus a
# control that isolates WHICH gate is doing the rejecting. The negatives are
# `manual`-tagged (they cannot be in `bazel test //...`, they are meant to be
# red) and CI runs them explicitly, asserting a non-zero exit — see the
# `axiom_audit` job in .github/workflows/ci.yml.
#
# Fixture axiom footprints, from `#print axioms`:
# Audited.constructive — none
# Audited.propositional — [propext]
# Audited.classical — [propext, Classical.choice, Quot.sound]
# Audited.admitted — [sorryAx]
lean_library(
name = "audited",
srcs = ["Audited/Arith.lean"],
)
# ── Positive ──────────────────────────────────────────────────────────────
# The default allowlist: Lean's three standard axioms. Accepts all three
# honest theorems.
lean_axiom_test(
name = "default_allowlist_test",
srcs = ["Audited/Arith.lean"],
theorems = [
"Audited.constructive",
"Audited.propositional",
"Audited.classical",
],
)
# A TIGHTER allowlist that still holds. Without this, a rule that ignored
# `allowed_axioms` entirely and always passed would look identical to a working
# one on `default_allowlist_test` alone.
lean_axiom_test(
name = "tight_allowlist_test",
srcs = ["Audited/Arith.lean"],
allowed_axioms = ["propext"],
theorems = [
"Audited.constructive",
"Audited.propositional",
],
)
# Same audit, but against a COMPILED dep rather than sources — the `deps` +
# `imports` path, which is how a consumer with a published olean tree wires it.
lean_axiom_test(
name = "via_dep_test",
imports = ["Audited.Arith"],
theorems = ["Audited.classical"],
deps = [":audited"],
)
# The control for `sorry_is_rejected` below: the same file, same rule, gate
# off. If this ever goes red the negative test proves nothing, because the
# failure would not be attributable to the gate.
lean_test(
name = "sorry_allowed_when_permitted_test",
srcs = ["Audited/Admitted.lean"],
entry = "Audited/Admitted.lean",
forbid_sorry = False,
)
# ── Negative: these MUST fail. CI asserts it. ─────────────────────────────
# The allowlist tightened by one axiom. `Audited.classical` needs
# `Classical.choice`; dropping it from the allowlist must turn the build red.
lean_axiom_test(
name = "tightened_allowlist_is_rejected",
srcs = ["Audited/Arith.lean"],
allowed_axioms = [
"propext",
"Quot.sound",
],
tags = ["manual"],
theorems = ["Audited.classical"],
)
# `sorryAx` is in no allowlist, so an admitted theorem fails the AXIOM gate.
# `forbid_sorry = False` on purpose: it isolates the axiom check from the
# compile-time sorry check, so this target proves the audit itself rejects it.
lean_axiom_test(
name = "admitted_is_rejected",
srcs = ["Audited/Admitted.lean"],
forbid_sorry = False,
imports = ["Audited.Admitted"],
tags = ["manual"],
theorems = ["Audited.admitted"],
)
# The compile-time gate, independent of any axiom audit: `forbid_sorry`
# defaults True, so merely compiling a tree containing a `sorry` is red.
lean_test(
name = "sorry_is_rejected",
srcs = ["Audited/Admitted.lean"],
entry = "Audited/Admitted.lean",
tags = ["manual"],
)
examples/batteries_smoke/BUILD.bazel
load("@lake_deps_smoke//:packages.bzl", "LAKE_PACKAGES")
load("@rules_lean//lean:lean.bzl", "lean_test")
package(default_visibility = ["//visibility:public"])
exports_files([
"lakefile.lean",
"lake-manifest.json",
"lean-toolchain",
])
# End-to-end smoke: validates that lake_workspace materialized Batteries
# and exposed it as a per-package lean_prebuilt_library that lean_test
# can consume. No mathlib in deps -> no Reservoir cache get -> the
# `allow_source_build = True` source-build path is what's under test.
#
# Also validates the generated `packages.bzl`: instead of hand-listing
# `@lake_deps_smoke//:batteries`, consume the derived `LAKE_PACKAGES`
# label set (here, exactly [batteries]).
lean_test(
name = "smoke_test",
srcs = ["Smoke.lean"],
entry = "Smoke.lean",
deps = LAKE_PACKAGES,
)
examples/olean_roundtrip/BUILD.bazel
load("@rules_lean//lean:lean.bzl", "lean_library", "lean_olean_archive", "lean_test")
load("@rules_shell//shell:sh_test.bzl", "sh_test")
# Round-trip for the 0.4.0 cross-repo compiled-olean seam:
#
# lean_library → compile Lib/Thing.lean to a persistent Lib/Thing.olean tree
# lean_test → Consumer.lean type-checks against the PREBUILT olean (dep),
# with no source re-share and no recompile of the library
# lean_olean_archive → bundle the library's .olean tree into a tarball
# sh_test → assert the tarball carries Lib/Thing.olean
#
# (The full http_archive → lean_imported_library hop is exercised where a
# release artifact is actually fetched; lean_imported_library shares its
# implementation with the lake-tested lean_prebuilt_library.)
lean_library(
name = "lib",
srcs = ["Lib/Thing.lean"],
)
lean_test(
name = "consume_test",
srcs = ["Consumer.lean"],
entry = "Consumer.lean",
deps = [":lib"],
)
lean_olean_archive(
name = "lib_archive",
library = ":lib",
)
sh_test(
name = "archive_has_olean_test",
srcs = ["archive_check.sh"],
args = ["$(location :lib_archive)"],
data = [":lib_archive"],
)
examples/regen_smoke/BUILD.bazel
load("@rules_lean//lean:lean.bzl", "lean_main_test", "lean_regen_test")
# Smoke for `lean_regen_test`. Validates the full pipeline:
# 1. lean_emit runs Hello.lean via the registered Lean toolchain.
# 2. Captures `IO.println "hello from lean_regen_test"` to stdout.
# 3. diff_test compares the captured stdout (with trailing newline)
# against the committed expected.txt.
# Fails if the committed expected drifts from what Hello.lean emits.
lean_regen_test(
name = "regen_smoke",
srcs = ["Hello.lean"],
entry = "Hello.lean",
expected = "expected.txt",
)
# Smoke for the `data` attr (v0.3.3). EchoFixture.lean does
# `IO.FS.readFile "fixture.txt"` and echoes the content; lean_regen_test
# diff_tests the captured stdout against the same `fixture.txt`.
# Passing proves the data file is staged in the action's work dir
# and reachable via package-relative path from the Lean entry.
lean_regen_test(
name = "regen_smoke_data",
srcs = ["EchoFixture.lean"],
data = ["fixture.txt"],
entry = "EchoFixture.lean",
expected = "fixture.txt",
)
# Smoke for `lean_main_test` (v0.3.5). Compiles + runs ExitZero.lean
# whose `main : IO UInt32` returns 0. Test passes iff the Lean program
# exits 0; non-zero exit (or compile failure) fails the test.
lean_main_test(
name = "regen_smoke_exit",
srcs = ["ExitZero.lean"],
entry = "ExitZero.lean",
)
examples/shared_namespace/BUILD.bazel
load("@rules_lean//lean:lean.bzl", "lean_library", "lean_test")
# Regression test for LEAN_PATH namespace resolution.
#
# Lean resolves a module in the FIRST LEAN_PATH root owning its top-level
# directory and does not fall through. Two dep roots both owning `Pg/` therefore
# cannot both be reached via LEAN_PATH — the second one's modules resolve into
# the first one's root and the compile fails with "object file ... does not
# exist".
#
# `_dep_manifest_lines` used to stage a dep only when it shared a top-level
# namespace with the CONSUMER. Here the consumer is `App`, disjoint from both
# deps, so nothing was staged and both went on leanpath — the failing shape.
# Now a namespace owned by more than one dep root is staged regardless.
#
# This mirrors the real case: leangres publishes pgcatalog and pgquery, both
# rooted at `Pg/`, and a consumer rooted at `Aion/` has to import both.
lean_library(
name = "pg_catalog",
srcs = ["Pg/Catalog/Tables.lean"],
)
lean_library(
name = "pg_query",
srcs = ["Pg/Query/Top.lean"],
)
lean_test(
name = "two_deps_one_namespace_test",
srcs = ["App/Main.lean"],
entry = "App/Main.lean",
deps = [
":pg_catalog",
":pg_query",
],
)Rules & providers#
Generated with Stardoc from the module's .bzl sources.
from docs/lake.md
Lake integration for rules_lean.
lake_workspace is a repository rule that materializes any Lake workspace
(lakefile + lake-manifest.json) into a Bazel-managed external repo,
downloads the matching Lean toolchain, resolves Lake packages, and exposes
each resolved package as its own lean_prebuilt_library target.
Generated targets in @<name>//::
:lean_toolchain/:lean_toolchain_def— register viaregister_toolchains(...).:<package>— onelean_prebuilt_libraryper Lake package found under.lake/packages/<package>/. Target names preserve Lake’s directory casing (e.g.:mathlib,:batteries,:Cli,:LeanSearchClient). Consumers depend on multiple packages by listing all needed names.
Deps are materialized from the pinned lake-manifest.json (never via lake update — see _lake_workspace_impl). Fast path for mathlib-based workspaces:
if .lake/packages/mathlib/ is present, the rule runs lake exe cache get
(tree-shaken by cache_roots) to pull prebuilt oleans from the Reservoir
cache (covering mathlib + its transitive deps). For non-mathlib packages and
workspaces, lake build produces oleans from source.
Use via the module extension:
lake = use_extension("@rules_lean//lean:lake.bzl", "lake")
lake.workspace(
name = "lake_deps",
lean_toolchain = "//:lean-toolchain",
lakefile = "//:lakefile.lean",
lake_manifest = "//:lake-manifest.json",
)
use_repo(lake, "lake_deps")
register_toolchains("@lake_deps//:lean_toolchain_def")
# In a BUILD.bazel:
lean_test(
name = "smoke",
srcs = ["Smoke.lean"],
entry = "Smoke.lean",
deps = ["@lake_deps//:mathlib", "@lake_deps//:batteries"],
)
Hermeticity:
- The Lean toolchain is downloaded with a known sha256 (see private/known_lean_versions.bzl) when the version is pinned there. Unpinned versions download unverified (warning emitted).
- Lake dep revs are pinned by the user’s committed lake-manifest.json.
- Mathlib oleans (when applicable) are content-addressed by mathlib’s commit hash in the upstream Reservoir cache; integrity is verified by Lake.
Constraints on the lakefile passed in:
- Should be a deps-only lakefile (the rule creates a placeholder
package source). Build directives (
lean_lib,lean_exe) for the user’s own code don’t belong here — those live in Bazel BUILD files via thelean_test/lean_emitrules.
lake_workspace
load("@rules_lean//lean:lake.bzl", "lake_workspace")
lake_workspace(name, allow_source_build, cache_roots, emit_imports_manifest, lake_manifest,
lakefile, lean_dist_lake, lean_dist_toolchain, lean_toolchain, olean_cache,
olean_cache_packages)
Materializes a Lake workspace as a Bazel external repo. Produces :lean_toolchain_def + one lean_prebuilt_library per resolved Lake package (target name = Lake’s directory name).
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this repository. | Name | required | |
| allow_source_build | If True, run lake build <pkg> for every package whose oleans aren’t covered by lake exe cache get. Slow for large packages (mathlib from source is ~30 min); fast and necessary for custom Lake deps that have no upstream cache. | Boolean | optional | False |
| cache_roots | Module specs to TREE-SHAKE mathlib’s olean download to — the roots your workspace actually imports (e.g. [“Mathlib.Data.List.Infix”, “Mathlib.Order.Basic”]). Passed to lake exe cache get <roots>, which mathlib’s cache CLI resolves via filterByRootModules to those roots PLUS their transitive closure — so the set is always sound; you cannot under-fetch a module you import.Empty (the default) fetches ALL of mathlib, which is what every consumer did before this attr existed. That is rarely what you want: measured against mathlib @ v4.30.0-rc2 (7933 modules, ~2.0 GB of olean+ilean), a Lean→SQL emitter needing 6 roots pulls 1302 modules / 324 MB — an 84% saving. Adding a CategoryTheory + Lie-algebra lane on top cost only +102 MB, so the win is in NOT fetching the other 6373 modules, not in trimming what you import. Specs resolve against the src search path, so Mathlib.Data.List.Infix and Mathlib/Data/List/Infix.lean both work. Ignored for workspaces without mathlib (their cache exe does not exist). | List of strings | optional | [] |
| emit_imports_manifest | If True, populate @<ws>//:lake_imports_manifest (the <olean-path>\t<imported-module> TSV over every resolved package). Off by default because producing it first compiles the RulesLean oleanImports CLI with the consumer’s Lean toolchain, which leaves a ~149 MB .lake/build/ behind PER lake_workspace (measured, darwin/arm64, Lean v4.30.0-rc2). The manifest is a niche introspection aid (“what does olean X import?”) and nothing needs it to build Lean code, so the default is not to pay for it.The target and the .tsv exist either way — empty when off — so flipping this attr never breaks a consumer’s BUILD file, only the contents it reads. | Boolean | optional | False |
| lake_manifest | The committed lake-manifest.json (pins git revs of every Lake dep). | Label | required | |
| lakefile | The lakefile (deps-only — no library/exe directives for the user’s own code). | Label | required | |
| lean_dist_lake | The shared toolchain’s bin/lake (for fetch-time lake runs). | Label | required | |
| lean_dist_toolchain | The shared lean_toolchain rule; the workspace re-declares a toolchain() pointing at it and aliases :lean_toolchain to it. | Label | required | |
| lean_toolchain | The lean-toolchain file. Drives both Lake’s toolchain choice and the Lean binary Bazel downloads. | Label | required | |
| olean_cache | Base URL/path for prebuilt-olean tarballs (a private cache — never public by default). The LEAN_OLEAN_CACHE repo_env overrides it. Empty → packages without an upstream cache fall back to source build. | String | optional | "" |
| olean_cache_packages | Lake packages to fetch from the olean cache instead of source-building (e.g. [“cslib”]). Needs a configured cache base; artifact path is | List of strings | optional | [] |
lean_dist
load("@rules_lean//lean:lake.bzl", "lean_dist")
lean_dist(name, version)
Extracts the Lean toolchain once; shared by all lake.workspace repos of the same version (deduplicates the multi-GB toolchain across workspaces).
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this repository. | Name | required | |
| version | Lean version tag (e.g. ‘v4.30.0-rc2’); platform is auto-detected. | String | required |
lake
lake = use_extension("@rules_lean//lean:lake.bzl", "lake")
lake.workspace(name, allow_source_build, cache_roots, emit_imports_manifest, lake_manifest,
lakefile, lean_toolchain, olean_cache, olean_cache_packages)
TAG CLASSES
workspace
Attributes
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | - | Name | required | |
| allow_source_build | - | Boolean | optional | False |
| cache_roots | Module specs to tree-shake mathlib’s olean download to (the roots this workspace imports). Resolved to those roots PLUS their transitive closure, so the fetch cannot miss something you import. Empty → fetch ALL of mathlib (~2.0 GB at v4.30.0-rc2). See the repo rule’s attr. | List of strings | optional | [] |
| emit_imports_manifest | Populate @<ws>//:lake_imports_manifest. Off by default: producing it compiles the RulesLean oleanImports CLI (~149 MB of .lake/build/) per workspace, and building Lean code never needs it. The target exists either way — empty when off. See the repo rule’s attr. | Boolean | optional | False |
| lake_manifest | - | Label | required | |
| lakefile | - | Label | required | |
| lean_toolchain | - | Label | required | |
| olean_cache | Base URL/path for prebuilt-olean tarballs (private; overridden by the LEAN_OLEAN_CACHE repo_env). Empty → source-build packages with no cache. | String | optional | "" |
| olean_cache_packages | Lake packages to fetch from the olean cache instead of building. | List of strings | optional | [] |
from docs/lean.md
Bazel rules for Lean 4.
User-facing rules:
lean_toolchain — registers a Lean compiler binary + runtime tree.
Normally produced by lake_workspace (see lake.bzl);
can also be declared by hand against a hermetic
lean tarball.
lean_prebuilt_library — exposes a tree of prebuilt .olean files as a
LeanInfo provider consumable via the deps attr.
The path_marker file’s parent directory becomes
the LEAN_PATH entry.
lean_library — compile a set of .lean sources to a persistent
.olean import-root tree (build outputs) and expose
it as LeanInfo. Lets one module be a compiled
dep of another (no source re-sharing). Transitive:
its LeanInfo carries its deps’ closure too.
lean_olean_archive — bundle a lean_library’s own .olean tree into a
tarball — the deployable cross-repo release artifact.
lean_imported_library — expose an unpacked .olean tarball (e.g. from an
http_archive of a release asset) as LeanInfo,
with NO recompile. The cross-repo consume side.
lean_test — stages a set of .lean sources into a module-path
layout and invokes the compiler on an entry point.
Returns 0 if all type-check, nonzero otherwise.
Accepts deps = [LeanInfo] and prepends each
dep’s import root to LEAN_PATH.
lean_emit — like lean_test, but the entry file defines
main : IO Unit; runs it and captures stdout to
a declared output file. The Lean kernel becomes
the source of truth for emitted artifacts (SQL,
TTL, Markdown). Same deps plumbing as lean_test.
lean_axiom_test — assert that each named theorem’s TRANSITIVE axiom
dependencies are inside an allowlist, and fail the
build otherwise. The check a proof repository
actually wants: #print axioms prints, it does not
gate.
Two things every rule here now does, which none of them did before 0.7.0:
- compiler diagnostics are forwarded even on a SUCCESSFUL compile.
leanexits 0 on asorry(it is a warning) and on#print axioms, and the driver only echoed output when the exit code was non-zero — so both vanished. Asorrywas a green, silent build. forbid_sorry(default True) makes an admitted goal a build failure.
lean_library/lean_olean_archive/lean_imported_library (added 0.4.0) are
the cross-repo compiled-artifact seam: split a monolithic Lean library into
modules, publish each module’s .olean tree as a per-(lean-version, os, arch)
release tarball, and have downstreams consume the prebuilt oleans without
recompiling. .olean is neither Lean-version- nor architecture-portable (it is
a compacted heap image), so a consumer must pin the SAME lean-toolchain and
select() the matching-platform artifact; Lean itself rejects a mismatched
olean loudly at use.
lean_axiom_test
load("@rules_lean//lean:lean.bzl", "lean_axiom_test")
lean_axiom_test(name, deps, srcs, allowed_axioms, forbid_sorry, imports, theorems)
Fail the build unless every named theorem’s transitive axiom dependencies are inside allowed_axioms.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | Compiled Lean libraries holding the theorems (e.g. a lean_library). Name the modules to import via imports. | List of labels | optional | [] |
| srcs | Lean sources to compile before auditing. Compiled in import-topological order, so list order is irrelevant (a glob() is fine). May be empty when the theorems come from deps. | List of labels | optional | [] |
| allowed_axioms | The allowlist. A theorem depending on anything outside it fails the build, naming the theorem and the offending axioms. Defaults to Lean’s three standard axioms — propext, Classical.choice, Quot.sound — which is classical logic with quotient types. What that default EXCLUDES is the point: sorryAx (an admitted goal) and Lean.ofReduceBool (a native_decide, which trusts the compiler rather than the kernel).Tighten it when a theorem is meant to be constructive: a proof that needs only [propext, Quot.sound] today and silently acquires Classical.choice tomorrow is a real change, and this is what reports it. An empty list allows NOTHING. | List of strings | optional | ["propext", "Classical.choice", "Quot.sound"] |
| forbid_sorry | If True (the default), an admitted goal — sorry, or any tactic that elaborates to sorryAx — fails the build.Lean reports sorry as a WARNING and exits 0, so before 0.7.0 an admitted theorem type-checked, the driver discarded the warning along with the rest of the compiler’s output, and the target went green. A proof that is not a proof is the one thing a Lean ruleset must not report as passing.Set False for a development tree that is deliberately mid-proof. It does not suppress the warning — that is now always printed — only the failure. | Boolean | optional | True |
| imports | Lean modules the generated audit imports (e.g. ["Soma"]). Defaults to every module in srcs, which is what a single-library audit wants; required when the theorems come only from deps. | List of strings | optional | [] |
| theorems | Fully-qualified theorem names to audit (e.g. ["Soma.network_confluence"]). Each is resolved as a real constant, so a typo fails rather than passing vacuously. An empty list is an error for the same reason. | List of strings | required |
lean_binary
load("@rules_lean//lean:lean.bzl", "lean_binary")
lean_binary(name, deps, srcs, entry, forbid_sorry)
A runnable Lean executable: compiles srcs to an olean root and lean --runs the entry with runtime argv.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | - | List of labels | optional | [] |
| srcs | - | List of labels | required | |
| entry | Module-path of the src whose main is the entry point. | String | required | |
| forbid_sorry | If True (the default), an admitted goal — sorry, or any tactic that elaborates to sorryAx — fails the build.Lean reports sorry as a WARNING and exits 0, so before 0.7.0 an admitted theorem type-checked, the driver discarded the warning along with the rest of the compiler’s output, and the target went green. A proof that is not a proof is the one thing a Lean ruleset must not report as passing.Set False for a development tree that is deliberately mid-proof. It does not suppress the warning — that is now always printed — only the failure. | Boolean | optional | True |
lean_emit
load("@rules_lean//lean:lean.bzl", "lean_emit")
lean_emit(name, deps, srcs, data, out, entry, forbid_sorry)
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | - | List of labels | optional | [] |
| srcs | - | List of labels | required | |
| data | Non-Lean files staged alongside srcs in the action’s work directory (NOT compiled). The Lean entry runs from that directory, so it can IO.FS.readFile them by their package-relative path. Typical use: fixture .dat / .txt / .json inputs the entry processes. | List of labels | optional | [] |
| out | The emitted artifact (one file). Filename should reflect the artifact kind. | Label | required | |
| entry | Path of the entry-point .lean file (relative to the package) defining main : IO Unit. Stdout is captured to out. | String | required | |
| forbid_sorry | If True (the default), an admitted goal — sorry, or any tactic that elaborates to sorryAx — fails the build.Lean reports sorry as a WARNING and exits 0, so before 0.7.0 an admitted theorem type-checked, the driver discarded the warning along with the rest of the compiler’s output, and the target went green. A proof that is not a proof is the one thing a Lean ruleset must not report as passing.Set False for a development tree that is deliberately mid-proof. It does not suppress the warning — that is now always printed — only the failure. | Boolean | optional | True |
lean_imported_library
load("@rules_lean//lean:lean.bzl", "lean_imported_library")
lean_imported_library(name, srcs, path_marker)
Expose an unpacked .olean release tarball as LeanInfo (no recompile).
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| srcs | All files of the unpacked .olean tree (typically @<archive_repo>//:all or a glob). | List of labels | required | |
| path_marker | Anchor file inside the unpacked import root (the archive’s .lean_root). Its parent dir becomes the LEAN_PATH entry. | Label | required |
lean_library
load("@rules_lean//lean:lean.bzl", "lean_library")
lean_library(name, deps, srcs, forbid_sorry)
Compile .lean sources to a persistent .olean import-root tree and expose it as LeanInfo.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | Compiled Lean libraries this one imports. Same-top-namespace deps are staged into the compile root; disjoint ones are on LEAN_PATH. All propagate transitively in this library’s LeanInfo. | List of labels | optional | [] |
| srcs | All .lean files in this library. Module path is derived from the file’s path relative to its own package. Compiled in import-topological order, so list order is irrelevant (a glob() is fine). | List of labels | required | |
| forbid_sorry | If True (the default), an admitted goal — sorry, or any tactic that elaborates to sorryAx — fails the build.Lean reports sorry as a WARNING and exits 0, so before 0.7.0 an admitted theorem type-checked, the driver discarded the warning along with the rest of the compiler’s output, and the target went green. A proof that is not a proof is the one thing a Lean ruleset must not report as passing.Set False for a development tree that is deliberately mid-proof. It does not suppress the warning — that is now always printed — only the failure. | Boolean | optional | True |
lean_main_test
load("@rules_lean//lean:lean.bzl", "lean_main_test")
lean_main_test(name, deps, srcs, data, entry, forbid_sorry)
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | - | List of labels | optional | [] |
| srcs | All .lean files needed to compile the entry. Compiled in import-topological order, so list order is irrelevant (a glob() is fine). | List of labels | required | |
| data | Non-Lean files staged at their workspace-relative path in the action’s work directory. The Lean entry runs from that directory, so it can IO.FS.readFile them. | List of labels | optional | [] |
| entry | Path of the entry-point .lean file (relative to the package) defining main : IO UInt32 (test result = exit code). | String | required | |
| forbid_sorry | If True (the default), an admitted goal — sorry, or any tactic that elaborates to sorryAx — fails the build.Lean reports sorry as a WARNING and exits 0, so before 0.7.0 an admitted theorem type-checked, the driver discarded the warning along with the rest of the compiler’s output, and the target went green. A proof that is not a proof is the one thing a Lean ruleset must not report as passing.Set False for a development tree that is deliberately mid-proof. It does not suppress the warning — that is now always printed — only the failure. | Boolean | optional | True |
lean_olean_archive
load("@rules_lean//lean:lean.bzl", "lean_olean_archive")
lean_olean_archive(name, out, library)
Bundle a lean_library’s .olean import-root tree into a deployable tarball.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| out | Output tarball name (default <name>.tar.gz). | String | optional | "" |
| library | The lean_library whose own .olean tree is archived. | Label | required |
lean_prebuilt_library
load("@rules_lean//lean:lean.bzl", "lean_prebuilt_library")
lean_prebuilt_library(name, srcs, path_marker)
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| srcs | All files in the prebuilt-olean tree (typically glob(["lib/**"])). | List of labels | required | |
| path_marker | Anchor file inside the import-root directory. The marker’s parent is the LEAN_PATH entry. | Label | required |
lean_test
load("@rules_lean//lean:lean.bzl", "lean_test")
lean_test(name, deps, srcs, entry, forbid_sorry)
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | Prebuilt Lean libraries. Same-top-namespace deps are staged into the compile root; disjoint ones are on LEAN_PATH. | List of labels | optional | [] |
| srcs | All .lean files in the proof tree. Module path is derived from the file’s path relative to this BUILD.bazel’s package. Compiled in import-topological order, so list order is irrelevant — glob(["**/*.lean"]) is fine. | List of labels | required | |
| entry | Path of the entry-point .lean file relative to the package. | String | required | |
| forbid_sorry | If True (the default), an admitted goal — sorry, or any tactic that elaborates to sorryAx — fails the build.Lean reports sorry as a WARNING and exits 0, so before 0.7.0 an admitted theorem type-checked, the driver discarded the warning along with the rest of the compiler’s output, and the target went green. A proof that is not a proof is the one thing a Lean ruleset must not report as passing.Set False for a development tree that is deliberately mid-proof. It does not suppress the warning — that is now always printed — only the failure. | Boolean | optional | True |
lean_toolchain
load("@rules_lean//lean:lean.bzl", "lean_toolchain")
lean_toolchain(name, lean, runtime)
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| lean | - | Label | required | |
| runtime | - | Label | required |
LeanInfo
load("@rules_lean//lean:lean.bzl", "LeanInfo")
LeanInfo(markers, files)
A Lean library: a directory of importable .olean files, exposed via a marker file whose parent directory is the LEAN_PATH entry.
FIELDS
| Name | Description |
|---|---|
| markers | depset[File]: each marker’s parent directory IS a LEAN_PATH entry. |
| files | depset[File]: all .olean files (and the marker) needed when this lib is consumed. |
LeanToolchainInfo
load("@rules_lean//lean:lean.bzl", "LeanToolchainInfo")
LeanToolchainInfo(lean, runtime)
Lean 4 compiler binary + runtime tree.
FIELDS
| Name | Description |
|---|---|
| lean | File: the lean compiler binary (executable). |
| runtime | depset[File]: stdlib oleans, shared libs, etc. |
lean_regen_test
load("@rules_lean//lean:lean.bzl", "lean_regen_test")
lean_regen_test(name, srcs, entry, expected, out, deps, data, tags)
Assert a committed file matches the current lean_emit output.
PARAMETERS
Conformance#
No gate findings. 8 contested atoms. See how gating works or the full report.
Contested atoms
Third-party modules where this module resolves a different version than others do. Not a violation of anything this module did — it is the actionable form of a registry-level convergence finding, and the sentence a maintainer can act on.
| Atom | Resolved here | Elsewhere |
|---|---|---|
apple_support | 1.24.2 | 2.2.0 ×1 |
bazel_skylib | 1.8.2 | 1.9.0 ×2 |
nlohmann_json | 3.6.1 | 3.12.0.bcr.1 ×1 |
protobuf | 33.4 | 34.0.bcr.1 ×2 |
rules_jvm_external | 6.7 | 6.8 ×4 |
rules_python | 1.7.0 | 2.0.1 ×1 |
rules_swift | 3.1.2 | 3.6.1 ×1 |
upb | 0.0.0-20220923-a547704 | 0.0.0-20230516-61a97ef ×1 |
Dependencies#
Depends on
Used by (10 in the registry)
Versions#
24 published versions, newest first. Each resolves to an immutable, integrity-checked archive.
| Version | Integrity (sha256) | Source archive |
|---|---|---|
0.6.2 latest | OESUpfl+zObTbpIP… | tag archive ↗ |
0.6.1 | 9EHvuaVRCqAng60h… | tag archive ↗ |
0.6.0 | wLHXJPEmsBIy4GbO… | tag archive ↗ |
0.5.5 | mYAi/q2LeZS3qMZ4… | tag archive ↗ |
0.5.4 | zysuiLaHuEh/B+2O… | tag archive ↗ |
0.5.3 | U97l5mnM+nm0X77j… | tag archive ↗ |
0.5.2 | mYkyTxH7zEWTee6u… | tag archive ↗ |
0.5.1 | L6TqRJlTF6WVXJn6… | tag archive ↗ |
0.5.0 | uMBcfmuycyE873Q0… | tag archive ↗ |
0.3.9 | qKA+T7riKQFNaLpQ… | tag archive ↗ |
0.3.8 | BqQpA98mVRUOogP6… | tag archive ↗ |
0.3.7 | QkkKRq7bynhEUSYz… | tag archive ↗ |
0.3.6 | LXl5Cp/PbYSbjKby… | tag archive ↗ |
0.3.5 | lyEMMFe7YdN+nF8z… | tag archive ↗ |
0.3.4 | vYH80yE9giekbYXh… | tag archive ↗ |
0.3.3 | h6VVmRrtwKwzeTa6… | tag archive ↗ |
0.3.2 | T/87d8lIhjDL3JXN… | tag archive ↗ |
0.3.1 | bgVOPHOz2LbsrzoF… | tag archive ↗ |
0.3.0 | rLb+F+rDrpP39JM2… | tag archive ↗ |
0.3.0-rc1 | DT3FpRRcLZH8z+kt… | tag archive ↗ |
0.2.2 | X3f1MU150YqabKmG… | tag archive ↗ |
0.2.1 | mCvQk2T4RtTyYnQ3… | tag archive ↗ |
0.2.0 | UoSFQMzYzBpfCnwk… | tag archive ↗ |
0.1.0 | DF2Ry/QrmSeisrdy… | tag archive ↗ |
Changelog#
All notable changes to rules_lean. The format is loosely Keep a Changelog — version headers mirror the published bazel-registry entries.
0.7.0 — a sorry was a GREEN, SILENT build; and axioms are now a gate
Two failures in the same place, found by trying to put citizen-sh/soma’s six
confluence theorems behind a Bazel target. The proofs themselves compiled
first try — lean_test over Lean 4 core with no Mathlib worked exactly as
documented, on darwin/arm64 and linux. What did not work is everything that
makes a green check mean something.
A sorry passed. lean reports an admitted goal as a WARNING and exits 0.
topo_compile echoed the compiler’s output only when the exit code was
non-zero — so the warning was discarded along with the exit code that said
nothing was wrong, and the target went green. Measured, not inferred: a
theorem … := by sorry appended to a real proof tree, rebuilt, PASSED,
zero output. The control (a genuine type error in the same file) went red, so
the harness worked; it just could not see this.
Two things change. The driver now forwards compiler diagnostics on SUCCESS as
well as failure — which also un-hides #print axioms, whose output was going
into the same void, so an Audit.lean full of them looked like it had run and
found nothing. And forbid_sorry (default True) makes an admitted goal
a build failure, on every rule that compiles Lean.
Behavior change. A tree containing a
sorrythat built green before now fails. That is the point — but it is a real break, so:forbid_sorry = Falseon the target restores the old behavior, and does not re-hide the warning.
The gate matches the compiler’s message text, which is exactly as brittle as
it sounds, so //examples/axiom_audit:sorry_is_rejected is a NEGATIVE test in
CI. If upstream rewords the warning, that test goes green and the job fails on
it rather than the check quietly becoming a no-op. (Byte-identical in 4.29.1
and 4.32.2, both checked in-tree.)
#print axioms prints; it does not gate. A proof that silently acquires
an axiom — a sorry in a lemma three imports down, a native_decide that
swaps the kernel for the compiler — keeps printing a line nobody reads. The
new lean_axiom_test fails the build instead:
lean_axiom_test(
name = "axioms_test",
srcs = glob(["**/*.lean"]),
theorems = ["Soma.network_confluence", "Soma.run_perm_invariant"],
# allowed_axioms defaults to [propext, Classical.choice, Quot.sound]
)
It generates a Lean module and checks at ELABORATION time with
Lean.collectAxioms — the same API behind #print axioms, so it agrees with
the hand audit by construction rather than by re-implementing it, and it is
transitive. RulesLean.Internal.AxiomDeps.declaredAxioms is NOT that: it is a
header-only scan of axioms a module declares directly, and its own docstring
says the transitive version “lands once there’s a concrete consumer pushing on
the shape”. soma was the consumer.
The default allowlist is Lean’s three standard axioms. What it excludes is the
point: sorryAx and Lean.ofReduceBool. Tightening it is the interesting
direction — a theorem that needs only [propext, Quot.sound] today and picks
up Classical.choice tomorrow is a real change, and this reports it.
Successful audits are silent; failed audits name the theorem, its axiom
dependencies, the allowlist, and the disallowed axioms.
Verified by mutation on soma’s actual proofs, not by assumption. soma’s
Audit.lean names eight theorems; with the three-axiom allowlist all eight
pass. Drop Classical.choice and five fail by name while
runBarriered_perm_invariant, network_confluence_barriered and
drive_reaches still pass, because those genuinely need only two — which is
exactly what soma’s README claims for them. A gate that cannot tell those
apart is not a gate.
//examples/axiom_audit carries four positive targets and three negative ones
(*_is_rejected, manual-tagged, run by CI with the exit code asserted
non-zero), plus a control — the same sorry file with forbid_sorry = False,
which must PASS, so a red negative is attributable to the gate and not to a
broken fixture.
Lean v4.32.2 is pinned, all four platforms. It was not in
known_lean_versions.bzl, so any workspace on a modern toolchain downloaded
the compiler UNVERIFIED behind a print() warning — including soma, whose
lean-toolchain says v4.32.2. Hashes are of the release ASSETS (immutable),
not /archive/refs/tags/ tarballs.
0.6.2 — two deps can share a top-level namespace
Lean resolves a module name in the first LEAN_PATH root that owns its
top-level directory and does not fall through to later roots. _dep_manifest_lines
handled one consequence of that — a dep colliding with the consumer’s own
namespace gets staged into the compile root — but not the other: two deps
colliding with each other.
Two published modules both rooted at Pg/ therefore could not both be consumed,
unless the consumer happened to have Pg/ sources of its own. The failure is
silent and misdirected — the compile dies naming a path inside the wrong
repository:
App/Main.lean:4:0: error: object file
'.../pg_catalog_lib/Pg/Query/Top.olean' of module Pg.Query.Top does not exist
Pg/Query/Top.olean is in pg_query’s root; Lean looked only in pg_catalog’s,
because that root owns Pg/ and it stopped there.
Now a top-level namespace owned by more than one dep root is staged, exactly as
one colliding with the consumer already was. Namespaces owned by a single dep and
untouched by the consumer still go on leanpath with no copy, so the common case
is unchanged. The decision is per-file, so a dep whose namespaces are partly
contested still contributes its uncontested ones via leanpath.
Why it mattered. This blocked the whole point of publishing compiled oleans
for any consumer whose sources are rooted elsewhere. Concretely: leangres ships
pgcatalog and pgquery, both rooted at Pg/, and their real consumer is rooted
at Aion/ — so it could not adopt the compiled artifacts at all and had to keep
recompiling from source.
//examples/shared_namespace is the regression test, and it is in CI. Verified by
deliberate break: reverted to the old _dep_manifest_lines and confirmed the test
fails with the error above, then restored.
0.6.1 — lean_olean_archive builds on linux, and is byte-reproducible
lean_olean_archive tarred the import root directly with tar -czhf. The
import root is a symlink farm into bazel-out, so -h reads THROUGH the links
while bazel may still be materialising them, and GNU tar treats that as fatal:
tar: ./Aion/Db/EntityType/EntityTypeFieldPredicates.olean:
file changed as we read it
GNU tar exits 1 on that warning; BSD tar does not. The rule therefore worked on macOS and failed on every linux/RBE build — invisible to local verification, and it took an RBE worker log to see it (observed on aion/sql, green on the same commit on darwin). Downstream this was not a cosmetic failure: the olean publish job was removed from aion’s CI because of it, which is what has been blocking the cross-repo compiled-olean seam this rule exists to provide.
The rule now stages the import root into a private scratch dir with cp -RL,
then tars that without -h. tar reads a tree nothing else is writing, and cp
does not fail on concurrent mtime churn the way tar does, so the race is
removed rather than narrowed.
While here: the tarball ships as a release asset that consumers pin by sha256,
so it is now reproducible. Three things had to be pinned, not one — entry
order (an LC_ALL=C-sorted list via -T -, not readdir order), per-entry
mtimes (touch to a fixed date, because cp stamps the staged copies with the
current time), and the gzip header timestamp (gzip -n). gzip -n on its own
is not enough and the archive stayed non-reproducible with only it; that was
caught by building the same tree twice a second apart and diffing.
uid/gid/uname/gname are still taken from the builder, so this is reproducible for a given builder — a CI runner rebuilding the same commit gets identical bytes — not across accounts.
And CI now actually runs the rule. //examples/olean_roundtrip has covered
lean_olean_archive since 0.4.0, but nothing ever executed it: the fast gate is
//docs/... only, and the two Lean jobs go through elan/lake rather than the
Bazel rule. The rule was broken on linux for a week behind six green checks, none
of which touched it. There is now an olean_archive PR gate on ubuntu —
ubuntu-only on purpose, since the failure mode is GNU tar exiting 1 where BSD tar
warns, so macOS cannot fail and re-proving it there buys nothing.
The round-trip assertion was also weaker than it looked: it grepped the tar
listing for the olean’s path. Drop the -L from cp -RL and the archive still
contains an entry at that path — a dangling symlink into bazel-out, useless to
consumers — and the grep passed. It now asserts the entry is a regular file with
non-zero bytes, verified by building both the correct and the non-dereferenced
archive and confirming the old check accepted the broken one.
No API change; out and the produced tarball layout are unchanged.
0.6.0 — the imports manifest is opt-in; lake_workspace stops building a CLI it never runs
Every lake_workspace materialization compiled the RulesLean oleanImports CLI
with the consumer’s Lean toolchain, unconditionally. Two things were wrong with
that, and both are now fixed.
The dep-free path built the CLI and provably never invoked it. When the
lakefile declares no requires, _lake_workspace_impl short-circuits — and the
manifest generator it calls with an empty package list writes "" and returns
before it would reach the binary. So the compile was pure cost. Observed on one
darwin output base, three times over: ruleslean_lib/ at 149 MB next to a
lake_imports_manifest.tsv of 0 bytes, in the rules_lang, rules_postgres
and rules_spec lake workspaces.
And even on the dep-ful path, nothing reads the manifest. It is an
introspection aid — “what does olean X import?” — not an input to compiling Lean.
So it is now behind emit_imports_manifest, default False:
lake.workspace(
name = "lake_deps",
emit_imports_manifest = True, # only if you actually consume the TSV
...
)
@<ws>//:lake_imports_manifest and the .tsv are still declared either way —
empty when off — so no consumer BUILD file has to branch on the attr. Turning it
back on restores the previous contents exactly.
_build_ruleslean_library’s timeout goes 600s → 3600s, and its docstring stops
understating the cost. It claimed “~3-5s cold”. Measured here on darwin/arm64,
Lean v4.30.0-rc2, fresh output base, uncontended: ~9s wall — but a 149 MB
.lake/build/, per lake_workspace. The clock was roughly right; the footprint
is the cost, and it is duplicated across every workspace in an output base. A
separate investigation measured 130.9s / 237.6s / 497.5s for the same step on
darwin, which was not reproduced here — so the wall time is
environment-dependent somewhere between ~9s and several minutes, and nobody has
measured it on linux. At ~9s the 600s cap is nowhere near binding; the raise is
insurance for the slow end (a repository-rule timeout is a hard analysis failure,
not a slow build), on a step that now rarely runs at all.
Measured end to end, on a throwaway consumer that bazel_deps on a module with a
dep-free lake workspace and builds one cc_library (darwin/arm64):
before 2.6 GB of external/ — 2.5 GB Lean toolchain + 149 MB ruleslean_lib/
after 2.3 MB of external/ — no Lean repos materialized at all
with, in the before case, a lake_imports_manifest.tsv of 0 bytes sitting
next to the 149 MB it cost to produce.
0.5.5 — resolve deps from the pinned manifest, never lake update
Backfilled entry; 0.5.5 shipped to the registry without one.
_lake_workspace_impl materialized deps with lake update, which regenerates the
lake-manifest.json that was just pinned and fires every dep’s post_update
hook. mathlib’s hook runs a hardcoded, unfiltered lake exe cache get, pulling
all ~7900 oleans before 0.5.4’s tree-shaken cache_roots fetch got a say — which
left cache_roots shipped-but-inert in 0.5.4. Now a non-mutating lake env true
resolves from the committed manifest, no hook fires, and cache_roots actually
takes effect. Verify by BYTES on disk, not by the “Already decompressed N” log,
which reports the tree-shaken count either way.
0.5.4 — tree-shake mathlib’s olean download (cache_roots)
lake.workspace(cache_roots = [...]) restricts mathlib’s lake exe cache get
to the given root modules plus their transitive closure, instead of fetching all
of mathlib. mathlib’s cache CLI already supports this (get [ARGS] →
filterByRootModules); rules_lean simply never passed the args. The fetch stays
sound — you cannot under-fetch a module you import.
Measured against mathlib @ v4.30.0-rc2 with a Lean→SQL emitter’s 6 roots
(Data.Fintype.Basic, Data.Fin.Basic, Data.List.Basic, Data.List.Infix,
Order.Basic, Order.BoundedOrder.Basic):
full fetch 8297 files (~2 GB) 6-root closure 595 files (52 MB) — 93% fewer files
Empty cache_roots (the default) keeps the fetch-everything behaviour, so
existing workspaces are unaffected.
0.5.3 — configurable prebuilt-olean cache
lake.workspacecan now fetch a package’s prebuilt oleans from a consumer-configurable cache instead of source-building it (e.g. cslib, which Reservoir doesn’t serve — a ~2.6k-job compile on every cold output base). Declareolean_cache_packages = ["cslib"]; the cache base is set via theolean_cachetag attr (MODULE) or theLEAN_OLEAN_CACHErepo_env (--repo_env=...in .bazelrc — the repo_env wins). Never hardcoded/public. Artifact path:<base>/<pkg>-<rev12>-<leanver>-<platform>.tar.gz(the package’s.lake/buildtree). No base configured → source-build fallback (allow_source_build).- Each resolved package now also exposes a
:<pkg>_build_treefilegroup, so producing the cache tarball is a hermeticpkg_tarover it (no manual hosttar/ AppleDouble cruft). - Validated: with the cache set, cslib is fetched + unpacked (0 source-build jobs), green.
0.5.2 — shared Lean toolchain (dedup)
- The
lakemodule extension now extracts the Lean toolchain once per version into a sharedlean_distrepo; everylake.workspacesymlinks it instead of extracting its own ~2.5G copy. Previously N workspaces (e.g. a project plus the lake workspaces ofrules_lang/rules_postgres/rules_spec) each carried a full toolchain — 4× the same toolchain ≈ 10G per output base, the dominant cause of multi-GB Lean checkouts / CI ENOSPC. Now 1× per version. - Backward-compatible:
@<ws>//:lean_toolchain_defis still a realtoolchain()(soregister_toolchains(...)is unchanged) — it now points at the sharedlean_disttoolchain;@<ws>//:lean_toolchainis an alias to it. The per-packagelean_prebuilt_librarytargets and the imports manifest are unchanged.
0.4.0 — compiled libraries + cross-repo olean artifacts
- New
lean_library: compiles.leansources to a persistent.oleanimport-root tree (build outputs) and exposes it asLeanInfo, so one module can be a compiled dependency of another (no source re-share, no recompile).DefaultInfocarries the library’s own tree;LeanInfocarries the transitive closure (own + deps). - New
lean_olean_archive: bundles alean_library’s own.oleantree into a tarball — the deployable cross-repo release artifact. - New
lean_imported_library: exposes an unpacked.oleantarball (e.g. from anhttp_archiveof a release asset) asLeanInfowith no recompile — the consume side. Shares thelean_prebuilt_libraryimplementation. - These three form the cross-repo compiled-olean seam (split a monolithic Lean
library into modules that publish/consume prebuilt oleans).
.oleanis neither Lean-version- nor architecture-portable, so artifacts are built per-(lean-version, os, arch)and consumers pin the matching toolchain; Lean rejects a mismatched olean loudly at use. - Round-trip example under
examples/olean_roundtrip/. - Cross-namespace deps. A
lean_librarydep that shares the consumer’s top-level namespace (e.g. two libs both underAion/) is staged into the single compile root, since Lean commits to the firstLEAN_PATHroot owning a namespace and won’t fall through to siblings. Disjoint deps (Mathlib, …) stay onLEAN_PATH, uncopied. This makeslean_library→lean_librarydeps within one namespace work (the basis for splitting a monolith in place). - Shell-free compile. All four rules (
lean_library,lean_test,lean_emit,lean_main_test) now drive the compiler through a self-contained Lean topo-compile driver (lean/private/topo_compile.lean, invokedlean --run …viactx.actions.run) instead of arun_shelltsortpipeline — staging/copying uses nativeIO.FS; the only subprocess islean.lean_test/lean_main_testnow type-check / run at build time (a failure fails the build); their test executable is a trivial pass.
0.3.9 — import-topological compile order (glob()-safe srcs)
lean_test,lean_emit, andlean_main_testnow compile theirsrcsin import-topological order instead of literal list order. Previously, Lean’s requirement that a module’s imports be compiled to.oleanfirst meantsrcshad to be hand-ordered with dependencies before dependents — and a naturalglob(["**/*.lean"])would fail, because a root file likeTrading.leansorts beforeTrading/Fx/Basic.lean(.</) yet imports it. Now the generated runner derives the order at execution time: it parses each staged file’simportlines, keeps edges to modules that are themselves insrcs, andtsorts the graph.srcs = glob([...])now Just Works; explicit ordered lists keep working unchanged (any valid manual order is already a valid topological order).- Implementation: a portable bash helper (
__lean_topo_compile, shared via_topo_compile_block) using onlygrep/sed/cut/tsort/mktemp— no bash-4 associative arrays, so it runs on macOS’s stock bash 3.2. Out-of-srcsimports (Mathlib, dep packages) are ignored; genuine import cycles still fail the build (Lean rejects them downstream).
0.3.5 — lean_main_test rule
- New
lean_main_test(name, srcs, entry, deps, data)rule inlean/lean.bzl. Compiles + runs a Lean entry whosemain : IO UInt32returns the test result via its exit code (0 = pass, non-zero = fail). No expected-output diff required — use when the Lean script self-validates (round-trip stability, structural equivalence) and you’d otherwise need a committedexpected.txtfixture just to flag drift. Accepts the samedeps(LeanInfo) +data(workspace-relative staging) attrs aslean_emit/lean_regen_test. - New smoke
examples/regen_smoke/regen_smoke_exitrunsExitZero.lean(pure 0) to exercise the happy path. A companionExitOne.lean(pure 1) is committed for manual negative testing.
0.3.4 — lean_emit.data accepts external-repo files
lean_emit.datanow stages files at their workspace-relative path (e.g.examples/regen_smoke/fixture.txt) instead of the package- relative path the 0.3.3 release used. Externally-sourced data (@some_repo//path:file) is staged atpath/file— the..//<canon>prefix in bazel’s external-repo short_path is stripped. This lets consumers pull fixtures directly from upstream repos (e.g.@postgres_src//:src/include/catalog/pg_namespace.dat) instead of vendoring them.- Smoke updated:
examples/regen_smoke/EchoFixture.leanreads the full workspace-relative path.
0.3.3 — lean_emit.data attr
lean_emit(andlean_regen_test) gain adataattr — non-Lean fixture files staged alongsidesrcsin the action’s work directory without being compiled. The entry runs from that work dir, so it canIO.FS.readFilethem by their package-relative path. Typical use:.dat/.txt/.jsoninputs the entry parses. Enabled rules_postgres’ Lean-nativePg.Catalog.Datround-trip gate against the vendoredpg_namespace.datsample.- New smoke
examples/regen_smoke/regen_smoke_dataexercises the attr end-to-end: a Lean main readsfixture.txtand echoes it; the diff_test verifies the captured stdout matches the samefixture.txt(proving the data file is reachable from the Lean entry’s relative-pathreadFile).
0.3.2 — lean_regen_test macro
- New
lean_regen_test(name, srcs, entry, expected, ...)macro inlean/lean.bzl. Wrapslean_emit+ skylibdiff_testto assert a committed artifact matches the current Lean-emit output for a given Lean main. Captures the “Lean spec is source-of-truth; emitted X was generated from it” pattern that rules_postgres’ Pg.Ir cluster Gate 1 was building on top oflean_emit+diff_testby hand. - Smoke test under
examples/regen_smoke/exercises the macro end-to-end against a tinyHello.leanand a committedexpected.txt.
0.3.1 — External-repo Lean sources
_module_pathand_lean_test_implnow handle external-repo source layouts (../<repo>+/<package>/<file>short_paths). Letslean_libraryandlean_testtargets in a consumer module reference Lean sources from abazel_deprepo without copying the files into the consumer’s tree. Used by rules_postgres’lean/Pg/Ir/Emit/modules when consumed through the registry rather than through alocal_path_override.
0.3.0 — RulesLean Lean library + lake_imports_manifest
- Promote
v0.3.0-rc1and pin to Leanv4.30.0-rc2for cslib compatibility. - Add
RulesLean.Internal.Closure(transitive olean closure computed from the Lake manifest) andRulesLean.Internal.AxiomDeps(declaredAxioms+isAxiom, Internal v0.1). - CI: add a
ruleslean_librarymatrix job so the in-tree Lean library is built + tested on every PR. - Untrack
.vscode/and notebook scratch artifacts; tighten.gitignore.
0.3.0-rc1 — RulesLean scaffold + manifest tooling
- Introduce the
RulesLeanLean library underlean/lib/(Olean + Lake) and wire it through Bazel. - Add the
lake_imports_manifesttarget: workspace API,exportedConstants+containsConstant, and theInternalnamespace convention withnamespacePackageIndex. - Add
tools/reservoir_manifest.py— a stdlib-only Reservoir index fetcher. - Update the install snippet to point at
fastverk/bazel-registry.
0.2.2 — Un-dev bazel_skylib
- Promote
bazel_skylibout ofdev_dependencyso downstream consumers can actuallyload()lean/BUILD.bazelwithout re-declaring it.
0.2.1 — README, license, CI, smoke test
- Bump module version to 0.2.1.
- Add
README.md, MITLICENSE, and the PR-gate CI workflow. - Add a Batteries-only
lake_workspacesmoke test. - Apply buildifier formatting fixes across the tree.
0.2.0 — Generalized Lake integration + stardoc
- Generalize the Lake integration so
lake_workspaceworks for arbitrary Lake projects instead of being hard-coded to a single layout. - Add stardoc generation for the public rules.
0.1.0 — Initial release
- First public cut of
rules_lean:lean_test,lean_emit,lean_prebuilt_library,lean_toolchain, and the initiallake_workspacerepository rule +lakemodule extension reusing Mathlib’s Reservoir cache.