Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

tests/mcp_release_doctor_blast.rs

//! End to end: the MCP tool `release_doctor` with `semantic_blast: true`.
//!
//! ```text
//! two repos on disk (web calls shop), consumed by the REAL nightly driver
//!   → `nornir-server mcp` over stdio (embedded, its own code room)
//!     → release_doctor { semantic_blast: true, json: true }
//!       → the dirty tree of `shop` is dumped by the driver (nothing written
//!         to the lake), diffed against what was consumed, and the sites in
//!         `web` come back in the report
//! ```
//!
//! Until this file the tool compiled with the argument and nothing called it.
//! The driver is built from `crates/mimir-driver` (own workspace, pinned
//! nightly) on first use, as `crates/mimir-ingest/tests/ingest/real.rs` does.
#![cfg(all(feature = "mcp", feature = "server"))]

use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};

use nornir::code_room::CodeRoom;
use nornir::code_room::consumer_loop::{DriverDumpers, Member, MemberStatus, Request};
use nornir::code_room::driver::Settings;
use serde_json::{Value, json};

fn driver_dir() -> PathBuf {
    PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/crates/mimir-driver"))
}

/// Build mimir-driver once (its own workspace, with the outer `cargo test`'s
/// environment scrubbed so its `rust-toolchain.toml` picks the nightly).
fn build_driver() -> PathBuf {
    let mut c = Command::new("cargo");
    for (k, _) in std::env::vars() {
        if (k.starts_with("CARGO_") && k != "CARGO_HOME") || matches!(k.as_str(), "RUSTFLAGS" | "RUSTC_WRAPPER" | "RUSTC_WORKSPACE_WRAPPER" | "RUSTUP_TOOLCHAIN" | "RUSTC") {
            c.env_remove(k);
        }
    }
    let st = c.current_dir(driver_dir()).args(["build", "--release", "--bins"]).status().expect("run cargo for mimir-driver");
    assert!(st.success(), "building mimir-driver failed");
    driver_dir().join("target/release")
}

fn write(path: &Path, text: &str) {
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::write(path, text).unwrap();
}

const SHOP_LIB: &str = "pub fn checkout(cart: u32) -> u32 {\n    cart\n}\n\npub fn refund() {}\n";
// `shop::checkout` is called on line 2, `shop::refund` on line 6
const WEB_LIB: &str = "pub fn render() -> u32 {\n    shop::checkout(1)\n}\n\npub fn undo() {\n    shop::refund()\n}\n";

