Skip to main content

lean_ctx/core/decisions/
check.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Evaluate decision `[[check]]`s against one file change.
3//!
4//! The check kinds are tree rules (docs/internal/decision-layer.md):
5//! `forbid_path`: no file may match `paths`; `forbid_regex`: no file matching
6//! `paths` may contain `pattern`; `require_regex`: at least one file matching
7//! `paths` must contain `pattern`. Weave (D4) judges a whole candidate tree.
8//! Here a check judges one file change and reports only what the change
9//! *introduces*:
10//!
11//! - `forbid_path`: the change creates a file under `paths`.
12//! - `forbid_regex`: the file holds more matches after than before.
13//! - `require_regex`: the file held a match, holds none after, and no other
14//!   file under `paths` holds one ([`OtherFiles`]).
15//!
16//! A violation already in the tree is not this change's, so the pre-edit hint
17//! and `lean-ctx decisions check` stay quiet on legacy code and the strict
18//! mode refuses only edits that make things worse.
19
20use std::path::Path;
21
22use super::{Check, CheckKind, Decision, DecisionSet};
23use crate::core::pre_edit::{PreEditFinding, PreEditInput, Severity};
24
25/// One violated check.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Violation {
28    pub decision_id: String,
29    pub title: String,
30    pub statement: String,
31    pub check_index: usize,
32    pub kind: CheckKind,
33    /// Root-relative path of the changed file.
34    pub path: String,
35    /// The check's message, else the decision's one-line statement.
36    pub message: String,
37    /// 1-based line of the offending text in the post-change content, when known.
38    pub line: Option<usize>,
39}
40
41impl Violation {
42    /// `dec-x (Title) at path:12 [forbid_regex]: message`
43    pub fn render(&self) -> String {
44        let at = self
45            .line
46            .map_or_else(|| self.path.clone(), |l| format!("{}:{l}", self.path));
47        format!(
48            "{} ({}) at {at} [{}]: {}",
49            self.decision_id,
50            self.title.trim(),
51            self.kind.as_str(),
52            self.message
53        )
54    }
55}
56
57/// Whether a file other than the changed one (the `&str`, root-relative)
58/// that the check covers contains its pattern: the `require_regex` "at least
59/// one file" rule. Answer `true` when that cannot be told cheaply; a missed
60/// hint is better than a false one.
61pub type OtherFiles<'a> = &'a dyn Fn(&Decision, &Check, &str) -> bool;
62
63/// Most files a working-tree scan reads before it gives up (answering `true`).
64const MAX_SCAN_FILES: usize = 5_000;
65const MAX_SCAN_BYTES: u64 = 1_000_000;
66
67/// [`OtherFiles`] over the working tree at `root` (gitignore respected).
68pub fn worktree_other_files(root: &Path, d: &Decision, check: &Check, exclude: &str) -> bool {
69    let Some(re) = check.regex() else {
70        return true;
71    };
72    let mut seen = 0usize;
73    for entry in ignore::WalkBuilder::new(root)
74        .hidden(false)
75        .build()
76        .flatten()
77    {
78        if !entry.file_type().is_some_and(|t| t.is_file()) {
79            continue;
80        }
81        let Some(rel) = super::relative_path(root, entry.path()) else {
82            continue;
83        };
84        if rel == exclude || rel.starts_with(".git/") || !check.covers(d, &rel) {
85            continue;
86        }
87        seen += 1;
88        if seen > MAX_SCAN_FILES || entry.metadata().is_ok_and(|m| m.len() > MAX_SCAN_BYTES) {
89            return true;
90        }
91        if std::fs::read_to_string(entry.path()).is_ok_and(|t| re.is_match(&t)) {
92            return true;
93        }
94    }
95    false
96}
97
98fn line_of(text: &str, offset: usize) -> usize {
99    text[..offset.min(text.len())].matches('\n').count() + 1
100}
101
102/// The first match in `after` whose text is not among `before`'s matches
103/// (by occurrence count), or the first match when that cannot be told apart.
104fn first_new_match(re: &regex::Regex, before: &str, after: &str) -> Option<usize> {
105    let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
106    for m in re.find_iter(before) {
107        *seen.entry(m.as_str()).or_default() += 1;
108    }
109    let mut first = None;
110    for m in re.find_iter(after) {
111        first.get_or_insert(m.start());
112        match seen.get_mut(m.as_str()) {
113            Some(n) if *n > 0 => *n -= 1,
114            _ => return Some(m.start()),
115        }
116    }
117    first
118}
119
120fn violation(d: &Decision, i: usize, rel_path: &str, line: Option<usize>) -> Violation {
121    let check = &d.checks[i];
122    Violation {
123        decision_id: d.id.clone(),
124        title: d.title.clone(),
125        statement: d.one_line(),
126        check_index: i,
127        kind: check.kind,
128        path: rel_path.to_string(),
129        message: check.message.clone().unwrap_or_else(|| d.one_line()),
130        line,
131    }
132}
133
134/// Violations a change of `rel_path` from `before` to `after` introduces,
135/// over every active decision in `set`. `before = None`: the file is new;
136/// `after = None`: the file is deleted (a deletion violates nothing).
137/// `require_regex` looks for other files in the set's working tree.
138pub fn evaluate_change(
139    set: &DecisionSet,
140    rel_path: &str,
141    before: Option<&str>,
142    after: Option<&str>,
143) -> Vec<Violation> {
144    let other = |d: &Decision, c: &Check, exclude: &str| {
145        set.root
146            .as_deref()
147            .is_none_or(|root| worktree_other_files(root, d, c, exclude))
148    };
149    evaluate_change_with(set, rel_path, before, after, &other)
150}
151
152/// [`evaluate_change`] with an explicit [`OtherFiles`] (e.g. over a git tree).
153pub fn evaluate_change_with(
154    set: &DecisionSet,
155    rel_path: &str,
156    before: Option<&str>,
157    after: Option<&str>,
158    other_files: OtherFiles<'_>,
159) -> Vec<Violation> {
160    let Some(after) = after else {
161        return Vec::new();
162    };
163    if before == Some(after) {
164        return Vec::new();
165    }
166    let mut out = Vec::new();
167    for d in set.active().filter(|d| set.repo_matches(d)) {
168        for (i, check) in d.checks.iter().enumerate() {
169            if !check.covers(d, rel_path) {
170                continue;
171            }
172            match check.kind {
173                CheckKind::ForbidPath => {
174                    if before.is_none() {
175                        out.push(violation(d, i, rel_path, None));
176                    }
177                }
178                CheckKind::ForbidRegex => {
179                    let Some(re) = check.regex() else { continue };
180                    let before_count = before.map_or(0, |b| re.find_iter(b).count());
181                    if re.find_iter(after).count() > before_count {
182                        let line = first_new_match(re, before.unwrap_or_default(), after)
183                            .map(|off| line_of(after, off));
184                        out.push(violation(d, i, rel_path, line));
185                    }
186                }
187                CheckKind::RequireRegex => {
188                    let Some(re) = check.regex() else { continue };
189                    if !re.is_match(after)
190                        && before.is_some_and(|b| re.is_match(b))
191                        && !other_files(d, check, rel_path)
192                    {
193                        out.push(violation(d, i, rel_path, None));
194                    }
195                }
196            }
197        }
198    }
199    out
200}
201
202/// Findings for one pre-edit check of `set` under `enforce` (`hint` |
203/// `refuse` | `off`). Introduced violations become hints (refusals when
204/// strict). Path- or symbol-scoped decisions covering the file without a
205/// violation become one [`Severity::Note`] (shown by LeanCTX's edit tools,
206/// not by native hooks); repo-wide ones are left to the read suffix.
207pub fn findings_for(
208    set: &DecisionSet,
209    rel_path: &str,
210    input: &PreEditInput<'_>,
211    enforce: &str,
212) -> Vec<PreEditFinding> {
213    if enforce == "off" {
214        return Vec::new();
215    }
216    let severity = if enforce == "refuse" {
217        Severity::Refuse
218    } else {
219        Severity::Hint
220    };
221    let violations = evaluate_change(set, rel_path, input.before, Some(input.after));
222    let mut out: Vec<PreEditFinding> = violations
223        .iter()
224        .map(|v| PreEditFinding {
225            source: "decision",
226            id: v.decision_id.clone(),
227            severity,
228            text: format!("{} — decision: {}", v.render(), v.statement),
229        })
230        .collect();
231    let scoped: Vec<&str> = set
232        .for_path(rel_path)
233        .into_iter()
234        .filter(|d| !d.is_repo_wide() && !violations.iter().any(|v| v.decision_id == d.id))
235        .map(|d| d.id.as_str())
236        .collect();
237    if !scoped.is_empty() {
238        out.push(PreEditFinding {
239            source: "decision",
240            id: scoped.join(","),
241            severity: Severity::Note,
242            text: format!(
243                "decisions in scope of {rel_path}: {} (ctx_knowledge action=decisions path={rel_path})",
244                scoped.join(", ")
245            ),
246        });
247    }
248    out
249}
250
251/// The decisions checker registered in [`crate::core::pre_edit`]: finds the
252/// project by its `.leanctx/decisions` directory and reads `[decisions]
253/// enforce` from the config. Cheap (one directory probe per ancestor) when a
254/// project has no decisions.
255pub fn pre_edit_findings(input: &PreEditInput<'_>) -> Vec<PreEditFinding> {
256    let Some(root) = super::find_root(input.path) else {
257        return Vec::new();
258    };
259    let Some(rel) = super::relative_path(&root, input.path) else {
260        return Vec::new();
261    };
262    let set = super::load(&root);
263    if set.decisions.is_empty() {
264        return Vec::new();
265    }
266    let cfg = crate::core::config::Config::load();
267    findings_for(&set, &rel, input, cfg.decisions.enforce_mode())
268}