leanback 0.1.4__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {leanback-0.1.4 → leanback-0.1.6}/PKG-INFO +4 -4
  2. {leanback-0.1.4 → leanback-0.1.6}/README.md +1 -1
  3. {leanback-0.1.4 → leanback-0.1.6}/pyproject.toml +2 -2
  4. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/check.py +8 -0
  5. leanback-0.1.6/src/leanback/common/env.py +309 -0
  6. leanback-0.1.6/src/leanback/common/stale.py +83 -0
  7. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/warm.py +9 -0
  8. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/mcp_server.py +63 -4
  9. leanback-0.1.4/src/leanback/common/env.py +0 -157
  10. {leanback-0.1.4 → leanback-0.1.6}/.gitignore +0 -0
  11. {leanback-0.1.4 → leanback-0.1.6}/LICENSE +0 -0
  12. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/README.md +0 -0
  13. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/__init__.py +0 -0
  14. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/__init__.py +0 -0
  15. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/errors.py +0 -0
  16. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/lsp_client.py +0 -0
  17. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/path_manager.py +0 -0
  18. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/project_context.py +0 -0
  19. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/util.py +0 -0
  20. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/common/watchdog.py +0 -0
  21. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/goals.py +0 -0
  22. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/mathlib_search.py +0 -0
  23. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/mathlib_search_simple.py +0 -0
  24. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/mcp_test.py +0 -0
  25. {leanback-0.1.4 → leanback-0.1.6}/src/leanback/prove.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: leanback
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: MCP server for Lean 4 theorem proving — check, prove, goals, eval, search
5
5
  Author: Stefan Szeider
6
6
  License: Apache-2.0
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Topic :: Scientific/Engineering :: Mathematics
15
15
  Requires-Python: >=3.11
