Skip to content

Commit b944e39

Browse files
ArghDA M6 follow-on: lake-env import resolution (M6 → 100%) (#47)
## What The Lean `#print axioms` audit now reaches **imported** files, not just import-free ones — closing the last M6 follow-on (M6 → 100%). When a `lakefile.lean`/`lakefile.toml` is found at or above the file, both the elaboration and the audit run via `lake env lean` (cwd = the lake root), injecting the project's `LEAN_PATH` so project-local imports resolve. ## How - `lake_root(file, include_root)` — the nearest ancestor directory with a lakefile, else `None` (bare-`lean` fallback). - `run_lean(file, lake_root)` — `lake env lean <file>` under a lake project (cwd = root), falling back to bare `lean` when there's no lakefile or `lake` itself is absent. Both `check_file` and `axiom_audit` route through it. - The audit copy stays in a **clean temp dir**: `LEAN_PATH` entries are absolute, so imports resolve regardless of the copy's location (ground-truthed). Assumes the project is already built — **arghda checks proofs, it does not run `lake build`** (a deliberate scope line, not a gap). With no lakefile the behaviour is unchanged (core + import-free files via bare `lean`). ## Verification (actually run) - `cargo fmt --check` — clean - `cargo clippy --all-targets -- -D warnings` — clean - `cargo test` — **168 pass** (+1 `lake_root` unit) - `bash scripts/check-spdx.sh .` — OK **Dogfooded vs real Lean 4.13.0 + Lake 5.0.0 on a built lake project:** | file | before M6 | now | |---|---|---| | `Demo.lean` (imports `Demo.Basic`), inside the built lake project | unresolved-import error | **`proven`** | | `Demo/Basic.lean` (leaf, no imports) | `proven` | `proven` | | same importing file copied **outside** a lake project | — | `error` (negative control — confirms the fix is the lake-env resolution) | ## Scope M6 complete. The remaining climb: M4 (Idris2 totality-hole/`partial` lint), M7 (real Echidna client — pending confirmation its API exists). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7 --- _Generated by [Claude Code](https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d61ae75 commit b944e39

2 files changed

Lines changed: 101 additions & 23 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ milestones = [
4646
{ name = "M3: Flying-Logic reasoning graph (src/reason: And|Or juncts, demote-only cycle-safe Verdict propagation, additive studio JSON)", completion = 100 },
4747
{ name = "M4: Idris2 adapter (core/ABI language: .ipkg/--check/totality; escape-hatches)", completion = 95 },
4848
{ name = "M5: SMT solver backends (Z3, CVC5): SMT-LIB2 -> sat/unsat/unknown -> Verdict", completion = 100 },
49-
{ name = "M6: Lean4 adapter (lake build + #print axioms audit)", completion = 92 },
49+
{ name = "M6: Lean4 adapter (lake build + #print axioms audit)", completion = 100 },
5050
{ name = "M7: Echidna dispatch seam (optional route to orchestrator :8090, same Outcome)", completion = 70 },
5151
{ name = "M8: Coq/Rocq adapter (--heavy provisioning; Section-aware postulate classifier)", completion = 90 },
5252
{ name = "M9: Isabelle adapter (--heavy; dogfood tropical-resource-typing .thy)", completion = 90 },
@@ -114,6 +114,22 @@ milestones = [
114114
# --backend idris2` over an ipkg fixture → crt_roots ['App'], App+Helper wired.
115115
# M4 now 95% (remaining: totality-hole `?name` + per-def `partial` lint).
116116
#
117+
# M6 FOLLOW-ON landed 2026-07-01 (M6 → 100%): lake-env import resolution — the
118+
# Lean audit now reaches IMPORTED files, not just import-free ones. When a
119+
# `lakefile.lean`/`lakefile.toml` is found at/above the file (`lake_root`), both
120+
# the elaboration and the `#print axioms` audit run via `lake env lean` with
121+
# cwd = the lake root, so the project's LEAN_PATH resolves project-local imports
122+
# (`run_lean` helper; falls back to bare `lean` when there's no lakefile, or if
123+
# `lake` is absent). The audit copy stays in a clean temp dir — LEAN_PATH
124+
# entries are absolute, so imports resolve regardless of the copy's location
125+
# (ground-truthed). Assumes the project is already built (arghda checks proofs,
126+
# it does not run `lake build` — a deliberate scope line, not a gap). 168 tests
127+
# (+1 lake_root unit). DOGFOODED vs real Lean 4.13.0 + Lake 5.0.0 on a built
128+
# lake project: the importing file `Demo.lean` (imports Demo.Basic) → proven
129+
# (was an unresolved-import error pre-M6); leaf → proven; negative control (same
130+
# file copied OUTSIDE the lake project) → error, confirming the fix is the
131+
# lake-env resolution specifically.
132+
#
117133
# M3 FOLLOW-ON landed 2026-07-01: workspace-fed verdicts + staleness in `reason`
118134
# — the reasoning graph now sources REAL verdicts from a four-state workspace's
119135
# `proven/` state, no re-check needed. New `arghda reason <path> --workspace
@@ -375,13 +391,13 @@ milestones = [
375391
actions = [
376392
"ENGINE-SPINE DELIVERABLE COMPLETE: M0-M7 + M11 all landed. v0.1 spec closed. Six backends (agda, agda-cubical, idris2, lean4 Assistant; z3, cvc5 Solver) + reasoning graph + doctor + groove + dispatch seam. Remaining work is refinement + the heavy tail:",
377393
"M7 follow-on: the real Echidna orchestrator HTTP client (feature-gated), once Echidna's API is confirmed — only Dispatch::run changes.",
378-
"M6 follow-on: #print axioms audit DONE (import-free files promote honestly); remaining = lake-env/LEAN_PATH so imported files can be checked + audited too.",
394+
"M6 follow-on: DONE (M6 → 100%) — lake-env resolution (`lake env lean` under a lakefile) so IMPORTED Lean files elaborate + get #print-axioms-audited too, not just import-free ones. Dogfooded on a built lake project.",
379395
"M4 follow-on: .ipkg-declared roots DONE; remaining = totality-hole (?name) + per-def `partial` lint.",
380396
"M3 follow-on: DONE — `reason --workspace <ws>` sources real verdicts from a `proven/` state (workspace_verdicts) + demotes stale-proven via the content-hash closure. Refinement follow-ons bucket now complete.",
381397
"M8 (Coq/Rocq): DONE (90%) — coqc 8.18.0 provisioned + adapter built + dogfooded (proven/admitted/postulated/error) with Section-aware postulate classifier. Remaining: _CoqProject logical paths + dependency-ordered compilation.",
382398
"M9 (Isabelle): DONE (90%) — Isabelle2025 provisioned + adapter built + dogfooded (proven/admitted/postulated/error) via session-build; ROOT-based roots, imports-clause edges. Remaining: session-qualified imports + dependency-ordered multi-session builds.",
383399
"M10 (Mizar): DONE (85%) — turned out to be a REAL verify, not detect-only: static i386 verifier runs on x86_64; provisioned + adapter (accom+verifier+.err) + dogfooded (proven/error). HEAVY TAIL M8-M10 COMPLETE. Remaining: local-article prel export.",
384-
"Next, climb up: M6 (lake-env/LEAN_PATH so imported Lean files get audited), M4 (totality-hole ?name + per-def partial lint for Idris2), M7 (real Echidna HTTP client, feature-gated). Each its own focused draft PR, gate green, real-tool dogfood where installed.",
400+
"Next, climb up: M4 (totality-hole ?name + per-def partial lint for Idris2), M7 (real Echidna HTTP client, feature-gated — but FIRST confirm Echidna's API actually exists; if not, keep the honest stub). Each its own focused draft PR, gate green, real-tool dogfood where installed.",
385401
]
386402

387403
[maintenance-status]
@@ -390,7 +406,9 @@ last-result = "pass" # unknown | pass | warn | fail
390406
open-warnings = 0
391407
open-failures = 0
392408
# `just check` (fmt-check + clippy -D warnings + build + test + SPDX) green
393-
# post-M10: 124 lib + 43 integration = 167 tests pass. codeql.yml added
409+
# post-M6-followon: 125 lib + 43 integration = 168 tests pass. M6 lake-env
410+
# import resolution dogfooded vs real Lean 4.13.0 + Lake 5.0.0 (imported file in
411+
# a built lake project → proven; outside → error). codeql.yml added
394412
# (code-scanning gate fix). M10 Mizar adapter dogfooded vs real Mizar 8.1.15
395413
# (proven/error via accom+verifier+.err; MIZFILES=/opt/mizar; doctor [OK]
396414
# mizar). M9 Isabelle dogfooded vs Isabelle2025; M8 Coq dogfooded vs coqc

src/prover/lean.rs

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@
1919
//! modules the bare temp-dir copy can't resolve), it stays `Verdict::Unknown`.
2020
//! * binary absent → [`Verdict::Unavailable`].
2121
//!
22-
//! The audit currently reaches import-free files (a temp-dir copy elaborates
23-
//! them without `LEAN_PATH`); auditing imported files needs `lake env`
24-
//! resolution — a documented follow-on.
22+
//! Import resolution: when a `lakefile.lean`/`lakefile.toml` is found at or
23+
//! above the file, both the elaboration and the `#print axioms` audit run
24+
//! under `lake env lean` (cwd = the lake root), which injects the project's
25+
//! `LEAN_PATH` — so IMPORTED files elaborate and get audited too, not just
26+
//! import-free ones. (This assumes the project is already built; arghda checks
27+
//! proofs, it does not run `lake build`.) With no lakefile, it falls back to
28+
//! bare `lean <file>` (core + import-free files); if `lake` is somehow absent,
29+
//! it also falls back to bare `lean`.
2530
//!
2631
//! Lean modules are dotted (`Mathlib.Data.Nat` ↔ `Mathlib/Data/Nat.lean`),
2732
//! so [`crate::graph::module_name_of`] is reused. Imports are top-level
2833
//! `import Mod` lines; Lean `open` is a *namespace* directive, not a
29-
//! dependency edge, so it is deliberately ignored. Project-wide module
30-
//! resolution (via `lake env` / `LEAN_PATH`) is a documented follow-on;
31-
//! this baseline runs `lean <file>` directly (core + import-free files).
34+
//! dependency edge, so it is deliberately ignored.
3235
3336
use super::{Backend, BackendKind, Outcome, Verdict};
3437
use crate::graph;
@@ -70,8 +73,11 @@ impl Backend for Lean {
7073
Some("#print axioms")
7174
}
7275

73-
fn check_file(&self, file: &Path, _include_root: &Path) -> Result<Outcome> {
74-
let output = Command::new("lean").arg(file).output();
76+
fn check_file(&self, file: &Path, include_root: &Path) -> Result<Outcome> {
77+
// Under a lake project, run via `lake env lean` so project-local
78+
// imports resolve; otherwise bare `lean`.
79+
let lake = lake_root(file, include_root);
80+
let output = run_lean(file, lake.as_deref());
7581
match output {
7682
Ok(out) => {
7783
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
@@ -81,10 +87,10 @@ impl Backend for Lean {
8187
// `#print axioms` audit to promote it honestly: Proven if every
8288
// declaration depends only on the standard axioms, Postulated
8389
// if a non-standard axiom (e.g. `native_decide`'s) sneaks in,
84-
// Admitted on sorryAx. If the audit can't run (imports need
85-
// lake, no declarations, tool absent) it stays `Unknown`.
90+
// Admitted on sorryAx. If the audit can't run (unbuilt imports,
91+
// no declarations, tool absent) it stays `Unknown`.
8692
if verdict == Verdict::Unknown {
87-
if let Some(audited) = axiom_audit(file) {
93+
if let Some(audited) = axiom_audit(file, lake.as_deref()) {
8894
verdict = audited;
8995
}
9096
}
@@ -268,12 +274,15 @@ fn classify_axioms(output: &str) -> Verdict {
268274
}
269275

270276
/// Run a `#print axioms` audit on `file`'s declarations. Copies the source
271-
/// into a fresh temp dir, appends `#print axioms <name>` per declaration,
272-
/// and runs `lean`. Returns the classified verdict, or `None` when the audit
273-
/// can't be trusted — no declarations, `lean` absent, or the copy fails to
274-
/// elaborate (e.g. it imports modules that need `lake env`/`LEAN_PATH`, which
275-
/// a bare temp dir lacks). `None` ⇒ the caller keeps the honest `Unknown`.
276-
fn axiom_audit(file: &Path) -> Option<Verdict> {
277+
/// into a fresh temp dir, appends `#print axioms <name>` per declaration, and
278+
/// elaborates it — under `lake env lean` when `lake_root` is `Some` (so the
279+
/// project's `LEAN_PATH` resolves imported modules), else bare `lean`. Because
280+
/// `LEAN_PATH` entries are absolute, the audit copy can stay in a clean temp
281+
/// dir even for imported files. Returns the classified verdict, or `None` when
282+
/// the audit can't be trusted — no declarations, `lean` absent, or the copy
283+
/// fails to elaborate (e.g. an import whose `.olean` isn't built). `None` ⇒
284+
/// the caller keeps the honest `Unknown`.
285+
fn axiom_audit(file: &Path, lake_root: Option<&Path>) -> Option<Verdict> {
277286
let src = fs::read_to_string(file).ok()?;
278287
let names = decl_names(&src);
279288
if names.is_empty() {
@@ -295,9 +304,9 @@ fn axiom_audit(file: &Path) -> Option<Verdict> {
295304
let audit_file = dir.join("Audit.lean");
296305

297306
let verdict = match fs::write(&audit_file, &body) {
298-
Ok(()) => match Command::new("lean").arg(&audit_file).output() {
307+
Ok(()) => match run_lean(&audit_file, lake_root) {
299308
// Only trust the audit if the copy elaborated cleanly; otherwise
300-
// (imports unresolved in the temp dir, etc.) stay Unknown.
309+
// (an import whose .olean isn't built, etc.) stay Unknown.
301310
Ok(out) if out.status.success() => {
302311
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
303312
combined.push_str(&String::from_utf8_lossy(&out.stderr));
@@ -312,6 +321,39 @@ fn axiom_audit(file: &Path) -> Option<Verdict> {
312321
verdict
313322
}
314323

324+
/// Find the lake project root at or above `file` (or `include_root`): the
325+
/// nearest ancestor directory containing a `lakefile.lean` or `lakefile.toml`.
326+
/// `None` ⇒ not a lake project (bare-`lean` fallback).
327+
fn lake_root(file: &Path, include_root: &Path) -> Option<PathBuf> {
328+
let start = file.parent().unwrap_or(include_root);
329+
for dir in start.ancestors().chain(include_root.ancestors()) {
330+
if dir.join("lakefile.lean").is_file() || dir.join("lakefile.toml").is_file() {
331+
return Some(dir.to_path_buf());
332+
}
333+
}
334+
None
335+
}
336+
337+
/// Elaborate `file` with Lean. Under a lake project (`lake_root` = `Some`), run
338+
/// `lake env lean <file>` with cwd = the lake root so `LEAN_PATH` resolves
339+
/// project imports; if `lake` itself is absent, fall back to bare `lean`. With
340+
/// no lake project, run bare `lean <file>`.
341+
fn run_lean(file: &Path, lake_root: Option<&Path>) -> std::io::Result<std::process::Output> {
342+
if let Some(root) = lake_root {
343+
match Command::new("lake")
344+
.arg("env")
345+
.arg("lean")
346+
.arg(file)
347+
.current_dir(root)
348+
.output()
349+
{
350+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // fall back to bare lean
351+
other => return other,
352+
}
353+
}
354+
Command::new("lean").arg(file).output()
355+
}
356+
315357
#[cfg(test)]
316358
mod tests {
317359
use super::*;
@@ -408,6 +450,24 @@ example : True := trivial\n\
408450
assert_eq!(lean_verdict("error: type mismatch", false), Verdict::Error);
409451
}
410452

453+
#[test]
454+
fn lake_root_found_at_ancestor() {
455+
// A file two dirs below a lakefile resolves to the lake root; a tree
456+
// with no lakefile resolves to None (bare-lean fallback).
457+
let tmp = tempfile::tempdir().unwrap();
458+
let root = tmp.path();
459+
std::fs::write(root.join("lakefile.toml"), "name = \"demo\"\n").unwrap();
460+
std::fs::create_dir_all(root.join("Demo")).unwrap();
461+
let deep = root.join("Demo/Basic.lean");
462+
std::fs::write(&deep, "def x : Nat := 0\n").unwrap();
463+
assert_eq!(lake_root(&deep, root).as_deref(), Some(root));
464+
465+
let bare = tempfile::tempdir().unwrap();
466+
let f = bare.path().join("T.lean");
467+
std::fs::write(&f, "theorem t : True := trivial\n").unwrap();
468+
assert_eq!(lake_root(&f, bare.path()), None);
469+
}
470+
411471
#[test]
412472
fn check_file_is_honest_about_availability() {
413473
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)