Skip to main content

lean_ctx/core/
pre_edit.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The one pre-edit check hook.
3//!
4//! Every edit path — `ctx_patch`, `ctx_edit` and the native Write/Edit
5//! PreToolUse hook (`lean-ctx hook write-guard`) — hands the post-edit content
6//! here once, before anything is written. Each registered checker returns
7//! findings; the caller refuses the edit when one is [`Severity::Refuse`] and
8//! otherwise appends [`render`]'s hint to its reply. A new check (decisions,
9//! claims, …) is one entry in `CHECKERS`; no edit path changes. The MCP
10//! tools reach it through `lean-ctx-server`'s `tools::claims_gate`.
11//!
12//! Output is deterministic (#498): findings are sorted, the header is fixed and
13//! no checker may put a timestamp or counter into its text.
14
15use std::path::Path;
16
17/// What an edit is about to do. `before = None`: the file is new.
18#[derive(Debug, Clone, Copy)]
19pub struct PreEditInput<'a> {
20    /// The file the edit writes (absolute, or relative to the process cwd).
21    pub path: &'a Path,
22    pub before: Option<&'a str>,
23    pub after: &'a str,
24    /// Project root of the editing session, when the caller knows it
25    /// (MCP tools); checkers that need none find their own root.
26    pub project_root: Option<&'a str>,
27    /// The editing agent, when known (claims are held per agent).
28    pub agent: Option<&'a str>,
29}
30
31impl<'a> PreEditInput<'a> {
32    /// An input without session context (native hooks, CLI).
33    pub fn new(path: &'a Path, before: Option<&'a str>, after: &'a str) -> Self {
34        Self {
35            path,
36            before,
37            after,
38            project_root: None,
39            agent: None,
40        }
41    }
42}
43
44/// How a finding affects the edit.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
46pub enum Severity {
47    /// Context only (e.g. "decisions in scope"); never shown by native hooks.
48    Note,
49    /// A problem the edit introduces; reported, the edit proceeds.
50    Hint,
51    /// A problem a strict configuration refuses; the edit must not be written.
52    Refuse,
53}
54
55/// One checker's finding.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct PreEditFinding {
58    /// Checker family, e.g. `decision`, `claim`.
59    pub source: &'static str,
60    /// Stable id of what was violated (decision id, claim id, …).
61    pub id: String,
62    pub severity: Severity,
63    /// One line, self-contained.
64    pub text: String,
65}
66
67/// A checker. Must be cheap when the project has nothing to check and must
68/// never panic. Only the claims checker writes (it claims the units an
69/// edit it does not refuse touches).
70pub type Checker = fn(&PreEditInput<'_>) -> Vec<PreEditFinding>;
71
72/// Registered checkers, in report order.
73const CHECKERS: &[Checker] = &[
74    claims_findings,
75    crate::core::decisions::check::pre_edit_findings,
76];
77
78/// Unit claims ([`crate::core::unit_claims`]): another agent's claim on a
79/// touched unit. Needs the session's project root and agent.
80fn claims_findings(input: &PreEditInput<'_>) -> Vec<PreEditFinding> {
81    use crate::core::unit_claims::{self, Gate};
82    let (Some(root), Some(agent)) = (input.project_root, input.agent) else {
83        return Vec::new();
84    };
85    let gate = unit_claims::gate_with(
86        unit_claims::mode(),
87        root,
88        agent,
89        input.path,
90        input.before.unwrap_or_default(),
91        input.after,
92    );
93    let (severity, text) = match gate {
94        Gate::Clear => return Vec::new(),
95        Gate::Warn(text) => (Severity::Hint, text),
96        Gate::Refuse(text) => (Severity::Refuse, text),
97    };
98    vec![PreEditFinding {
99        source: "claim",
100        id: input.path.to_string_lossy().into_owned(),
101        severity,
102        text: text.trim().trim_start_matches("ERROR:").trim().to_string(),
103    }]
104}
105
106/// Run every registered checker.
107pub fn evaluate(input: &PreEditInput<'_>) -> Vec<PreEditFinding> {
108    evaluate_with(input, CHECKERS)
109}
110
111/// [`evaluate`] over an explicit checker list (tests, embedders).
112pub fn evaluate_with(input: &PreEditInput<'_>, checkers: &[Checker]) -> Vec<PreEditFinding> {
113    let mut findings: Vec<PreEditFinding> = checkers.iter().flat_map(|c| c(input)).collect();
114    findings.sort_by(|a, b| {
115        (b.severity, a.source, &a.id, &a.text).cmp(&(a.severity, b.source, &b.id, &b.text))
116    });
117    findings.dedup();
118    findings
119}
120
121/// Header of every rendered hint. Stable: prompt caches key on it.
122pub const HINT_HEADER: &str = "[PRE-EDIT]";
123
124/// The refusal text when any finding refuses the edit.
125pub fn refusal(findings: &[PreEditFinding]) -> Option<String> {
126    let refused: Vec<&PreEditFinding> = findings
127        .iter()
128        .filter(|f| f.severity == Severity::Refuse)
129        .collect();
130    if refused.is_empty() {
131        return None;
132    }
133    let mut out = String::from(
134        "ERROR: pre-edit check refused this edit (nothing was written). \
135         Change the edit (or settle the claim / supersede the decision):",
136    );
137    for f in refused {
138        out.push_str("\n- ");
139        out.push_str(&f.text);
140    }
141    Some(out)
142}
143
144/// The hint for findings at or above `min`; `None` when there is nothing.
145pub fn render(findings: &[PreEditFinding], min: Severity) -> Option<String> {
146    let shown: Vec<&PreEditFinding> = findings.iter().filter(|f| f.severity >= min).collect();
147    if shown.is_empty() {
148        return None;
149    }
150    let mut out = String::from(HINT_HEADER);
151    for f in shown {
152        out.push_str("\n- ");
153        out.push_str(&f.text);
154    }
155    Some(out)
156}