16
- Requires-Dist: mcp>=1.26.0
16
+ Requires-Dist: mcp<2,>=1.26.0
17
17
  Requires-Dist: rich>=14.0.0
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -140,7 +140,7 @@ search(project="/home/user/lean/myproject", batch="add_assoc,mul_assoc,add_comm"
140
140
 
141
141
  ## All Tools Are Read-Only
142
142
 
143
- LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools. The one exception: `eval` with `build=True` runs `lake build`, which writes build artifacts to the project's `.lake` directory.
143
+ LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools. Two exceptions write build artifacts to the project's `.lake` directory: `eval` with `build=True` runs `lake build`, and `check` with `file=` compiles out-of-date imports of the checked file, as the Lean language server does.
144
144
 
145
145
  ## Response Format
146
146
 
@@ -121,7 +121,7 @@ search(project="/home/user/lean/myproject", batch="add_assoc,mul_assoc,add_comm"
121
121
 
122
122
  ## All Tools Are Read-Only
123
123
 
124
- LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools. The one exception: `eval` with `build=True` runs `lake build`, which writes build artifacts to the project's `.lake` directory.
124
+ LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools. Two exceptions write build artifacts to the project's `.lake` directory: `eval` with `build=True` runs `lake build`, and `check` with `file=` compiles out-of-date imports of the checked file, as the Lean language server does.
125
125
 
126
126
  ## Response Format
127
127
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "leanback"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "MCP server for Lean 4 theorem proving — check, prove, goals, eval, search"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -21,7 +21,7 @@ classifiers = [
21
21
  ]
22
22
  dependencies = [
23
23
  "rich>=14.0.0",
24
- "mcp>=1.26.0",
24
+ "mcp>=1.26.0,<2",
25
25
  ]
26
26
 
27
27
  [dependency-groups]
@@ -23,6 +23,7 @@ def check_file(
23
23
  capture_json: bool = True,
24
24
  console: Console | None = None,
25
25
  preserve_relative: bool = True,
26
+ stats: dict | None = None,
26
27
  ) -> tuple[bool, list[LeanError]]:
27
28
  """
28
29
  Check a single Lean file for errors.
@@ -33,6 +34,8 @@ def check_file(
33
34
  capture_json: Whether to capture output as JSON for better error parsing
34
35
  console: Rich console for output (creates a new one if None)
35
36
  preserve_relative: Whether to preserve relative paths (True by default)
37
+ stats: Optional dict that receives run statistics (see
38
+ ``LeanRunner.run_lean``)
36
39
 
37
40
  Returns:
38
41
  Tuple of (success, errors)
@@ -100,6 +103,7 @@ def check_file(
100
103
  project_path=project_path,
101
104
  capture_json=capture_json,
102
105
  check=False,
106
+ stats=stats,
103
107
  )
104
108
 
105
109
  # Parse the output for errors
@@ -320,6 +324,7 @@ def check_lean_expr(
320
324
  project_path: str | Path | None = None,
321
325
  capture_json: bool = True,
322
326
  console: Console | None = None,
327
+ stats: dict | None = None,
323
328
  ) -> tuple[bool, list[LeanError]]:
324
329
  """
325
330
  Check a Lean expression for errors.
@@ -330,6 +335,8 @@ def check_lean_expr(
330
335
  project_path: Path to the Lean project (if None, tries to find it)
331
336
  capture_json: Whether to capture output as JSON for better error parsing
332
337
  console: Rich console for output (creates a new one if None)
338
+ stats: Optional dict that receives run statistics (see
339
+ ``LeanRunner.run_lean``)
333
340
 
334
341
  Returns:
335
342
  Tuple of (success, errors)
@@ -356,6 +363,7 @@ def check_lean_expr(
356
363
  project_path=project_path,
357
364
  capture_json=capture_json,
358
365
  console=console,
366
+ stats=stats,
359
367
  )
360
368
  finally:
361
369
  # Clean up the temporary file
@@ -0,0 +1,309 @@
1
+ """
2
+ Lean environment handling utilities.
3
+
4
+ This module provides functionality for executing Lean commands in the correct environment.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+
16
+
17
+ # Timeout (seconds) for `lake setup-file --no-build`. It only reads traces and
18
+ # prints JSON; on a built project it takes 1-3 s.
19
+ SETUP_FILE_TIMEOUT = 60.0
20
+
21
+
22
+ def _lib_ext() -> str:
23
+ if sys.platform == "darwin":
24
+ return "dylib"
25
+ if sys.platform == "win32":
26
+ return "dll"
27
+ return "so"
28
+
29
+
30
+ def _find_native_dynlibs(project_path: Path) -> list[Path]:
31
+ """Discover native shared libraries in Lake packages.
32
+
33
+ Scans .lake/packages/*/.lake/build/lib/ for .so (Linux) or .dylib (macOS)
34
+ files that need --load-dynlib for the Lean interpreter. Fallback used when
35
+ ``lake setup-file`` cannot be consulted.
36
+ """
37
+ packages_dir = project_path / ".lake" / "packages"
38
+ if not packages_dir.is_dir():
39
+ return []
40
+ return sorted(packages_dir.glob(f"*/.lake/build/lib/lib*.{_lib_ext()}"))
41
+
42
+
43
+ def _has_native_libs(project_path: Path) -> bool:
44
+ """True if the project or one of its packages has a built shared library.
45
+
46
+ Only such projects (``precompileModules``, FFI packages) need plugins or
47
+ dynlibs, so projects without any skip the ``lake setup-file`` call.
48
+ """
49
+ ext = _lib_ext()
50
+ lake = project_path / ".lake"
51
+ if any((lake / "build" / "lib").glob(f"lib*.{ext}")):
52
+ return True
53
+ return any((lake / "packages").glob(f"*/.lake/build/lib/lib*.{ext}"))
54
+
55
+
56
+ def lake_setup_file(
57
+ project_path: Path, file_path: Path, timeout: float = SETUP_FILE_TIMEOUT
58
+ ) -> tuple[list[str], list[str]] | None:
59
+ """Ask Lake which plugins and dynlibs a file needs.
60
+
61
+ Runs ``lake setup-file --no-build`` (what the language server does for an
62
+ opened file) and returns ``(plugins, dynlibs)`` in Lake's order. Plugins
63
+ are the precompiled library dylibs of the import closure (projects with
64
+ ``precompileModules``); passing a project's own dylib without the dylibs
65
+ of its dependencies crashes Lean, so the order matters. Returns None when
66
+ Lake fails (unbuilt or out-of-date imports, no lakefile) or times out.
67
+
68
+ Handles both JSON schemas: Lake 5 (Lean >= 4.22) prints top-level
69
+ ``plugins``/``dynlibs``; older versions print ``paths.pluginPaths`` and
70
+ ``paths.loadDynlibPaths``.
71
+ """
72
+ try:
73
+ result = subprocess.run(
74
+ ["lake", "setup-file", "--no-build", str(file_path)],
75
+ cwd=project_path,
76
+ capture_output=True,
77
+ text=True,
78
+ check=False,
79
+ timeout=timeout,
80
+ )
81
+ except (subprocess.TimeoutExpired, OSError):
82
+ return None
83
+ if result.returncode != 0:
84
+ return None
85
+ try:
86
+ data = json.loads(result.stdout)
87
+ except json.JSONDecodeError:
88
+ return None
89
+ if not isinstance(data, dict):
90
+ return None
91
+ paths = data.get("paths")
92
+ if isinstance(paths, dict):
93
+ plugins = paths.get("pluginPaths", [])
94
+ dynlibs = paths.get("loadDynlibPaths", [])
95
+ else:
96
+ plugins = data.get("plugins", [])
97
+ dynlibs = data.get("dynlibs", [])
98
+ if not isinstance(plugins, list) or not isinstance(dynlibs, list):
99
+ return None
100
+ return [str(x) for x in plugins], [str(x) for x in dynlibs]
101
+
102
+
103
+ def _max_rss_mb(ru_maxrss: int) -> float:
104
+ """Convert ``ru_maxrss`` to MB (bytes on macOS, KiB elsewhere)."""
105
+ if sys.platform == "darwin":
106
+ return ru_maxrss / (1024 * 1024)
107
+ return ru_maxrss / 1024
108
+
109
+
110
+ def _run_with_rusage(
111
+ command: list[str],
112
+ cwd: Path,
113
+ env: dict[str, str],
114
+ timeout: float | None,
115
+ ) -> tuple[int, str, str, float | None]:
116
+ """Run a command and return (exit_code, stdout, stderr, max_rss_mb).
117
+
118
+ Uses ``os.wait4`` so the peak memory of this child alone is known. Falls
119
+ back to ``subprocess.run`` (no RSS) where ``wait4`` is unavailable.
120
+ """
121
+ if not hasattr(os, "wait4"):
122
+ result = subprocess.run(
123
+ command,
124
+ cwd=cwd,
125
+ check=False,
126
+ capture_output=True,
127
+ text=True,
128
+ env=env,
129
+ timeout=timeout,
130
+ )
131
+ return result.returncode, result.stdout, result.stderr, None
132
+
133
+ with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f:
134
+ proc = subprocess.Popen(command, cwd=cwd, stdout=out_f, stderr=err_f, env=env)
135
+ reaped: dict = {}
136
+
137
+ def waiter() -> None:
138
+ _, status, rusage = os.wait4(proc.pid, 0)
139
+ reaped["status"] = status
140
+ reaped["rusage"] = rusage
141
+
142
+ thread = threading.Thread(target=waiter, daemon=True)
143
+ thread.start()
144
+ thread.join(timeout)
145
+ if thread.is_alive():
146
+ proc.kill()
147
+ thread.join()
148
+ proc.returncode = -9
149
+ raise subprocess.TimeoutExpired(command, timeout or 0)
150
+ # Mark the Popen as reaped so it does not try to wait again.
151
+ proc.returncode = os.waitstatus_to_exitcode(reaped["status"])
152
+ out_f.seek(0)
153
+ err_f.seek(0)
154
+ stdout = out_f.read().decode("utf-8", errors="replace")
155
+ stderr = err_f.read().decode("utf-8", errors="replace")
156
+ return (
157
+ proc.returncode,
158
+ stdout,
159
+ stderr,
160
+ _max_rss_mb(reaped["rusage"].ru_maxrss),
161
+ )
162
+
163
+
164
+ def find_local_modules(project_path: Path, modules: list[str]) -> list[str]:
165
+ """Filter module names to those with source files in the project.
166
+
167
+ A module like ``Foo.Bar`` is local if ``<project>/Foo/Bar.lean`` exists.
168
+ Dependency modules (e.g. Mathlib.*) and core modules are excluded — they
169
+ are already built or come from the cache.
170
+ """
171
+ local = []
172
+ for module in modules:
173
+ source = project_path / (module.replace(".", "/") + ".lean")
174
+ if source.is_file():
175
+ local.append(module)
176
+ return local
177
+
178
+
179
+ def build_modules(
180
+ project_path: Path, modules: list[str], timeout: float = 600
181
+ ) -> tuple[bool, str]:
182
+ """Build the given modules with ``lake build`` in the project directory.
183
+
184
+ Returns:
185
+ Tuple of (success, output). On failure, output contains the
186
+ combined stdout/stderr from lake.
187
+ """
188
+ if not modules:
189
+ return True, ""
190
+ try:
191
+ result = subprocess.run(
192
+ ["lake", "build", *modules],
193
+ cwd=project_path,
194
+ capture_output=True,
195
+ text=True,
196
+ check=False,
197
+ timeout=timeout,
198
+ )
199
+ except subprocess.TimeoutExpired:
200
+ return False, f"lake build timed out after {timeout:.0f} seconds"
201
+ output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip()
202
+ return result.returncode == 0, output
203
+
204
+
205
+ class LeanRunner:
206
+ """
207
+ Utility for running Lean commands in the appropriate environment.
208
+ """
209
+
210
+ @staticmethod
211
+ def run_lean(
212
+ file_path: str | Path,
213
+ project_path: str | Path | None = None,
214
+ capture_json: bool = False,
215
+ check: bool = True,
216
+ env_vars: dict[str, str] | None = None,
217
+ timeout: float | None = None,
218
+ stats: dict | None = None,
219
+ ) -> tuple[int, str, str]:
220
+ """
221
+ Run Lean on a file in the appropriate environment.
222
+
223
+ Args:
224
+ file_path: Path to the Lean file to run
225
+ project_path: Path to a Lean project (for lake env)
226
+ capture_json: Whether to request JSON output
227
+ check: Whether to raise an exception on non-zero exit codes
228
+ env_vars: Additional environment variables
229
+ timeout: Timeout in seconds
230
+ stats: Optional dict that receives run statistics: ``elapsed_s``
231
+ (wall time of the lean process), ``max_rss_mb`` (peak memory,
232
+ None if unknown) and ``plugins`` (library files loaded).
233
+
234
+ Returns:
235
+ Tuple of (exit_code, stdout, stderr)
236
+ """
237
+ # Convert to Path
238
+ if isinstance(file_path, str):
239
+ file_path = Path(file_path)
240
+
241
+ # We need absolute paths for checking existence internally
242
+ abs_file_path = file_path.absolute()
243
+
244
+ # Determine which path and command to use
245
+ if project_path:
246
+ # If a project is specified, use that project's environment
247
+ project_path = (
248
+ Path(project_path) if isinstance(project_path, str) else project_path
249
+ )
250
+
251
+ # Always use lake env lean for projects - this ensures proper dependency management
252
+ command = ["lake", "env", "lean"]
253
+
254
+ # Load precompiled libraries (precompileModules) and FFI dynlibs
255
+ # the way `lake build` does. Lake knows the exact set and order;
256
+ # projects without any built shared library skip the lookup.
257
+ if _has_native_libs(project_path):
258
+ setup = lake_setup_file(project_path, abs_file_path)
259
+ if setup is not None:
260
+ plugins, dynlibs = setup
261
+ else:
262
+ plugins = []
263
+ dynlibs = [str(lib) for lib in _find_native_dynlibs(project_path)]
264
+ for lib in plugins:
265
+ command.append(f"--plugin={lib}")
266
+ for lib in dynlibs:
267
+ command.append(f"--load-dynlib={lib}")
268
+ if stats is not None:
269
+ stats["plugins"] = plugins + dynlibs
270
+
271
+ if capture_json:
272
+ command.append("--json")
273
+ command.append(str(abs_file_path))
274
+ cwd = project_path
275
+
276
+ # Lake automatically handles all dependency paths - no manual LEAN_PATH setup needed
277
+
278
+ else:
279
+ # Default: run directly with lean for files without project context
280
+ command = ["lean"]
281
+ if capture_json:
282
+ command.append("--json")
283
+ command.append(str(abs_file_path))
284
+
285
+ cwd = file_path.parent
286
+
287
+ # Set up environment
288
+ proc_env = os.environ.copy()
289
+ if env_vars:
290
+ proc_env.update(env_vars)
291
+
292
+ start = time.monotonic()
293
+ try:
294
+ exit_code, stdout, stderr, max_rss_mb = _run_with_rusage(
295
+ command, cwd=cwd, env=proc_env, timeout=timeout
296
+ )
297
+ except subprocess.TimeoutExpired:
298
+ if stats is not None:
299
+ stats["elapsed_s"] = round(time.monotonic() - start, 3)
300
+ stats["max_rss_mb"] = None
301
+ return 124, "", "Lean execution timed out"
302
+ if stats is not None:
303
+ stats["elapsed_s"] = round(time.monotonic() - start, 3)
304
+ stats["max_rss_mb"] = (
305
+ round(max_rss_mb, 1) if max_rss_mb is not None else None
306
+ )
307
+ if check and exit_code != 0:
308
+ raise subprocess.CalledProcessError(exit_code, command, stdout, stderr)
309
+ return exit_code, stdout, stderr
@@ -0,0 +1,83 @@
1
+ """
2
+ Detection of out-of-date project modules in a file's import closure.
3
+
4
+ Lean loads the compiled ``.olean`` of every import. When a project module
5
+ was edited after its ``.olean`` was built, a plain ``lean`` run (and a warm
6
+ language-server worker that opened the file earlier) silently uses the old
7
+ compiled version, so a green check can be a false pass. This module finds
8
+ such modules by comparing modification times of source and ``.olean`` for
9
+ project-local imports, transitively. Lake decides by content hashes, so a
10
+ module whose source was merely touched is reported as well; that is a
11
+ harmless over-approximation.
12
+ """
13
+
14
+ import re
15
+ from pathlib import Path
16
+
17
+
18
+ _IMPORT_RE = re.compile(r"^\s*import\s+(\S+)", re.MULTILINE)
19
+
20
+
21
+ def parse_imports(source: str) -> list[str]:
22
+ """Return the module names of all ``import`` lines in a Lean source text."""
23
+ return _IMPORT_RE.findall(source)
24
+
25
+
26
+ def module_source(project_path: Path, module: str) -> Path | None:
27
+ """Path of the source file of a project-local module, or None."""
28
+ source = project_path / (module.replace(".", "/") + ".lean")
29
+ return source if source.is_file() else None
30
+
31
+
32
+ def module_olean(project_path: Path, module: str) -> Path | None:
33
+ """Path of the compiled ``.olean`` of a project-local module, or None."""
34
+ rel = module.replace(".", "/") + ".olean"
35
+ for lib_dir in ("lean", ""):
36
+ candidate = project_path / ".lake" / "build" / "lib" / lib_dir / rel
37
+ if candidate.is_file():
38
+ return candidate
39
+ return None
40
+
41
+
42
+ def find_stale_modules(project_path: Path, modules: list[str]) -> list[str]:
43
+ """Project-local modules in the import closure of ``modules`` whose
44
+ source is newer than their ``.olean``.
45
+
46
+ Only modules whose source lives in the project are followed; dependencies
47
+ and core modules are assumed up to date. Modules without any ``.olean``
48
+ are skipped (Lean reports those as missing anyway).
49
+ """
50
+ stale: list[str] = []
51
+ seen: set[str] = set()
52
+ queue = list(modules)
53
+ while queue:
54
+ module = queue.pop(0)
55
+ if module in seen:
56
+ continue
57
+ seen.add(module)
58
+ src = module_source(project_path, module)
59
+ if src is None:
60
+ continue
61
+ olean = module_olean(project_path, module)
62
+ if olean is not None:
63
+ try:
64
+ if src.stat().st_mtime > olean.stat().st_mtime:
65
+ stale.append(module)
66
+ except OSError:
67
+ pass
68
+ try:
69
+ queue.extend(
70
+ parse_imports(src.read_text(encoding="utf-8", errors="replace"))
71
+ )
72
+ except OSError:
73
+ pass
74
+ return stale
75
+
76
+
77
+ def find_stale_imports(project_path: Path, file_path: Path) -> list[str]:
78
+ """Stale project-local modules in the import closure of a file."""
79
+ try:
80
+ source = file_path.read_text(encoding="utf-8", errors="replace")
81
+ except OSError:
82
+ return []
83
+ return find_stale_modules(project_path, parse_imports(source))
@@ -148,6 +148,15 @@ _registry = _WorkerRegistry()
148
148
  atexit.register(_registry.shutdown_all)
149
149
 
150
150
 
151
+ def discard_project(project_path: Path) -> None:
152
+ """Shut down the project's warm worker so the next check starts fresh.
153
+
154
+ Used when a project module in the import closure is out of date: a new
155
+ ``lake serve`` worker rebuilds stale imports when it opens the file.
156
+ """
157
+ _registry.discard(project_path.resolve())
158
+
159
+
151
160
  def check_file_warm(
152
161
  project_path: Path, file_path: Path, timeout: float = 600.0
153
162
  ) -> list[dict] | None:
@@ -57,8 +57,9 @@ mcp = FastMCP(
57
57
  instructions="""\
58
58
  LeanBack provides tools for Lean 4 theorem proving. All tools are read-only for source files — \
59
59
  they never modify your .lean files. Use your own file tools to create/edit .lean files, then use \
60
- LeanBack to verify them. (Exception: eval with build=true compiles project modules, writing build \
61
- artifacts to .lake/.)
60
+ LeanBack to verify them. Two operations write build artifacts to .lake/: eval with build=true \
61
+ compiles project modules, and check(file=...) compiles out-of-date imports of the checked file \
62
+ (like the Lean language server does).
62
63
 
63
64
  ## Getting Started
64
65
  1. Call project_info(project="/path/to/project") to check if a project is ready
@@ -428,6 +429,34 @@ def project_info(project: str) -> str:
428
429
  )
429
430
 
430
431
 
432
+ def _add_run_stats(result: dict, stats: dict) -> None:
433
+ """Attach peak memory and loaded plugins of a cold Lean run."""
434
+ if stats.get("max_rss_mb") is not None:
435
+ result["max_rss_mb"] = stats["max_rss_mb"]
436
+ if stats.get("plugins"):
437
+ result["plugins_loaded"] = [Path(p).name for p in stats["plugins"]]
438
+
439
+
440
+ def _stale_import_warnings(stale: list[str], file_name: str) -> list:
441
+ """One warning per out-of-date project module used by a cold check."""
442
+ from leanback.common.errors import LeanError
443
+
444
+ return [
445
+ LeanError(
446
+ file_name=file_name,
447
+ line=0,
448
+ column=0,
449
+ severity="warning",
450
+ message=(
451
+ f"Import {module} is out of date: its source is newer than its "
452
+ f"compiled .olean, so this check used the OLD compiled version."
453
+ ),
454
+ suggestion=f"Run `lake build {module}` in the project, then check again.",
455
+ )
456
+ for module in stale
457
+ ]
458
+
459
+
431
460
  @mcp.tool()
432
461
  @_with_elapsed
433
462
  def check(
@@ -450,7 +479,7 @@ def check(
450
479
 
451
480
  Parameters:
452
481
  - project: Absolute path to the Lean project directory (e.g., "/home/user/lean/myproject")
453
- - file: Path to a .lean file within the project (e.g., "Basic.lean", "src/Logic.lean"). Repeated checks of the same file reuse a warm Lean server: the first check pays the import cost (~20-30s with Mathlib), subsequent checks after edits take seconds as long as the file's import lines are unchanged.
482
+ - file: Path to a .lean file within the project (e.g., "Basic.lean", "src/Logic.lean"). Repeated checks of the same file reuse a warm Lean server: the first check pays the import cost (~20-30s with Mathlib), subsequent checks after edits take seconds as long as the file's import lines are unchanged. Like the Lean language server, a file check compiles out-of-date imports of THIS project when it opens the file (writing to .lake/build); the result lists them under 'imports_rebuilt'.
454
483
  - check_all: Check each file individually for detailed error reporting (slower but more thorough)
455
484
  - expr: A Lean expression or command to check (e.g., "2 + 2 = 4", "Nat.add", "#eval 1 + 1"). Bare expressions are auto-wrapped in #check.
456
485
  - imports: List of modules to import when checking expressions (e.g., ["Mathlib.Tactic"]). First Mathlib import takes ~20-30s.
@@ -461,6 +490,7 @@ def check(
461
490
  - severity: "error", "warning", or "info" (e.g., output from #check)
462
491
  - message: The error or output message
463
492
  - suggestion: Helpful hint to fix the issue (when available)
493
+ Other fields: 'elapsed_ms' (whole call); 'engine' (file checks only: "warm" = the persistent Lean server, the normal case, which rebuilds out-of-date imports itself; "cold" = a fresh lean process, used when the warm server fails and for all expression checks); 'max_rss_mb' (peak memory of the Lean process, cold runs only); 'plugins_loaded' (cold runs on projects with precompileModules only: the precompiled libraries passed to Lean, which may include toolchain libraries such as libLake_shared); 'stale_imports' (cold runs only: project modules whose source is newer than their compiled .olean, so the check used the OLD version; run `lake build` and check again); 'imports_rebuilt' (warm file checks only: out-of-date project modules the Lean server recompiled before checking).
464
494
 
465
495
  Examples:
466
496
  - Verify project compiles: check(project="/home/user/lean/myproject")
@@ -524,12 +554,18 @@ def check(
524
554
  ):
525
555
  expr = f"#check ({expr_stripped})"
526
556
 
557
+ from leanback.common.stale import find_stale_modules
558
+
559
+ stats: dict = {}
527
560
  success, errors = check_lean_expr(
528
561
  expr=expr,
529
562
  imports=imports or [],
530
563
  project_path=project_path,
531
564
  console=console,
565
+ stats=stats,
532
566
  )
567
+ stale = find_stale_modules(project_path, imports or [])
568
+ errors.extend(_stale_import_warnings(stale, "<inline>"))
533
569
 
534
570
  error_count = sum(1 for e in errors if e.severity == "error")
535
571
  warning_count = sum(1 for e in errors if e.severity == "warning")
@@ -547,6 +583,9 @@ def check(
547
583
  "total_info": info_count,
548
584
  },
549
585
  }
586
+ _add_run_stats(result, stats)
587
+ if stale:
588
+ result["stale_imports"] = stale
550
589
  return _json(result)
551
590
 
552
591
  # Case 2: Check a specific file
@@ -587,9 +626,20 @@ def check(
587
626
  # repeated checks of the same file fast (the import environment
588
627
  # stays in memory). Falls back to the cold path on any failure.
589
628
  from leanback.common.errors import lsp_diagnostics_to_errors
590
- from leanback.common.warm import check_file_warm
629
+ from leanback.common.stale import find_stale_imports
630
+ from leanback.common.warm import check_file_warm, discard_project
631
+
632
+ # Project modules edited after their .olean was built: a warm
633
+ # worker that opened the file earlier would keep using the old
634
+ # compiled version, so it is replaced; a fresh lake serve worker
635
+ # rebuilds stale imports when it opens the file. The cold path
636
+ # cannot rebuild and reports them instead.
637
+ stale = find_stale_imports(project_path, file_path)
638
+ if stale:
639
+ discard_project(project_path)
591
640
 
592
641
  engine = "cold"
642
+ stats: dict = {}
593
643
  warm_diags = check_file_warm(project_path, file_path)
594
644
  if warm_diags is not None:
595
645
  errors = lsp_diagnostics_to_errors(warm_diags, str(display_path))
@@ -600,7 +650,10 @@ def check(
600
650
  file_path=file_path,
601
651
  project_path=project_path,
602
652
  console=console,
653
+ stats=stats,
603
654
  )
655
+ if stale and engine == "cold":
656
+ errors.extend(_stale_import_warnings(stale, str(display_path)))
604
657
 
605
658
  error_count = sum(1 for e in errors if e.severity == "error")
606
659
  warning_count = sum(1 for e in errors if e.severity == "warning")
@@ -619,6 +672,12 @@ def check(
619
672
  "files_checked": 1,
620
673
  },
621
674
  }
675
+ _add_run_stats(result, stats)
676
+ if stale:
677
+ if engine == "warm":
678
+ result["imports_rebuilt"] = stale
679
+ else:
680
+ result["stale_imports"] = stale
622
681
  if file_path.name == "lakefile.lean" and not success:
623
682
  result["note"] = (
624
683
  "lakefile.lean uses Lake DSL which may produce errors "
@@ -1,157 +0,0 @@
1
- """
2
- Lean environment handling utilities.
3
-
4
- This module provides functionality for executing Lean commands in the correct environment.
5
- """
6
-
7
- import os
8
- import subprocess
9
- import sys
10
- from pathlib import Path
11
-
12
-
13
- def _find_native_dynlibs(project_path: Path) -> list[Path]:
14
- """Discover native shared libraries in Lake packages.
15
-
16
- Scans .lake/packages/*/.lake/build/lib/ for .so (Linux) or .dylib (macOS)
17
- files that need --load-dynlib for the Lean interpreter.
18
- """
19
- packages_dir = project_path / ".lake" / "packages"
20
- if not packages_dir.is_dir():
21
- return []
22
-
23
- if sys.platform == "darwin":
24
- ext = "dylib"
25
- elif sys.platform == "win32":
26
- ext = "dll"
27
- else:
28
- ext = "so"
29
- return sorted(packages_dir.glob(f"*/.lake/build/lib/lib*.{ext}"))
30
-
31
-
32
- def find_local_modules(project_path: Path, modules: list[str]) -> list[str]:
33
- """Filter module names to those with source files in the project.
34
-
35
- A module like ``Foo.Bar`` is local if ``<project>/Foo/Bar.lean`` exists.
36
- Dependency modules (e.g. Mathlib.*) and core modules are excluded — they
37
- are already built or come from the cache.
38
- """
39
- local = []
40
- for module in modules:
41
- source = project_path / (module.replace(".", "/") + ".lean")
42
- if source.is_file():
43
- local.append(module)
44
- return local
45
-
46
-
47
- def build_modules(
48
- project_path: Path, modules: list[str], timeout: float = 600
49
- ) -> tuple[bool, str]:
50
- """Build the given modules with ``lake build`` in the project directory.
51
-
52
- Returns:
53
- Tuple of (success, output). On failure, output contains the
54
- combined stdout/stderr from lake.
55
- """
56
- if not modules:
57
- return True, ""
58
- try:
59
- result = subprocess.run(
60
- ["lake", "build", *modules],
61
- cwd=project_path,
62
- capture_output=True,
63
- text=True,
64
- check=False,
65
- timeout=timeout,
66
- )
67
- except subprocess.TimeoutExpired:
68
- return False, f"lake build timed out after {timeout:.0f} seconds"
69
- output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip()
70
- return result.returncode == 0, output
71
-
72
-
73
- class LeanRunner:
74
- """
75
- Utility for running Lean commands in the appropriate environment.
76
- """
77
-
78
- @staticmethod
79
- def run_lean(
80
- file_path: str | Path,
81
- project_path: str | Path | None = None,
82
- capture_json: bool = False,
83
- check: bool = True,
84
- env_vars: dict[str, str] | None = None,
85
- timeout: float | None = None,
86
- ) -> tuple[int, str, str]:
87
- """
88
- Run Lean on a file in the appropriate environment.
89
-
90
- Args:
91
- file_path: Path to the Lean file to run
92
- project_path: Path to a Lean project (for lake env)
93
- capture_json: Whether to request JSON output
94
- check: Whether to raise an exception on non-zero exit codes
95
- env_vars: Additional environment variables
96
- timeout: Timeout in seconds
97
-
98
- Returns:
99
- Tuple of (exit_code, stdout, stderr)
100
- """
101
- # Convert to Path
102
- if isinstance(file_path, str):
103
- file_path = Path(file_path)
104
-
105
- # We need absolute paths for checking existence internally
106
- abs_file_path = file_path.absolute()
107
-
108
- # Determine which path and command to use
109
- if project_path:
110
- # If a project is specified, use that project's environment
111
- project_path = (
112
- Path(project_path) if isinstance(project_path, str) else project_path
113
- )
114
-
115
- # Always use lake env lean for projects - this ensures proper dependency management
116
- command = ["lake", "env", "lean"]
117
-
118
- # Load native shared libraries for FFI-dependent packages (e.g., cvc5)
119
- for lib in _find_native_dynlibs(project_path):
120
- command.append(f"--load-dynlib={lib}")
121
-
122
- if capture_json:
123
- command.append("--json")
124
- command.append(str(abs_file_path))
125
- cwd = project_path
126
-
127
- # Lake automatically handles all dependency paths - no manual LEAN_PATH setup needed
128
-
129
- else:
130
- # Default: run directly with lean for files without project context
131
- command = ["lean"]
132
- if capture_json:
133
- command.append("--json")
134
- command.append(str(abs_file_path))
135
-
136
- cwd = file_path.parent
137
-
138
- # Set up environment
139
- proc_env = os.environ.copy()
140
- if env_vars:
141
- proc_env.update(env_vars)
142
-
143
- try:
144
- result = subprocess.run(
145
- command,
146
- cwd=cwd,
147
- check=check,
148
- capture_output=True,
149
- text=True,
150
- env=proc_env,
151
- timeout=timeout,
152
- )
153
- return result.returncode, result.stdout, result.stderr
154
- except subprocess.CalledProcessError as e:
155
- return e.returncode, e.stdout, e.stderr
156
- except subprocess.TimeoutExpired:
157
- return 124, "", "Lean execution timed out"
File without changes
File without changes
File without changes
File without changes