/// `shop` and `web` (which depends on `shop` by path) as two git repos under
/// `root`, each with one commit. The lock files are ignored so the dry run's
/// `cargo check` does not dirty a tree by itself.
fn two_repos(root: &Path) -> (PathBuf, PathBuf) {
    let (shop, web) = (root.join("shop"), root.join("web"));
    write(&shop.join("Cargo.toml"), "[package]\nname = \"shop\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
    write(&shop.join("src/lib.rs"), SHOP_LIB);
    write(
        &web.join("Cargo.toml"),
        "[package]\nname = \"web\"\nversion = \"0.2.0\"\nedition = \"2021\"\n\n[dependencies]\nshop = { path = \"../shop\", version = \"0.1.0\" }\n",
    );
    write(&web.join("src/lib.rs"), WEB_LIB);
    for repo in [&shop, &web] {
        write(&repo.join(".gitignore"), "Cargo.lock\ntarget/\n");
        nornir::gitio::init(repo).unwrap();
        nornir::gitio::commit_all(repo, "seed").unwrap();
    }
    (shop, web)
}

fn member(name: &str, source: &Path) -> Member {
    Member { name: name.to_string(), source: source.to_path_buf(), branch: None, head: nornir::gitio::head_sha(source).unwrap() }
}

/// The MCP child over stdio, embedded (no `NORNIR_SERVER`).
struct Mcp {
    child: Child,
    stdin: ChildStdin,
    out: BufReader<ChildStdout>,
    next_id: i64,
}

impl Mcp {
    fn spawn(root: &Path, config: &Path, driver: &Path) -> Self {
        // the child's HOME is the scratch root (its warehouse and code room
        // are home-derived); rustup and cargo keep the real ones, or the
        // driver's `cargo +nightly` would find no toolchain
        let home = PathBuf::from(std::env::var_os("HOME").expect("HOME"));
        let rustup_home = std::env::var_os("RUSTUP_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".rustup"));
        let cargo_home = std::env::var_os("CARGO_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".cargo"));
        let mut cmd = Command::new(env!("CARGO_BIN_EXE_nornir-server"));
        cmd.arg("mcp")
            .env("NORNIR_CONFIG", config)
            .env("HOME", root)
            .env("RUSTUP_HOME", rustup_home)
            .env("CARGO_HOME", cargo_home)
            .env("NORNIR_MIMIR_DRIVER", driver)
            .env("NORNIR_MCP_NO_TELEMETRY", "1")
            .env_remove("NORNIR_SERVER")
            .env_remove("NORNIR_SERVER_TOKEN")
            .env_remove("NORNIR_WORKSPACE")
            .env_remove("NORNIR_MIMIR_DRIVER_HOST")
            .env_remove("NORNIR_MIMIR_TOOLCHAIN");
        // the outer `cargo test` must not steer the driver's cargo
        for (k, _) in std::env::vars() {
            if (k.starts_with("CARGO_") && k != "CARGO_HOME") || matches!(k.as_str(), "RUSTFLAGS" | "RUSTC_WRAPPER" | "RUSTC_WORKSPACE_WRAPPER" | "RUSTUP_TOOLCHAIN" | "RUSTC") {
                cmd.env_remove(k);
            }
        }
        let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn().expect("spawn nornir-server mcp");
        let stdin = child.stdin.take().unwrap();
        let out = BufReader::new(child.stdout.take().unwrap());
        let mut m = Mcp { child, stdin, out, next_id: 1 };
        let init = m.request("initialize", json!({"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "mcp_release_doctor_blast", "version": "1"}}));
        assert!(init["result"].is_object(), "initialize failed: {init}");
        writeln!(m.stdin, "{}", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).unwrap();
        m.stdin.flush().unwrap();
        m
    }

    fn request(&mut self, method: &str, params: Value) -> Value {
        let id = self.next_id;
        self.next_id += 1;
        writeln!(self.stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
        self.stdin.flush().unwrap();
        let mut line = String::new();
        loop {
            line.clear();
            assert!(self.out.read_line(&mut line).unwrap() > 0, "the MCP child closed its stdout before answering id={id}");
            if let Ok(v) = serde_json::from_str::<Value>(line.trim()) {
                if v["id"].as_i64() == Some(id) {
                    return v;
                }
            }
        }
    }

    /// `release_doctor` with the semantic blast: `Ok(report)` or `Err(what the tool said)`.
    fn doctor(&mut self) -> Result<Value, String> {
        let resp = self.request("tools/call", json!({"name": "release_doctor", "arguments": {"semantic_blast": true, "json": true}}));
        if let Some(e) = resp.get("error") {
            return Err(e["message"].as_str().unwrap_or_default().to_string());
        }
        let text: String = resp["result"]["content"].as_array().map(|a| a.iter().filter_map(|c| c["text"].as_str()).collect()).unwrap_or_default();
        if resp["result"]["isError"].as_bool().unwrap_or(false) {
            return Err(text);
        }
        Ok(serde_json::from_str(&text).unwrap_or_else(|e| panic!("the report is not JSON ({e}): {text}")))
    }
}

impl Drop for Mcp {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

/// The call sites of one impact as `(crate, caller, kind, file, line)`.
fn sites(impact: &Value) -> Vec<(String, String, String, String, u64)> {
    let s = |v: &Value| v.as_str().unwrap_or_default().to_string();
    impact["call_sites"].as_array().unwrap().iter().map(|c| (s(&c["crate_name"]), s(&c["caller"]), s(&c["call_kind"]), s(&c["file"]), c["line"].as_u64().unwrap())).collect()
}

#[test]
fn release_doctor_reports_what_a_dirty_tree_breaks_in_another_repo_and_refuses_a_tree_that_does_not_compile() {
    let bin = build_driver();
    let driver = bin.join("cargo-mimir-dump");
    let tmp = tempfile::tempdir().unwrap();
    // (a canonical root: the child resolves its paths from it)
    let root = tmp.path().canonicalize().unwrap();
    let (shop, web) = two_repos(&root);
    let config = root.join("_cfg/_rel/nornir.toml");
    write(&config, "[storage]\nkind = \"local\"\n\n[repo.shop]\n\n[repo.web]\n");

    // ---- consume both repos into the lake the child will open: the embedded
    // MCP's code room lives under `<HOME>/.nornir/workspaces/default/builds`
    let builds = root.join(".nornir/workspaces/default/builds");
    {
        let ready = Settings { driver: Some(driver.clone()), host: None, toolchain: None, search: vec![] }.probe().unwrap_or_else(|r| panic!("the built driver must probe green: {r}"));
        let room = CodeRoom::new("ws", &builds);
        let members = [member("shop", &shop), member("web", &web)];
        let report = room.consume(&DriverDumpers(ready), &[], &Request { members: &members, ..Default::default() }, &mut |_| {}).unwrap_or_else(|r| panic!("consume refused: {r}"));
        let got: Vec<_> = report.members.iter().map(|m| (m.member.as_str(), m.status, m.error.clone())).collect();
        assert_eq!(got, vec![("shop", MemberStatus::CaughtUp, None), ("web", MemberStatus::CaughtUp, None)]);
        // the lake is single-process: let go before the child opens it
        room.close();
    }

    let mut mcp = Mcp::spawn(&root, &config, &driver);

    // ---- clean trees: nothing pending, nothing asked of the driver
    let report = mcp.doctor().unwrap_or_else(|e| panic!("clean trees must not refuse: {e}"));
    assert!(report["semantic_blast"].as_object().unwrap().is_empty(), "{}", report["semantic_blast"]);
    assert!(report["dirty"].as_array().unwrap().iter().all(|d| d["dirty"] == false), "{}", report["dirty"]);

    // ---- an uncommitted change of a signature `web` calls: a CHANGED symbol
    // with web's site, by file and line
    write(&shop.join("src/lib.rs"), &SHOP_LIB.replace("cart: u32) -> u32", "cart: u64) -> u64"));
    let report = mcp.doctor().unwrap_or_else(|e| panic!("a tree that compiles must not refuse: {e}"));
    let blast = &report["semantic_blast"]["shop"];
    assert_eq!((blast["source"].as_str(), blast["target_repo"].as_str()), (Some("code_room"), Some("shop")), "{blast}");
    assert_eq!(blast["affected_crates"], json!(["web"]), "{blast}");
    let impacts = blast["impacts"].as_array().unwrap();
    assert_eq!(impacts.len(), 1, "only `checkout` changed: {blast}");
    assert_eq!((impacts[0]["symbol"].as_str(), impacts[0]["change"].as_str()), (Some("shop::checkout"), Some("changed")), "{blast}");
    assert_eq!(sites(&impacts[0]), vec![("web".to_string(), "web::render".to_string(), "CALLS".to_string(), "src/lib.rs".to_string(), 2)], "{blast}");
    assert_eq!(blast["total_call_sites"], 1, "{blast}");

    // ---- `refund` deleted as well: a REMOVED symbol that `web::undo` still
    // calls (what the link gate fails on), next to the changed one
    write(&shop.join("src/lib.rs"), &SHOP_LIB.replace("cart: u32) -> u32", "cart: u64) -> u64").replace("\npub fn refund() {}\n", ""));
    let report = mcp.doctor().unwrap_or_else(|e| panic!("a tree that compiles must not refuse: {e}"));
    let impacts = report["semantic_blast"]["shop"]["impacts"].as_array().unwrap().clone();
    let by_symbol = |name: &str| impacts.iter().find(|i| i["symbol"] == name).unwrap_or_else(|| panic!("no impact for {name}: {impacts:?}"));
    assert_eq!(impacts.len(), 2, "{impacts:?}");
    assert_eq!(by_symbol("shop::refund")["change"], "removed");
    assert_eq!(sites(by_symbol("shop::refund")), vec![("web".to_string(), "web::undo".to_string(), "CALLS".to_string(), "src/lib.rs".to_string(), 6)]);
    assert_eq!(by_symbol("shop::checkout")["change"], "changed");

    // ---- a tree that does not compile: no report, the reason by name
    write(&shop.join("src/lib.rs"), "pub fn checkout(cart: u32) -> u32 {\n    cart +\n}\n");
    let said = mcp.doctor().expect_err("a tree that does not compile must fail closed");
    assert!(said.contains("`shop`") && said.contains("does not compile") && said.contains("fail closed"), "{said}");

    // ---- back to the committed text: still a dirty tree? no — nothing pending again
    write(&shop.join("src/lib.rs"), SHOP_LIB);
    let report = mcp.doctor().unwrap_or_else(|e| panic!("the restored tree must not refuse: {e}"));
    assert!(report["semantic_blast"].as_object().unwrap().is_empty(), "{}", report["semantic_blast"]);
}