noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

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

struct Fixture {
    root: PathBuf,
}

impl Fixture {
    fn new(label: &str, schema: &str) -> Self {
        let ordinal = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
        let root = std::env::temp_dir().join(format!(
            "noxid-wo40-{label}-{}-{ordinal}",
            std::process::id()
        ));
        fs::create_dir_all(root.join("server/api")).expect("create endpoint directory");
        fs::create_dir_all(root.join("server/utils")).expect("create server utils directory");
        fs::write(root.join("Noxid.toml"), "[app]\ntitle = \"WO-40\"\n").expect("write manifest");
        fs::write(root.join("package.json"), "{\"type\":\"module\"}\n")
            .expect("write package metadata");
        fs::write(
            root.join("server/api/probe.get.nox"),
            "endpoint Probe { result: String }\n",
        )
        .expect("write endpoint");
        fs::write(
            root.join("server/host.ts"),
            "import { progress } from './utils/schema.js';\nvoid progress;\nexport const endpoints = Object.freeze({ 'endpoint:Probe@1': async () => 'ok' });\n",
        )
        .expect("write host");
        fs::write(root.join("server/utils/schema.ts"), schema).expect("write schema");
        Self { root }
    }

    fn build(&self) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .args(["build", ".", "--out-dir", "dist"])
            .current_dir(&self.root)
            .output()
            .expect("execute noxid build")
    }

    fn write(&self, relative: &str, source: &str) {
        let path = self.root.join(relative);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create fixture parent");
        }
        fs::write(path, source).expect("write fixture source");
    }
}

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

fn output_text(output: &Output) -> String {
    format!(
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    )
}

fn files_with_unsafe_calls(root: &Path, relative: &Path, found: &mut BTreeSet<String>) {
    let path = root.join(relative);
    if path.is_dir() {
        for entry in fs::read_dir(&path).expect("read source directory") {
            let entry = entry.expect("read source entry");
            let child = relative.join(entry.file_name());
            files_with_unsafe_calls(root, &child, found);
        }
    } else if matches!(
        path.extension().and_then(|value| value.to_str()),
        Some("js" | "mjs")
    ) && fs::read_to_string(&path)
        .expect("read JavaScript source")
        .contains(".unsafe(")
    {
        found.insert(relative.to_string_lossy().replace('\\', "/"));
    }
}

fn advisory_no_query_contract(security_manifest: &str, openapi: &str) -> Result<(), String> {
    if !security_manifest.contains("\"policy\":\"scoped\"") {
        return Ok(());
    }
    let normalized = openapi.to_ascii_lowercase();
    for field in [
        "sql",
        "rawsql",
        "raw_sql",
        "querytext",
        "query_text",
        "statement",
    ] {
        if normalized.contains(&format!("\"{field}\"")) {
            return Err(format!(
                "advisory[RAW_QUERY_CONTRACT]: typed agent surface exposes SQL-shaped field `{field}` while scoped data exists"
            ));
        }
    }
    Ok(())
}

#[test]
fn adapter_enforces_declared_scopes_and_runtime_principal_authority() {
    let repository = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
    let output = Command::new("node")
        .args(["--test", "plugins/drizzle-orm/data-scopes.test.mjs"])
        .current_dir(repository)
        .output()
        .expect("run adapter scope tests");
    assert!(output.status.success(), "{}", output_text(&output));
}

#[test]
fn build_emits_canonical_declared_table_policies_and_guarantee() {
    let fixture = Fixture::new(
        "manifest",
        r#"
export const publicConfig = unscopedTable(pgTable("public_config", {}));
export const progress = scopedTable(pgTable("progress", {}), "user_id");
"#,
    );
    let output = fixture.build();
    assert!(output.status.success(), "{}", output_text(&output));
    let manifest =
        fs::read_to_string(fixture.root.join("dist/server/security.manifest.json")).unwrap();
    assert!(manifest.contains("\"schemaVersion\":10"), "{manifest}");
    assert!(
        manifest.contains("Agents have no query surface"),
        "{manifest}"
    );
    assert!(manifest.contains(
        "\"dataPolicies\":[{\"table\":\"progress\",\"policy\":\"scoped\",\"principalColumn\":\"user_id\",\"principalType\":null},{\"table\":\"public_config\",\"policy\":\"unscoped\",\"principalColumn\":null,\"principalType\":null}]"
    ), "{manifest}");
}

#[test]
fn build_refuses_a_table_without_an_explicit_policy() {
    let fixture = Fixture::new(
        "undeclared",
        "export const progress = pgTable(\"progress\", {});\n",
    );
    let output = fixture.build();
    assert!(!output.status.success(), "bare table unexpectedly built");
    let text = output_text(&output);
    assert!(text.contains("error[DATA_POLICY_UNDECLARED]"), "{text}");
    assert!(text.contains("scopedTable"), "{text}");
    assert!(text.contains("unscopedTable"), "{text}");
}

