noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
//! Independent WO-53 round-1 verification.
//!
//! These tests come from the context-budgeting contract: the public CLI and
//! MCP surfaces must return one bounded guide, and the generated documentation
//! index must be a bijection over bounded, scoped chunks that the website copy
//! step preserves byte-for-byte.

use std::collections::BTreeSet;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};

const GUIDE_BUDGET: usize = 6 * 1024;
const CHUNK_BUDGET: u64 = 24 * 1024;
const CONTEXT_PREAMBLE: &str = "Context first: call query_project { operation: \"context\" } with a byte budget and read only the surface it points you to; reach for this guide's detail after that, not before.";
// Round 2 reconciled WO-53 with main, which had added the `interop` and
// `motion` topics and grown `core` past the 6 KB cap (split into `core` and
// `types`). The contract this test encodes is "usage enumerates the complete
// guide topic set, and every advertised topic is budgeted and context-first",
// so the list is the complete advertised set, not the round-1 subset. Merging
// WO-53 into main added `observability`: main's tracing growth pushed the
// merged `routing` topic past the 6 KB cap, and `[server] tracing` split off
// under the same rule. Merging WO-50 added `scaffolding`: the app template's
// starting shape is 2.4 KB of guidance that does not fit the `core` budget.
const TOPICS: [(&str, &str); 14] = [
    ("core", "Noxid agent quick reference"),
    ("scaffolding", "Noxid scaffolding"),
    ("interop", "Noxid JavaScript interop"),
    ("types", "Noxid types and data vocabulary"),
    ("routing", "Noxid routing"),
    ("storage", "Noxid storage"),
    ("realtime", "Noxid realtime"),
    ("observability", "Noxid observability"),
    ("reactivity", "Noxid reactivity"),
    ("motion", "Noxid motion"),
    ("remote-actions", "Noxid typed remote actions"),
    ("ssr", "Noxid rendering"),
    ("testing", "Noxid testing"),
    ("semantic-tools", "Noxid semantic tools"),
];

static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);

struct TempDir(PathBuf);

impl TempDir {
    fn new(label: &str) -> Self {
        let ordinal = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
        let path = std::env::temp_dir().join(format!(
            "noxid-wo53-qa1-{label}-{}-{ordinal}",
            std::process::id()
        ));
        fs::create_dir_all(&path).expect("create QA temporary directory");
        Self(path)
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.0);
    }
}

fn noxid(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_noxid"))
        .args(args)
        .output()
        .expect("run noxid")
}

fn root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}

#[test]
fn public_agent_guide_topic_set_is_context_first_and_byte_bounded() {
    for (topic, heading) in TOPICS {
        let output = noxid(&["agent-guide", topic]);
        assert!(
            output.status.success(),
            "agent-guide {topic} failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        assert!(
            output.stdout.len() <= GUIDE_BUDGET,
            "public agent-guide {topic} output is {} bytes, over {GUIDE_BUDGET}",
            output.stdout.len()
        );
        let text = String::from_utf8(output.stdout).expect("guide output is UTF-8");
        assert_eq!(
            text.lines().next(),
            Some(CONTEXT_PREAMBLE),
            "agent-guide {topic} did not open with the common instruction"
        );
        assert!(
            text.contains(heading),
            "agent-guide {topic} omitted {heading}"
        );
    }

    let usage = noxid(&[]);
    assert!(
        !usage.status.success(),
        "missing command unexpectedly succeeded"
    );
    let usage = format!(
        "{}{}",
        String::from_utf8_lossy(&usage.stdout),
        String::from_utf8_lossy(&usage.stderr)
    );
    let expected = TOPICS
        .iter()
        .map(|(topic, _)| *topic)
        .collect::<Vec<_>>()
        .join("|");
    assert!(
        usage.contains(&format!("noxid agent-guide <{expected}>")),
        "usage did not enumerate the complete guide topic set: {usage}"
    );
}

#[test]
fn describe_cli_and_mcp_return_one_budgeted_routing_guide() {
    let guide = noxid(&["agent-guide", "routing"]);
    let described = noxid(&["describe", "guide", "routing"]);
    assert!(guide.status.success(), "agent-guide routing failed");
    assert!(
        described.status.success(),
        "describe guide routing failed: {}",
        String::from_utf8_lossy(&described.stderr)
    );
    assert_eq!(
        described.stdout, guide.stdout,
        "CLI describe did not return exactly the routing topic"
    );

    let request = r#"{"jsonrpc":"2.0","id":53,"method":"tools/call","params":{"name":"query_project","arguments":{"operation":"describe","kind":"guide","name":"routing"}}}"#;
    let mut child = Command::new(env!("CARGO_BIN_EXE_noxid"))
        .args(["mcp", "../../examples/counter/Counter.nox"])
        .current_dir(env!("CARGO_MANIFEST_DIR"))
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("start noxid MCP");
    {
        let mut stdin = child.stdin.take().expect("MCP stdin");
        stdin
            .write_all(format!("{request}\n").as_bytes())
            .expect("write MCP request");
    }
    let output = child.wait_with_output().expect("finish MCP request");
    assert!(
        output.status.success(),
        "MCP describe failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        output.stdout.len() <= GUIDE_BUDGET,
        "MCP routing response is {} bytes, over {GUIDE_BUDGET}",
        output.stdout.len()
    );
    let response = String::from_utf8(output.stdout).expect("MCP response is UTF-8");
    for contract in [
        r#""kind":"guide""#,
        r#""name":"routing""#,
        "Context first: call query_project",
        "Noxid routing",
    ] {
        assert!(
            response.contains(contract),
            "MCP response omitted {contract}"
        );
    }
    assert!(
        !response.contains("Noxid storage"),
        "MCP leaked storage guide"
    );
    assert!(
        !response.contains("Noxid realtime"),
        "MCP leaked realtime guide"
    );
}

