Skip to main content

lean_agent_core/
accept.rs

1//! Accept predicate for replayed proof attempts.
2//!
3//! A passing `lake lean` exit code alone is gameable: an attempt can weaken the
4//! statement it claims to prove, smuggle in `sorry`, or pin an extra axiom, and
5//! still exit zero. This module turns "the file compiled" into "the file
6//! compiled and the thing it proved is the thing we asked for" through a small
7//! set of guards, each of which returns a typed rejection carrying its own
8//! trace. It is not a proof of soundness: it raises the bar against the known
9//! bypass classes below. See "Known limitations".
10//!
11//! Live guards (run on every passing compile):
12//!
13//! 1. STATEMENT-UNCHANGED. The declaration signature (everything up to the
14//!    `:=` that opens the proof body) must be byte-identical before and after the
15//!    edit. This guards against silent statement-weakening.
16//! 2. AXIOM-WHITELIST. `#print axioms <decl>` must report a subset of
17//!    {`propext`, `Classical.choice`, `Quot.sound`}. A `sorry` warning on the
18//!    compile, or any axiom outside the set, is a rejection. The axiom set is
19//!    read from a probe bracketed by per-run sentinels, so a top-level command
20//!    in the edited file cannot forge the result, and an anonymous declaration
21//!    (`example`) is aliased to a named probe rather than skipped. This guards
22//!    against `sorry`/`admit` and extra-axiom passes.
23//! 3. REVERSE-DEP. `lake build` of the edited module (and its direct importers
24//!    when they are cheap to find) must succeed, so a weakened shared lemma
25//!    cannot stay green on a stale olean.
26//!
27//! Guard 4 (NEGATIVE-CONTROL) is wired but stubbed. See [`check_negative_control`].
28//!
29//! Known limitations: these guards catch known bypass classes; they are not a
30//! proof that no bypass exists. The axiom probe assumes the edit is a proof
31//! body, so a replacement that introduces a top-level command (`#eval`,
32//! `#print`, `import`, `set_option`, `macro`, `elab`, `open`) is refused at
33//! patch time (see [`crate::patch`]); that restriction is what keeps the probe
34//! trustworthy.
35
36use crate::mine::module_name;
37use crate::{
38    Diagnostic, LeanFile, Provenance, Result, TraceConfig, detect_declaration, discover_lean_files,
39    run_lean_file,
40};
41use camino::{Utf8Path, Utf8PathBuf};
42use serde::{Deserialize, Serialize};
43use std::process::Stdio;
44use std::time::Duration;
45use tokio::process::Command;
46use tokio::time;
47use tracing::{debug, warn};
48use uuid::Uuid;
49
50/// Axioms a certified proof may depend on without weakening trust.
51///
52/// These are Lean's standard classical foundations. Anything else (notably
53/// `sorryAx`) means the proof is not the proof we asked for.
54pub const AXIOM_WHITELIST: [&str; 3] = ["propext", "Classical.choice", "Quot.sound"];
55
56/// Most importer modules a single reverse-dependency guard will build.
57const MAX_IMPORTERS: usize = 16;
58
59/// Why the accept predicate refused to certify a passing compile.
60///
61/// Each variant carries the trace needed to see why the guard fired.
62#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
63#[serde(tag = "guard", rename_all = "snake_case")]
64pub enum RejectReason {
65    /// The edit changed the declaration statement, not just the proof body.
66    StatementChanged {
67        /// Declaration whose statement changed.
68        declaration: String,
69        /// Signature before the edit.
70        before: String,
71        /// Signature after the edit.
72        after: String,
73    },
74    /// The statement guard could not locate the declaration to compare.
75    StatementUnverifiable {
76        /// Why the comparison could not proceed.
77        detail: String,
78    },
79    /// The compiled declaration depends on a non-whitelisted axiom.
80    DisallowedAxiom {
81        /// Declaration whose axioms were inspected.
82        declaration: String,
83        /// Axioms outside [`AXIOM_WHITELIST`] (for example `sorryAx`).
84        offending: Vec<String>,
85        /// Full axiom set Lean reported.
86        axioms: Vec<String>,
87    },
88    /// The axiom set could not be read back from Lean.
89    AxiomCheckFailed {
90        /// Declaration whose axioms were being inspected.
91        declaration: String,
92        /// Why the check could not proceed.
93        detail: String,
94    },
95    /// `lake build` of the edited module (or an importer) failed.
96    ReverseDepFailed {
97        /// Module that failed to build.
98        module: String,
99        /// First lines of the build failure.
100        detail: String,
101    },
102    /// Negative control found the claim and its negation both pass (vacuous).
103    VacuousClaim {
104        /// Detail of the negative-control failure.
105        detail: String,
106    },
107}
108
109/// Outcome of one guard inside an [`AcceptReport`].
110#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
111#[serde(tag = "status", rename_all = "snake_case")]
112pub enum GuardStatus {
113    /// The guard ran and found nothing wrong.
114    Passed,
115    /// The guard did not run; `note` records why.
116    Skipped {
117        /// Human-readable reason the guard was not evaluated.
118        note: String,
119    },
120    /// The guard refused the attempt.
121    Rejected {
122        /// Typed rejection with its trace.
123        reason: RejectReason,
124    },
125}
126
127/// Per-guard outcomes for one accept evaluation.
128#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
129pub struct AcceptReport {
130    /// Guard 1: statement unchanged.
131    pub statement: GuardStatus,
132    /// Guard 2: axiom whitelist.
133    pub axioms: GuardStatus,
134    /// Guard 3: reverse dependency build.
135    pub reverse_dep: GuardStatus,
136    /// Guard 4: negative control (stubbed).
137    pub negative_control: GuardStatus,
138}
139
140/// Result of running the accept predicate over one patched, passing compile.
141#[derive(Clone, Debug, Eq, PartialEq)]
142pub struct AcceptOutcome {
143    /// True only when every live guard passed.
144    pub accepted: bool,
145    /// Per-guard outcomes.
146    pub report: AcceptReport,
147    /// The first guard rejection, when one fired.
148    pub reject_reason: Option<RejectReason>,
149}
150
151/// Negation manifest for the negative-control guard.
152///
153/// TODO(loop-phase): the loop must supply the formal negation of the claim so the
154/// guard can compile it and require failure. Until then this type is the wiring
155/// seam and [`check_negative_control`] is a documented no-op.
156#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct NegativeControl {
158    /// Lean source for the sibling declaration asserting the negation.
159    pub negation_source: String,
160    /// Name of the negation declaration.
161    pub negation_name: String,
162}
163
164/// Everything the accept predicate needs for one attempt.
165#[derive(Clone, Debug)]
166pub struct AcceptRequest<'a> {
167    /// Source-of-truth project root (holds the pre-edit file).
168    pub lake_root: &'a Utf8Path,
169    /// Patched workspace copy root (holds the post-edit file).
170    pub workspace_root: &'a Utf8Path,
171    /// Target file, relative to each root.
172    pub target: &'a Utf8Path,
173    /// One-based first line the edit was allowed to touch.
174    pub edit_line: u32,
175    /// Diagnostics from the patched compile (used to spot a `sorry` warning).
176    pub patched_diagnostics: &'a [Diagnostic],
177    /// Tooling provenance for any Lean run.
178    pub provenance: &'a Provenance,
179    /// Per-command timeout for axiom and build checks.
180    pub timeout: Duration,
181    /// Run the reverse-dependency guard (guard 3).
182    pub run_reverse_dep: bool,
183    /// Negative-control manifest, when the loop supplies one (guard 4).
184    pub negative_control: Option<&'a NegativeControl>,
185}
186
187/// Run the live guards in order and return the first rejection, if any.
188///
189/// Guards short-circuit: once one rejects, the rest are marked skipped so the
190/// report still shows why later guards did not run.
191pub async fn evaluate(request: &AcceptRequest<'_>) -> AcceptOutcome {
192    let not_eval = || GuardStatus::Skipped {
193        note: "not evaluated; an earlier guard rejected".to_owned(),
194    };
195
196    let statement = check_statement(request);
197    if let GuardStatus::Rejected { reason } = &statement {
198        let reason = reason.clone();
199        warn!(?reason, "accept predicate rejected on the statement guard");
200        return AcceptOutcome {
201            accepted: false,
202            report: AcceptReport {
203                statement,
204                axioms: not_eval(),
205                reverse_dep: not_eval(),
206                negative_control: not_eval(),
207            },
208            reject_reason: Some(reason),
209        };
210    }
211
212    let axioms = check_axioms(request).await;
213    // The axiom guard must PASS for an attempt to be accepted. A rejection is
214    // surfaced directly; a skip (which would otherwise leave `accepted` true) is
215    // turned into a rejection so an unverifiable axiom set never counts as clean.
216    let axiom_reject = match &axioms {
217        GuardStatus::Passed => None,
218        GuardStatus::Rejected { reason } => Some(reason.clone()),
219        GuardStatus::Skipped { note } => Some(RejectReason::AxiomCheckFailed {
220            declaration: "<unknown>".to_owned(),
221            detail: format!("axiom guard did not run ({note}); refusing to accept"),
222        }),
223    };
224    if let Some(reason) = axiom_reject {
225        warn!(?reason, "accept predicate rejected on the axiom guard");
226        return AcceptOutcome {
227            accepted: false,
228            report: AcceptReport {
229                statement,
230                axioms,
231                reverse_dep: not_eval(),
232                negative_control: not_eval(),
233            },
234            reject_reason: Some(reason),
235        };
236    }
237
238    let reverse_dep = if request.run_reverse_dep {
239        check_reverse_dep(request).await
240    } else {
241        GuardStatus::Skipped {
242            note: "reverse-dependency guard disabled".to_owned(),
243        }
244    };
245    if let GuardStatus::Rejected { reason } = &reverse_dep {
246        let reason = reason.clone();
247        warn!(
248            ?reason,
249            "accept predicate rejected on the reverse-dep guard"
250        );
251        return AcceptOutcome {
252            accepted: false,
253            report: AcceptReport {
254                statement,
255                axioms,
256                reverse_dep,
257                negative_control: not_eval(),
258            },
259            reject_reason: Some(reason),
260        };
261    }
262
263    let negative_control = check_negative_control(request.negative_control);
264    AcceptOutcome {
265        accepted: true,
266        report: AcceptReport {
267            statement,
268            axioms,
269            reverse_dep,
270            negative_control,
271        },
272        reject_reason: None,
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Guard 1: statement unchanged
278// ---------------------------------------------------------------------------
279
280/// Result of comparing the enclosing declaration's statement before and after.
281#[derive(Clone, Debug, Eq, PartialEq)]
282enum StatementCheck {
283    /// The signature is byte-identical before and after.
284    Unchanged {
285        /// Declaration label for the trace.
286        declaration: String,
287    },
288    /// The signature differs.
289    Changed {
290        /// Declaration label for the trace.
291        declaration: String,
292        /// Signature before the edit.
293        before: String,
294        /// Signature after the edit.
295        after: String,
296    },
297    /// The signature could not be extracted from one side.
298    Unverifiable {
299        /// Why extraction failed.
300        detail: String,
301    },
302}
303
304/// Guard 1. Read both copies of the target and compare the statement signature.
305fn check_statement(request: &AcceptRequest<'_>) -> GuardStatus {
306    let original_path = request.lake_root.join(request.target);
307    let patched_path = request.workspace_root.join(request.target);
308
309    let original = match std::fs::read_to_string(&original_path) {
310        Ok(text) => text,
311        Err(err) => {
312            return GuardStatus::Rejected {
313                reason: RejectReason::StatementUnverifiable {
314                    detail: format!("reading original {original_path}: {err}"),
315                },
316            };
317        }
318    };
319    let patched = match std::fs::read_to_string(&patched_path) {
320        Ok(text) => text,
321        Err(err) => {
322            return GuardStatus::Rejected {
323                reason: RejectReason::StatementUnverifiable {
324                    detail: format!("reading patched {patched_path}: {err}"),
325                },
326            };
327        }
328    };
329
330    match compare_statement(&original, &patched, request.edit_line) {
331        StatementCheck::Unchanged { .. } => GuardStatus::Passed,
332        StatementCheck::Changed {
333            declaration,
334            before,
335            after,
336        } => GuardStatus::Rejected {
337            reason: RejectReason::StatementChanged {
338                declaration,
339                before,
340                after,
341            },
342        },
343        StatementCheck::Unverifiable { detail } => GuardStatus::Rejected {
344            reason: RejectReason::StatementUnverifiable { detail },
345        },
346    }
347}
348
349/// Compare the signature of the declaration enclosing `edit_line` across copies.
350fn compare_statement(original: &str, patched: &str, edit_line: u32) -> StatementCheck {
351    let before = match enclosing_signature(original, edit_line) {
352        Ok(pair) => pair,
353        Err(detail) => {
354            return StatementCheck::Unverifiable {
355                detail: format!("original: {detail}"),
356            };
357        }
358    };
359    let after = match enclosing_signature(patched, edit_line) {
360        Ok(pair) => pair,
361        Err(detail) => {
362            return StatementCheck::Unverifiable {
363                detail: format!("patched: {detail}"),
364            };
365        }
366    };
367
368    if before.1 == after.1 {
369        StatementCheck::Unchanged {
370            declaration: before.0,
371        }
372    } else {
373        StatementCheck::Changed {
374            declaration: before.0,
375            before: before.1,
376            after: after.1,
377        }
378    }
379}
380
381/// Locate the declaration enclosing `edit_line` and return `(label, signature)`.
382fn enclosing_signature(
383    source: &str,
384    edit_line: u32,
385) -> std::result::Result<(String, String), String> {
386    let lines: Vec<&str> = source.lines().collect();
387    let idx = edit_line.saturating_sub(1) as usize;
388    let decl = detect_declaration(&lines, idx)
389        .ok_or_else(|| format!("no enclosing declaration at line {edit_line}"))?;
390    let label = decl
391        .name
392        .clone()
393        .map_or_else(|| decl.kind.clone(), |name| format!("{} {name}", decl.kind));
394    Ok((label, statement_signature(&decl.source)))
395}
396
397/// Extract the statement signature: the declaration text up to the `:=` that
398/// opens the proof body.
399///
400/// The split is on the first top-level `:=`, so a `:=` inside binders (a default
401/// argument such as `(n : Nat := 0)`) or a string literal does not end the
402/// signature. When no top-level `:=` is present the whole declaration is treated
403/// as the signature, which is the conservative choice for `inductive`/`structure`
404/// shapes that carry no proof body.
405fn statement_signature(decl_source: &str) -> String {
406    let bytes = decl_source.as_bytes();
407    let mut depth: i32 = 0;
408    let mut in_string = false;
409    let mut i = 0usize;
410    while i < bytes.len() {
411        let byte = bytes[i];
412        if in_string {
413            if byte == b'\\' {
414                i += 2;
415                continue;
416            }
417            if byte == b'"' {
418                in_string = false;
419            }
420            i += 1;
421            continue;
422        }
423        match byte {
424            b'"' => in_string = true,
425            b'(' | b'[' | b'{' => depth += 1,
426            b')' | b']' | b'}' => depth -= 1,
427            b':' if depth <= 0 && bytes.get(i + 1) == Some(&b'=') => {
428                return decl_source[..i].trim_end().to_owned();
429            }
430            _ => {}
431        }
432        i += 1;
433    }
434    decl_source.trim_end().to_owned()
435}
436
437// ---------------------------------------------------------------------------
438// Guard 2: axiom whitelist
439// ---------------------------------------------------------------------------
440
441/// Guard 2. Reject a `sorry` warning, then read `#print axioms <decl>` and
442/// require the axiom set to sit inside [`AXIOM_WHITELIST`].
443async fn check_axioms(request: &AcceptRequest<'_>) -> GuardStatus {
444    let patched_path = request.workspace_root.join(request.target);
445    let patched = match std::fs::read_to_string(&patched_path) {
446        Ok(text) => text,
447        Err(err) => {
448            return GuardStatus::Rejected {
449                reason: RejectReason::AxiomCheckFailed {
450                    declaration: "<unknown>".to_owned(),
451                    detail: format!("reading patched {patched_path}: {err}"),
452                },
453            };
454        }
455    };
456    let lines: Vec<&str> = patched.lines().collect();
457    let decl = detect_declaration(&lines, request.edit_line.saturating_sub(1) as usize);
458    let label = decl.as_ref().map_or_else(
459        || "<unknown>".to_owned(),
460        |found| {
461            found.name.clone().map_or_else(
462                || found.kind.clone(),
463                |name| format!("{} {name}", found.kind),
464            )
465        },
466    );
467
468    // A `sorry` leaves a warning even though the file exits zero. Catch it
469    // directly, which also covers anonymous declarations the next step skips.
470    if request
471        .patched_diagnostics
472        .iter()
473        .any(|diagnostic| message_mentions_sorry(&diagnostic.message))
474    {
475        return GuardStatus::Rejected {
476            reason: RejectReason::DisallowedAxiom {
477                declaration: label,
478                offending: vec!["sorryAx".to_owned()],
479                axioms: vec!["sorryAx".to_owned()],
480            },
481        };
482    }
483
484    let Some(decl) = decl else {
485        // Without a declaration there is nothing to read axioms from. Refuse,
486        // rather than fall back to the sorry-scan alone, so an unreadable axiom
487        // set never passes silently.
488        return GuardStatus::Rejected {
489            reason: RejectReason::AxiomCheckFailed {
490                declaration: label,
491                detail: "no enclosing declaration to read axioms from".to_owned(),
492            },
493        };
494    };
495
496    match print_axioms(request, &patched, &decl).await {
497        Ok(axioms) => {
498            let offending: Vec<String> = axioms
499                .iter()
500                .filter(|axiom| !AXIOM_WHITELIST.contains(&axiom.as_str()))
501                .cloned()
502                .collect();
503            if offending.is_empty() {
504                GuardStatus::Passed
505            } else {
506                GuardStatus::Rejected {
507                    reason: RejectReason::DisallowedAxiom {
508                        declaration: label,
509                        offending,
510                        axioms,
511                    },
512                }
513            }
514        }
515        Err(detail) => GuardStatus::Rejected {
516            reason: RejectReason::AxiomCheckFailed {
517                declaration: label,
518                detail,
519            },
520        },
521    }
522}
523
524/// Compile a probe that prints the edited declaration's axioms between unique
525/// sentinels, then parse only the text the probe itself emitted.
526///
527/// The patched file can hold attacker-controlled text, so the "does not depend
528/// on any axioms" marker that [`parse_axioms`] reads is trusted only when it
529/// appears between two per-run sentinels the probe emits. A top-level `#eval` in
530/// the edited file prints outside that window and so cannot forge the result. An
531/// anonymous declaration is aliased to a named `def` so it can be named in
532/// `#print axioms`. The probe file is removed before returning.
533async fn print_axioms(
534    request: &AcceptRequest<'_>,
535    patched: &str,
536    decl: &crate::Declaration,
537) -> std::result::Result<Vec<String>, String> {
538    let nonce = Uuid::new_v4().simple().to_string();
539    let begin = format!("LAP_AXIOMS_BEGIN_{nonce}");
540    let end = format!("LAP_AXIOMS_END_{nonce}");
541
542    // Name to print axioms of, plus any aliased declaration to append first.
543    let (print_name, alias_decl) = match &decl.name {
544        Some(name) => (name.clone(), None),
545        None => {
546            let alias = format!("__lap_probe_{nonce}");
547            let source =
548                alias_anonymous_decl(&decl.source, &decl.kind, &alias).ok_or_else(|| {
549                    format!(
550                        "could not build a named probe for anonymous `{}`",
551                        decl.kind
552                    )
553                })?;
554            (alias, Some(source))
555        }
556    };
557
558    let probe_rel = probe_path(request.target);
559    let probe_abs = request.workspace_root.join(&probe_rel);
560
561    let mut probe_lines: Vec<String> = patched.lines().map(str::to_owned).collect();
562    let insert_at = (decl.end_line as usize).min(probe_lines.len());
563
564    let mut block: Vec<String> = Vec::new();
565    if let Some(alias_decl) = &alias_decl {
566        block.extend(alias_decl.lines().map(str::to_owned));
567    }
568    // The sentinel prints and `#print axioms` all write to stdout in source
569    // order, so the axiom report lands between the two sentinels.
570    block.push(format!("#eval IO.println {begin:?}"));
571    block.push(format!("#print axioms {print_name}"));
572    block.push(format!("#eval IO.println {end:?}"));
573    for (offset, line) in block.into_iter().enumerate() {
574        probe_lines.insert(insert_at + offset, line);
575    }
576    let mut probe_content = probe_lines.join("\n");
577    probe_content.push('\n');
578
579    if let Err(err) = std::fs::write(&probe_abs, &probe_content) {
580        return Err(format!("writing axiom probe {probe_abs}: {err}"));
581    }
582
583    let mut config = TraceConfig::new(request.workspace_root.to_path_buf());
584    config.timeout = request.timeout;
585    config.include_warnings = true;
586    config.keep_raw_output = true;
587    let trace = run_lean_file(&config, request.provenance, LeanFile(probe_rel)).await;
588
589    let _ = std::fs::remove_file(&probe_abs);
590
591    let combined = format!(
592        "{}\n{}",
593        trace.stderr.unwrap_or_default(),
594        trace.stdout.unwrap_or_default()
595    );
596    let window = slice_between(&combined, &begin, &end).ok_or_else(|| {
597        format!("axiom probe produced no sentinel-bracketed output for `{print_name}`")
598    })?;
599    parse_axioms(window, &print_name)
600        .ok_or_else(|| format!("could not read `#print axioms {print_name}` output"))
601}
602
603/// Return the text strictly between the first `begin` and the next following
604/// `end`, or `None` when both sentinels are not present in that order.
605fn slice_between<'a>(text: &'a str, begin: &str, end: &str) -> Option<&'a str> {
606    let start = text.find(begin)? + begin.len();
607    let rest = text.get(start..)?;
608    let stop = rest.find(end)?;
609    rest.get(..stop)
610}
611
612/// Rewrite an anonymous declaration into a named `def` so its axioms can be
613/// printed.
614///
615/// The first standalone `kind` keyword in `source` becomes `def <alias>`; the
616/// type and body are kept verbatim, so the aliased term elaborates to the same
617/// proof and reports the same axioms. Returns `None` when the keyword is not
618/// found, in which case the caller refuses the attempt.
619fn alias_anonymous_decl(source: &str, kind: &str, alias: &str) -> Option<String> {
620    let mut from = 0usize;
621    while let Some(rel) = source.get(from..)?.find(kind) {
622        let at = from + rel;
623        let before_ok = at == 0
624            || source
625                .get(..at)
626                .and_then(|prefix| prefix.chars().next_back())
627                .is_some_and(char::is_whitespace);
628        let after = at + kind.len();
629        let after_ok = source
630            .get(after..)
631            .and_then(|rest| rest.chars().next())
632            .is_none_or(|c| !c.is_alphanumeric() && c != '_');
633        if before_ok && after_ok {
634            let mut out = String::with_capacity(source.len() + alias.len() + 4);
635            out.push_str(source.get(..at)?);
636            out.push_str("def ");
637            out.push_str(alias);
638            out.push_str(source.get(after..)?);
639            return Some(out);
640        }
641        from = after;
642    }
643    None
644}
645
646/// Probe-file path next to the target, marked so importer scans skip it.
647fn probe_path(target: &Utf8Path) -> Utf8PathBuf {
648    let stem = target.file_stem().unwrap_or("target");
649    let file = format!("{stem}__lap_axioms.lean");
650    match target.parent() {
651        Some(parent) if !parent.as_str().is_empty() => parent.join(file),
652        _ => Utf8PathBuf::from(file),
653    }
654}
655
656/// Parse `#print axioms <name>` output for `name`'s axiom set.
657///
658/// Returns an empty vector for "does not depend on any axioms", the listed
659/// axioms otherwise, or `None` when neither shape is present for `name`.
660fn parse_axioms(output: &str, name: &str) -> Option<Vec<String>> {
661    let none_marker = format!("'{name}' does not depend on any axioms");
662    if output.contains(&none_marker) {
663        return Some(Vec::new());
664    }
665    let some_marker = format!("'{name}' depends on axioms:");
666    let after = output.find(&some_marker)? + some_marker.len();
667    let rest = &output[after..];
668    let open = rest.find('[')?;
669    let close = rest[open..].find(']')? + open;
670    let inner = &rest[open + 1..close];
671    Some(
672        inner
673            .split(',')
674            .map(|token| token.trim().to_owned())
675            .filter(|token| !token.is_empty())
676            .collect(),
677    )
678}
679
680/// True when a diagnostic message reports a `sorry` placeholder.
681fn message_mentions_sorry(message: &str) -> bool {
682    message.to_ascii_lowercase().contains("sorry")
683}
684
685// ---------------------------------------------------------------------------
686// Guard 3: reverse dependency
687// ---------------------------------------------------------------------------
688
689/// Guard 3. Build the edited module from source, then any direct importers.
690async fn check_reverse_dep(request: &AcceptRequest<'_>) -> GuardStatus {
691    let module = module_for_target(request.workspace_root, request.target);
692
693    match build_module(request.workspace_root, &module, request.timeout).await {
694        Ok(true) => {}
695        Ok(false) => {
696            return GuardStatus::Rejected {
697                reason: RejectReason::ReverseDepFailed {
698                    module: module.clone(),
699                    detail: "lake build reported failure".to_owned(),
700                },
701            };
702        }
703        Err(detail) => {
704            return GuardStatus::Rejected {
705                reason: RejectReason::ReverseDepFailed { module, detail },
706            };
707        }
708    }
709
710    for importer in find_importers(request.workspace_root, &module) {
711        match build_module(request.workspace_root, &importer, request.timeout).await {
712            Ok(true) => {}
713            Ok(false) => {
714                return GuardStatus::Rejected {
715                    reason: RejectReason::ReverseDepFailed {
716                        module: importer,
717                        detail: "importer failed to build against the edited module".to_owned(),
718                    },
719                };
720            }
721            Err(detail) => {
722                // An importer that cannot even be spawned is logged, not fatal:
723                // the edited module already built cleanly above.
724                warn!(%importer, %detail, "skipping importer that could not be built");
725            }
726        }
727    }
728
729    GuardStatus::Passed
730}
731
732/// Derive the dotted module name for `target` (relative to `root`), honoring the
733/// lake library/executable source directories.
734///
735/// A `srcDir` layout maps to the real module name (`src/Demo/Basic.lean` becomes
736/// `Demo.Basic`, not `src.Demo.Basic`). When no source directory matches, the
737/// path is taken relative to the project root, which is correct for the default
738/// root-relative layout.
739fn module_for_target(root: &Utf8Path, target: &Utf8Path) -> String {
740    for src_dir in lake_source_dirs(root) {
741        if let Ok(stripped) = target.strip_prefix(Utf8Path::new(&src_dir)) {
742            return module_name(stripped, Utf8Path::new("."));
743        }
744    }
745    module_name(target, Utf8Path::new("."))
746}
747
748/// Source directories declared by the `lakefile.toml` libraries and executables.
749///
750/// Only `srcDir` values other than the project root are returned, longest first,
751/// so the most specific source root is stripped before a module name is formed.
752/// A missing or unparsable `lakefile.toml` yields an empty list and the caller
753/// falls back to the project-root mapping.
754fn lake_source_dirs(root: &Utf8Path) -> Vec<String> {
755    let Ok(text) = std::fs::read_to_string(root.join("lakefile.toml")) else {
756        return Vec::new();
757    };
758    let Ok(value) = toml::from_str::<toml::Value>(&text) else {
759        return Vec::new();
760    };
761    let mut dirs = Vec::new();
762    for table_key in ["lean_lib", "lean_exe"] {
763        let Some(entries) = value.get(table_key).and_then(toml::Value::as_array) else {
764            continue;
765        };
766        for entry in entries {
767            if let Some(dir) = entry.get("srcDir").and_then(toml::Value::as_str) {
768                let dir = dir.trim_matches('/');
769                if !dir.is_empty() && dir != "." {
770                    dirs.push(dir.to_owned());
771                }
772            }
773        }
774    }
775    dirs.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
776    dirs.dedup();
777    dirs
778}
779
780/// Run `lake build <module>` in `root` and report success, build failure, or a
781/// spawn-level error.
782async fn build_module(
783    root: &Utf8Path,
784    module: &str,
785    timeout: Duration,
786) -> std::result::Result<bool, String> {
787    match run_capture("lake", &["build", module], root, timeout).await {
788        Ok(output) => {
789            if !output.success {
790                debug!(%module, detail = %first_lines(&output.combined, 8), "lake build failed");
791            }
792            Ok(output.success)
793        }
794        Err(err) => Err(format!("lake build did not run: {err}")),
795    }
796}
797
798/// Find modules under `root` whose source imports `module` directly.
799fn find_importers(root: &Utf8Path, module: &str) -> Vec<String> {
800    let Ok(files) = discover_lean_files(root, true) else {
801        return Vec::new();
802    };
803    let exact = format!("import {module}");
804    let prefix = format!("import {module} ");
805    let mut importers = Vec::new();
806    for file in files {
807        let path = file.as_path();
808        if path.as_str().contains("__lap_") {
809            continue;
810        }
811        let Ok(source) = std::fs::read_to_string(path) else {
812            continue;
813        };
814        let imports_it = source.lines().any(|line| {
815            let trimmed = line.trim();
816            trimmed == exact || trimmed.starts_with(&prefix)
817        });
818        if imports_it {
819            let relative = path.strip_prefix(root).unwrap_or(path);
820            let importer = module_for_target(root, relative);
821            if importer != module {
822                importers.push(importer);
823            }
824        }
825        if importers.len() >= MAX_IMPORTERS {
826            break;
827        }
828    }
829    importers.sort();
830    importers.dedup();
831    importers
832}
833
834// ---------------------------------------------------------------------------
835// Guard 4: negative control (stub)
836// ---------------------------------------------------------------------------
837
838/// Guard 4. Compile the formal negation of the claim and require it to fail; if
839/// both the claim and its negation pass, the claim is vacuous and is rejected.
840///
841/// TODO(loop-phase): this needs the claim manifest (the negation source and
842/// name) that the loop has not produced yet. Until a [`NegativeControl`] is
843/// supplied the guard is a documented no-op so guards 1-3 stay live on their own.
844pub fn check_negative_control(control: Option<&NegativeControl>) -> GuardStatus {
845    match control {
846        None => GuardStatus::Skipped {
847            note: "TODO(loop-phase): negative control needs the claim manifest".to_owned(),
848        },
849        Some(_) => GuardStatus::Skipped {
850            note: "TODO(loop-phase): negation compile is not implemented yet".to_owned(),
851        },
852    }
853}
854
855// ---------------------------------------------------------------------------
856// Process helper
857// ---------------------------------------------------------------------------
858
859/// Captured output of one external command.
860struct CommandOutput {
861    /// True when the process exited successfully.
862    success: bool,
863    /// Combined stderr and stdout, for the rejection trace.
864    combined: String,
865}
866
867/// Spawn `program` with `args` in `cwd`, capturing output under a timeout.
868///
869/// A timeout is reported as a non-success result rather than an error, so a
870/// build that hangs is treated as a build failure.
871async fn run_capture(
872    program: &str,
873    args: &[&str],
874    cwd: &Utf8Path,
875    timeout: Duration,
876) -> Result<CommandOutput> {
877    let mut command = Command::new(program);
878    command
879        .args(args)
880        .current_dir(cwd)
881        .kill_on_drop(true)
882        .stdout(Stdio::piped())
883        .stderr(Stdio::piped());
884
885    let child = command.spawn()?;
886    match time::timeout(timeout, child.wait_with_output()).await {
887        Ok(result) => {
888            let output = result?;
889            let combined = format!(
890                "{}\n{}",
891                String::from_utf8_lossy(&output.stderr),
892                String::from_utf8_lossy(&output.stdout)
893            );
894            Ok(CommandOutput {
895                success: output.status.success(),
896                combined,
897            })
898        }
899        Err(_) => Ok(CommandOutput {
900            success: false,
901            combined: format!("timed out after {}s", timeout.as_secs()),
902        }),
903    }
904}
905
906/// First `count` lines of `text`, joined, for a compact trace.
907fn first_lines(text: &str, count: usize) -> String {
908    text.lines()
909        .filter(|line| !line.trim().is_empty())
910        .take(count)
911        .collect::<Vec<_>>()
912        .join(" | ")
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    #[test]
920    fn signature_splits_on_top_level_assign() {
921        assert_eq!(
922            statement_signature("theorem foo : 2 + 2 = 5 := by rfl"),
923            "theorem foo : 2 + 2 = 5"
924        );
925    }
926
927    #[test]
928    fn signature_ignores_assign_inside_binders() {
929        // The `:=` in the default argument must not end the signature.
930        assert_eq!(
931            statement_signature("def f (n : Nat := 0) : Nat := n"),
932            "def f (n : Nat := 0) : Nat"
933        );
934    }
935
936    #[test]
937    fn signature_handles_multiline_body() {
938        let source = "theorem bar : 1 + 1 = 2 := by\n  sorry";
939        assert_eq!(statement_signature(source), "theorem bar : 1 + 1 = 2");
940    }
941
942    #[test]
943    fn unchanged_body_keeps_statement() {
944        let original = "theorem bar : 1 + 1 = 2 := by\n  sorry\n";
945        let patched = "theorem bar : 1 + 1 = 2 := by\n  rfl\n";
946        let check = compare_statement(original, patched, 2);
947        assert!(matches!(check, StatementCheck::Unchanged { .. }));
948    }
949
950    #[test]
951    fn weakened_statement_is_flagged() {
952        let original = "theorem foo : 2 + 2 = 5 := by rfl\n";
953        let patched = "theorem foo : 2 + 2 = 4 := by rfl\n";
954        let check = compare_statement(original, patched, 1);
955        assert!(matches!(check, StatementCheck::Changed { .. }));
956        if let StatementCheck::Changed { before, after, .. } = check {
957            assert_eq!(before, "theorem foo : 2 + 2 = 5");
958            assert_eq!(after, "theorem foo : 2 + 2 = 4");
959        }
960    }
961
962    #[test]
963    fn missing_declaration_is_unverifiable() {
964        let check = compare_statement("-- just a comment\n", "-- just a comment\n", 1);
965        assert!(matches!(check, StatementCheck::Unverifiable { .. }));
966    }
967
968    #[test]
969    fn parses_no_axioms() {
970        let out = "'foo' does not depend on any axioms";
971        assert_eq!(parse_axioms(out, "foo"), Some(Vec::new()));
972    }
973
974    #[test]
975    fn parses_whitelisted_axioms() {
976        let out = "'usesClassical' depends on axioms: [propext, Classical.choice, Quot.sound]";
977        assert_eq!(
978            parse_axioms(out, "usesClassical"),
979            Some(vec![
980                "propext".to_owned(),
981                "Classical.choice".to_owned(),
982                "Quot.sound".to_owned(),
983            ])
984        );
985        let axioms = parse_axioms(out, "usesClassical").unwrap_or_default();
986        assert!(axioms.iter().all(|a| AXIOM_WHITELIST.contains(&a.as_str())));
987    }
988
989    #[test]
990    fn parses_sorry_axiom_as_offending() {
991        let out = "'baz' depends on axioms: [sorryAx]";
992        let axioms = parse_axioms(out, "baz").unwrap_or_default();
993        assert_eq!(axioms, vec!["sorryAx".to_owned()]);
994        assert!(
995            axioms
996                .iter()
997                .any(|a| !AXIOM_WHITELIST.contains(&a.as_str()))
998        );
999    }
1000
1001    #[test]
1002    fn parse_axioms_returns_none_for_other_decl() {
1003        let out = "'other' does not depend on any axioms";
1004        assert_eq!(parse_axioms(out, "foo"), None);
1005    }
1006
1007    #[test]
1008    fn sorry_message_is_detected() {
1009        assert!(message_mentions_sorry("declaration uses `sorry`"));
1010        assert!(!message_mentions_sorry("unsolved goals"));
1011    }
1012
1013    #[test]
1014    fn negative_control_is_a_documented_stub() {
1015        let status = check_negative_control(None);
1016        assert!(matches!(status, GuardStatus::Skipped { .. }));
1017        if let GuardStatus::Skipped { note } = status {
1018            assert!(note.contains("TODO(loop-phase)"));
1019        }
1020    }
1021
1022    #[test]
1023    fn probe_path_sits_next_to_the_target() {
1024        assert_eq!(
1025            probe_path(Utf8Path::new("Demo.lean")).as_str(),
1026            "Demo__lap_axioms.lean"
1027        );
1028        assert_eq!(
1029            probe_path(Utf8Path::new("src/Demo.lean")).as_str(),
1030            "src/Demo__lap_axioms.lean"
1031        );
1032    }
1033
1034    #[test]
1035    fn slice_between_reads_only_the_sentinel_window() {
1036        // A forged marker before BEGIN must not leak into the parsed window.
1037        let combined = "'foo' does not depend on any axioms\n\
1038             BEGIN\n'foo' depends on axioms: [evil]\nEND\ntrailing\n";
1039        let window = slice_between(combined, "BEGIN", "END").unwrap_or("");
1040        assert!(window.contains("[evil]"));
1041        assert!(!window.contains("does not depend"));
1042        assert_eq!(parse_axioms(window, "foo"), Some(vec!["evil".to_owned()]));
1043    }
1044
1045    #[test]
1046    fn slice_between_needs_both_sentinels_in_order() {
1047        assert_eq!(slice_between("BEGIN only", "BEGIN", "END"), None);
1048        assert_eq!(slice_between("END before BEGIN", "BEGIN", "END"), None);
1049        assert_eq!(slice_between("neither", "BEGIN", "END"), None);
1050    }
1051
1052    #[test]
1053    fn alias_rewrites_example_into_named_def() {
1054        assert_eq!(
1055            alias_anonymous_decl("example : 2 = 2 := by rfl", "example", "__p").unwrap_or_default(),
1056            "def __p : 2 = 2 := by rfl"
1057        );
1058    }
1059
1060    #[test]
1061    fn alias_keeps_binders_and_swaps_only_the_keyword() {
1062        assert_eq!(
1063            alias_anonymous_decl("example (n : Nat) : n = n := by\n  rfl", "example", "__p")
1064                .unwrap_or_default(),
1065            "def __p (n : Nat) : n = n := by\n  rfl"
1066        );
1067    }
1068
1069    #[test]
1070    fn alias_is_none_when_keyword_is_absent() {
1071        assert_eq!(
1072            alias_anonymous_decl("theorem t : True := trivial", "example", "__p"),
1073            None
1074        );
1075    }
1076
1077    type BoxResult = std::result::Result<(), Box<dyn std::error::Error>>;
1078
1079    #[test]
1080    fn module_for_target_falls_back_to_root_relative() -> BoxResult {
1081        // No lakefile in this temp dir, so the path maps relative to the root.
1082        let dir = tempfile::TempDir::new()?;
1083        let root = Utf8Path::from_path(dir.path()).ok_or("non-UTF-8 temp path")?;
1084        assert_eq!(
1085            module_for_target(root, Utf8Path::new("Demo/Basic.lean")),
1086            "Demo.Basic"
1087        );
1088        Ok(())
1089    }
1090
1091    #[test]
1092    fn lake_source_dirs_reads_srcdir_and_maps_module() -> BoxResult {
1093        let dir = tempfile::TempDir::new()?;
1094        let root = Utf8Path::from_path(dir.path()).ok_or("non-UTF-8 temp path")?;
1095        std::fs::write(
1096            root.join("lakefile.toml"),
1097            "name = \"demo\"\n\n[[lean_lib]]\nname = \"Demo\"\nsrcDir = \"src\"\n",
1098        )?;
1099        assert_eq!(lake_source_dirs(root), vec!["src".to_owned()]);
1100        assert_eq!(
1101            module_for_target(root, Utf8Path::new("src/Demo/Basic.lean")),
1102            "Demo.Basic"
1103        );
1104        Ok(())
1105    }
1106}