Skip to main content

weave_cli/
semcheck.rs

1//! `weave land --check sem`: the merged tree is verified by `sem check`
2//! before anything is published.
3//!
4//! `sem check --base <tip> --json` runs in the directory holding the merged
5//! tree (the agent's repository with `--onto`, the lander's worktree with
6//! `--queue`). It returns the verdict of the project's own compiler, type
7//! checker, linter and tests on the whole project, rechecking incrementally
8//! only where that is provably the same verdict. Exit 0 lands; exit 1 (fail),
9//! exit 2 (could not decide), any other exit, unreadable output, or no `sem`
10//! at all refuses: the check is never skipped once asked for.
11//!
12//! Turned on by `--check sem`, or by default with `.weave/config`
13//! (git-config syntax) in the repository root:
14//!
15//! ```text
16//! [land]
17//!     check = sem
18//!     checkers = ts,lint,tests   # optional: passed as --checkers
19//! ```
20//!
21//! `--check none` turns a configured default off for one run.
22
23use std::path::{Path, PathBuf};
24use std::process::Command;
25use std::time::Duration;
26
27use serde_json::{json, Value};
28
29use crate::gitscan;
30
31type R<T> = Result<T, Box<dyn std::error::Error>>;
32
33/// What `--check sem` runs.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Check {
36    /// `--checkers` for sem check (None: its own default, every checker it detects).
37    pub checkers: Option<String>,
38}
39
40/// `--check` (`sem` | `none`) over the repository default in `.weave/config`.
41pub fn resolve(flag: Option<&str>, checkers: Option<&str>, repo: &Path) -> R<Option<Check>> {
42    let (configured, cfg_checkers) = config_default(repo);
43    let want = match flag {
44        Some("sem") => true,
45        Some("none") => false,
46        Some(other) => return Err(format!("--check wants `sem` or `none`, not `{other}`").into()),
47        None => match configured.as_deref() {
48            Some("sem") => true,
49            None | Some("none") | Some("") => false,
50            Some(other) => {
51                return Err(format!(
52                    ".weave/config: land.check wants `sem` or `none`, not `{other}`"
53                )
54                .into())
55            }
56        },
57    };
58    Ok(want.then(|| Check {
59        checkers: checkers.map(str::to_string).or(cfg_checkers),
60    }))
61}
62
63/// `land.check` and `land.checkers` from `<repo>/.weave/config`.
64pub fn config_default(repo: &Path) -> (Option<String>, Option<String>) {
65    let top = gitscan::git(repo, &["rev-parse", "--show-toplevel"])
66        .map(|s| PathBuf::from(s.trim()))
67        .unwrap_or_else(|_| repo.to_path_buf());
68    let file = top.join(".weave/config");
69    if !file.exists() {
70        return (None, None);
71    }
72    let get = |key: &str| {
73        Command::new("git")
74            .arg("config")
75            .arg("--file")
76            .arg(&file)
77            .arg("--get")
78            .arg(key)
79            .output()
80            .ok()
81            .filter(|o| o.status.success())
82            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
83            .filter(|s| !s.is_empty())
84    };
85    (get("land.check"), get("land.checkers"))
86}
87
88/// The `sem` binary: `$WEAVE_SEM`, else `sem` on PATH.
89fn sem_binary() -> Option<PathBuf> {
90    if let Some(p) = std::env::var_os("WEAVE_SEM").filter(|p| !p.is_empty()) {
91        let p = PathBuf::from(p);
92        return p.is_file().then_some(p);
93    }
94    let path = std::env::var_os("PATH")?;
95    std::env::split_paths(&path)
96        .map(|d| d.join("sem"))
97        .find(|p| p.is_file())
98}
99
100/// How a check ended.
101#[derive(Debug, Clone)]
102pub enum Outcome {
103    /// Exit 0: the sem check document (verdict, checkers, certificate).
104    Pass(Value),
105    /// Not landable: short machine reason, the report for the agent, and the
106    /// sem check document when there is one.
107    Refused {
108        reason: &'static str,
109        report: String,
110        doc: Option<Value>,
111    },
112}
113
114/// Run `sem check --base <tip> --json` in `dir`, whose HEAD has tree `tree`
115/// (the tree to be published). A pass counts only if sem checked exactly that
116/// tree: untracked files in `dir` would make its verdict one about another tree.
117pub fn run(
118    dir: &Path,
119    tip: &str,
120    tree: &str,
121    check: &Check,
122    limit: Duration,
123    env: &[(&str, &str)],
124) -> R<Outcome> {
125    let Some(sem) = sem_binary() else {
126        return Ok(Outcome::Refused {
127            reason: "sem not installed",
128            report:
129                "land: --check sem was asked for (or .weave/config sets land.check = sem), but \
130                     `sem` is not installed: it is not on PATH and WEAVE_SEM does not name it. \
131                     The merged tree was not verified, so nothing was published. Install sem, \
132                     or pass --check none to land without it."
133                    .to_string(),
134            doc: None,
135        });
136    };
137    let mut cmd = Command::new(&sem);
138    cmd.args(["check", "--base", tip, "--json"])
139        .current_dir(dir);
140    if let Some(c) = &check.checkers {
141        cmd.args(["--checkers", c]);
142    }
143    cmd.arg("--timeout").arg(limit.as_secs().max(1).to_string());
144    for (k, v) in env {
145        cmd.env(k, v);
146    }
147    // sem's own per-tool limit is `limit`; give the whole run a little more
148    let run = gitscan::run_bounded(cmd, "sem check", None, limit + Duration::from_secs(60));
149    let (status, stdout, stderr) = match run {
150        Ok(r) => r,
151        Err(e) => {
152            return Ok(Outcome::Refused {
153                reason: "sem check could not decide",
154                report: format!("land: sem check did not finish: {e}"),
155                doc: None,
156            })
157        }
158    };
159    let doc: Option<Value> = serde_json::from_slice(&stdout).ok();
160    let code = status.code();
161    let Some(doc) = doc else {
162        let tail = |b: &[u8]| {
163            let t = String::from_utf8_lossy(b);
164            let lines: Vec<&str> = t.lines().collect();
165            lines[lines.len().saturating_sub(30)..].join("\n")
166        };
167        return Ok(Outcome::Refused {
168            reason: "sem check could not decide",
169            report: format!(
170                "land: sem check exited {} without a readable result:\n{}\n{}",
171                code.map_or("on a signal".into(), |c| c.to_string()),
172                tail(&stdout),
173                tail(&stderr)
174            ),
175            doc: None,
176        });
177    };
178    match code {
179        Some(0) if doc["verdict"] == "pass" && doc["head"]["tree"].as_str() != Some(tree) => {
180            let untracked = gitscan::git(dir, &["ls-files", "--others", "--exclude-standard"])
181                .unwrap_or_default();
182            Ok(Outcome::Refused {
183                reason: "sem check checked another tree",
184                report: format!(
185                    "land: sem check passed, but on a tree that is not the one to be published \
186                     (checked {}, publishing {tree}): untracked files in the working tree are \
187                     part of what the tools saw:\n{}\nRemove them, ignore them (.gitignore), or \
188                     commit them, then run land again.",
189                    doc["head"]["tree"].as_str().unwrap_or("?"),
190                    untracked
191                        .lines()
192                        .map(|l| format!("  {l}"))
193                        .collect::<Vec<_>>()
194                        .join("\n")
195                ),
196                doc: Some(doc),
197            })
198        }
199        Some(0) if doc["verdict"] == "pass" => Ok(Outcome::Pass(doc)),
200        Some(1) => Ok(Outcome::Refused {
201            reason: "sem check failed",
202            report: render(&doc, "FAILED"),
203            doc: Some(doc),
204        }),
205        _ => Ok(Outcome::Refused {
206            reason: "sem check could not decide",
207            report: render(&doc, "COULD NOT DECIDE"),
208            doc: Some(doc),
209        }),
210    }
211}
212
213/// The sem check result in words for the agent: every checker's verdict,
214/// mode and reasons, then its diagnostics (the first 100 per checker).
215pub fn render(doc: &Value, headline: &str) -> String {
216    let s = |v: &Value| v.as_str().unwrap_or("").to_string();
217    let mut out = vec![format!("land: sem check {headline} on the merged tree:")];
218    if let Some(e) = doc.get("error").and_then(Value::as_str) {
219        out.push(format!("  {e}"));
220    }
221    for n in doc["notes"].as_array().into_iter().flatten() {
222        out.push(format!("  note: {}", s(n)));
223    }
224    for c in doc["checkers"].as_array().into_iter().flatten() {
225        out.push(format!(
226            "  {} {} ({}, {}, {} rechecked)",
227            s(&c["name"]),
228            s(&c["verdict"]).to_uppercase(),
229            s(&c["tool"]),
230            s(&c["mode"]),
231            c["filesRecheckedCount"]
232        ));
233        for r in c["reasons"].as_array().into_iter().flatten() {
234            out.push(format!("    because: {}", s(r)));
235        }
236        let diags = c["diagnostics"].as_array().cloned().unwrap_or_default();
237        for d in diags.iter().take(100) {
238            for (i, line) in s(d).lines().enumerate() {
239                out.push(format!("{}{line}", if i == 0 { "    " } else { "      " }));
240            }
241        }
242        if diags.len() > 100 {
243            out.push(format!("    ... {} more", diags.len() - 100));
244        }
245    }
246    out.join("\n")
247}
248
249/// One line per checker for a passing check.
250pub fn summary(doc: &Value) -> String {
251    let parts: Vec<String> = doc["checkers"]
252        .as_array()
253        .into_iter()
254        .flatten()
255        .map(|c| {
256            format!(
257                "{} {} {} ({} rechecked)",
258                c["name"].as_str().unwrap_or("?"),
259                c["verdict"].as_str().unwrap_or("?"),
260                c["mode"].as_str().unwrap_or("?"),
261                c["filesRecheckedCount"]
262            )
263        })
264        .collect();
265    parts.join("; ")
266}
267
268/// The document weave records for a check: what was checked and sem check's
269/// own certificate (input trees, tool versions, mode per checker).
270pub fn certificate(tip: &str, head: &str, tree: &str, outcome: &Outcome) -> Value {
271    let (verdict, reason, doc) = match outcome {
272        Outcome::Pass(d) => ("pass", None, Some(d)),
273        Outcome::Refused { reason, doc, .. } => ("refused", Some(*reason), doc.as_ref()),
274    };
275    json!({
276        "kind": "sem-check",
277        "onto": tip,
278        "head": head,
279        "tree": tree,
280        "verdict": verdict,
281        "reason": reason,
282        "sem": doc.map(|d| json!({
283            "verdict": d["verdict"],
284            "exitCode": d["exitCode"],
285            "checkers": d["checkers"].as_array().map(|a| a.iter().map(|c| json!({
286                "name": c["name"], "tool": c["tool"], "toolVersion": c["toolVersion"],
287                "verdict": c["verdict"], "mode": c["mode"], "reasons": c["reasons"],
288                "filesRechecked": c["filesRechecked"], "errors": c["errors"],
289                "diagnostics": c["diagnostics"],
290            })).collect::<Vec<_>>()),
291            "certificate": d["certificate"],
292        })),
293    })
294}