#[test]
fn llms_index_is_a_bijection_over_scoped_budgeted_chunks() {
    let root = root();
    let index = fs::read_to_string(root.join("llms.txt")).expect("read llms index");
    let full = fs::read_to_string(root.join("llms-full.txt")).expect("read full guide");
    let mut indexed = BTreeSet::new();
    let mut chunk_lines = BTreeSet::new();

    for line in index.lines().filter(|line| line.starts_with("- llms/")) {
        let row = line.strip_prefix("- llms/").expect("index row prefix");
        let (name, rest) = row.split_once(" (").expect("chunk name and size");
        let (size, scope) = rest.split_once(" bytes) - ").expect("chunk size and scope");
        let declared = size.parse::<u64>().expect("decimal chunk size");
        assert!(!scope.trim().is_empty(), "chunk {name} has an empty scope");
        assert!(
            indexed.insert(name.to_string()),
            "chunk {name} is indexed more than once"
        );

        let path = root.join("llms").join(name);
        let actual = fs::metadata(&path)
            .unwrap_or_else(|error| panic!("indexed chunk {name} does not resolve: {error}"))
            .len();
        assert_eq!(declared, actual, "index mis-sized chunk {name}");
        assert!(
            actual <= CHUNK_BUDGET,
            "chunk {name} is {actual} bytes, over {CHUNK_BUDGET}"
        );
        let body = fs::read_to_string(path).expect("chunk is UTF-8");
        for body_line in body.lines().filter(|body_line| !body_line.is_empty()) {
            if !(body_line.starts_with("## ") && body_line.contains("(continued, part ")) {
                chunk_lines.insert(body_line.to_string());
            }
        }
    }

    let actual = fs::read_dir(root.join("llms"))
        .expect("read chunk directory")
        .map(|entry| entry.expect("chunk entry").file_name())
        .filter_map(|name| name.into_string().ok())
        .filter(|name| name.ends_with(".txt"))
        .collect::<BTreeSet<_>>();
    assert_eq!(
        indexed, actual,
        "index and chunk directory are not a bijection"
    );
    assert!(indexed.len() >= 20, "unexpectedly small chunk topic set");

    let first_topic = full
        .lines()
        .position(|line| line.starts_with("## "))
        .expect("full guide contains topics");
    for line in full
        .lines()
        .skip(first_topic)
        .filter(|line| !line.is_empty())
    {
        assert!(
            chunk_lines.contains(line),
            "full-guide line is absent from every chunk: {line}"
        );
    }
}

#[test]
fn website_seo_step_copies_every_context_file_byte_for_byte() {
    let root = root();
    let temp = TempDir::new("website-copy");
    fs::write(
        temp.0.join("index.html"),
        "<!doctype html><html><head><title>Noxid</title></head><body></body></html>\n",
    )
    .expect("write HTML shell");
    fs::write(
        temp.0.join("app.routes.json"),
        r#"{"routes":[{"pattern":"/","middleware":[],"parameters":[],"metadata":{"title":"Noxid"}}]}"#,
    )
    .expect("write route manifest");

    let output = Command::new("node")
        .arg(root.join("tools/noxid-seo.mjs"))
        .arg(&temp.0)
        .current_dir(&root)
        .output()
        .expect("run website SEO copy step");
    assert!(
        output.status.success(),
        "SEO copy failed:\n{}\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(
        fs::read(root.join("llms.txt")).expect("source index"),
        fs::read(temp.0.join("llms.txt")).expect("served index")
    );
    assert_eq!(
        fs::read(root.join("llms-full.txt")).expect("source full guide"),
        fs::read(temp.0.join("llms-full.txt")).expect("served full guide")
    );

    let expected = fs::read_dir(root.join("llms"))
        .expect("source chunks")
        .map(|entry| entry.expect("source chunk").file_name())
        .filter_map(|name| name.into_string().ok())
        .filter(|name| name.ends_with(".txt"))
        .collect::<BTreeSet<_>>();
    let copied = fs::read_dir(temp.0.join("llms"))
        .expect("copied chunks")
        .map(|entry| entry.expect("copied chunk").file_name())
        .filter_map(|name| name.into_string().ok())
        .filter(|name| name.ends_with(".txt"))
        .collect::<BTreeSet<_>>();
    assert_eq!(expected, copied, "website copy omitted or added chunks");
    for name in expected {
        assert_eq!(
            fs::read(root.join("llms").join(&name)).expect("source chunk bytes"),
            fs::read(temp.0.join("llms").join(&name)).expect("copied chunk bytes"),
            "website copy changed chunk {name}"
        );
    }
}