#[test]
fn developer_server_entry_is_trusted_with_dynamic_driver_assembly() {
    let fixture = Fixture::new(
        "unsafe-sql",
        "export const progress = scopedTable(pgTable(\"progress\", {}), \"user_id\");\n",
    );
    fixture.write(
        "server/host.ts",
        r#"const driver = await import("post" + "gres");
void driver;
export const endpoints = Object.freeze({ "endpoint:Probe@1": async () => "ok" });
"#,
    );
    let output = fixture.build();
    assert!(output.status.success(), "{}", output_text(&output));
    assert!(fixture.root.join("dist/server/handler.js").is_file());
}

#[test]
fn trusted_developer_server_entry_builds_with_inert_decoys() {
    let fixture = Fixture::new(
        "unsafe-sql-decoys",
        "export const progress = scopedTable(pgTable(\"progress\", {}), \"user_id\");\n",
    );
    fixture.write(
        "server/host.ts",
        r#"// import("post" + "gres"); client.unsafe("SELECT secret");
const teaching = 'from"postgres" .unsafe( __installNoxidPrincipalAuthority "SELECT " + value';
void teaching;
export const endpoints = Object.freeze({ "endpoint:Probe@1": async () => "ok" });
"#,
    );
    let output = fixture.build();
    assert!(output.status.success(), "{}", output_text(&output));
}

#[test]
fn unsafe_sql_allowlist_is_exactly_the_two_documented_ddl_owners() {
    const ALLOWLIST: [(&str, &str); 2] = [
        (
            "tools/noxid-db.mjs",
            "forward migration and PostgreSQL RLS DDL owned by noxid db",
        ),
        (
            "crates/codegen-server-js/src/lib.rs",
            "idempotent _noxid_jobs bootstrap DDL owned by the generated queue runtime",
        ),
    ];
    let repository = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
    let mut found = BTreeSet::new();
    files_with_unsafe_calls(&repository, Path::new("tools"), &mut found);
    files_with_unsafe_calls(&repository, Path::new("plugins"), &mut found);
    let generated = Path::new("crates/codegen-server-js/src/lib.rs");
    if fs::read_to_string(repository.join(generated))
        .expect("read generated server runtime")
        .contains(".unsafe(")
    {
        found.insert(generated.to_string_lossy().into_owned());
    }
    let allowed = ALLOWLIST
        .iter()
        .map(|(path, rationale)| {
            assert!(!rationale.is_empty());
            (*path).to_string()
        })
        .collect::<BTreeSet<_>>();
    assert_eq!(
        found, allowed,
        "unsafe SQL ownership changed; review and keep the allowlist exact"
    );
}

#[test]
fn advisory_manifest_lint_names_sql_shaped_agent_inputs() {
    let fixture = Fixture::new(
        "query-advisory",
        "export const progress = scopedTable(pgTable(\"progress\", {}), \"user_id\");\n",
    );
    fixture.write(
        "server/api/probe.get.nox",
        "endpoint Probe { query { sql: String } result: String }\n",
    );
    let output = fixture.build();
    assert!(output.status.success(), "{}", output_text(&output));
    let security = fs::read_to_string(fixture.root.join("dist/server/security.manifest.json"))
        .expect("read security manifest");
    let openapi =
        fs::read_to_string(fixture.root.join("dist/api.openapi.json")).expect("read OpenAPI");
    let advisory = advisory_no_query_contract(&security, &openapi)
        .expect_err("SQL-shaped free text should be named by the advisory lint");
    assert!(advisory.contains("advisory[RAW_QUERY_CONTRACT]"));
    assert!(advisory.contains("`sql`"));

    let clean = Fixture::new(
        "query-advisory-clean",
        "export const progress = scopedTable(pgTable(\"progress\", {}), \"user_id\");\n",
    );
    let output = clean.build();
    assert!(output.status.success(), "{}", output_text(&output));
    advisory_no_query_contract(
        &fs::read_to_string(clean.root.join("dist/server/security.manifest.json")).unwrap(),
        &fs::read_to_string(clean.root.join("dist/api.openapi.json")).unwrap(),
    )
    .expect("closed typed endpoint has no raw-query advisory");
}

#[test]
fn generated_audit_surface_allowlists_identity_and_scope_metadata_only() {
    let fixture = Fixture::new(
        "audit-fields",
        "export const progress = scopedTable(pgTable(\"progress\", {}), \"user_id\");\n",
    );
    let output = fixture.build();
    assert!(output.status.success(), "{}", output_text(&output));
    let handler =
        fs::read_to_string(fixture.root.join("dist/server/handler.js")).expect("read handler");
    for field in [
        "agentSemanticId",
        "actingPrincipal",
        "dataTable",
        "scopeColumn",
    ] {
        assert!(handler.contains(field), "missing audit field {field}");
    }
    assert!(handler.contains("\"data.access\""));
    assert!(!handler.contains("rowValue"));
    assert!(!handler.contains("rowValues"));
}