koala-core 1.0.4

Shared types, invariant evaluator, and primitives for the koala framework.
Documentation
use crate::invariant::rules::util::{list_files_with_ext, rel};
use crate::invariant::{Category, Context, Invariant, Outcome};
use std::fs;

/// Crates allowed to do network I/O. Everything else under `crates/`
/// must stay pure or route through these.
const SHELL_CRATES: &[&str] = &["koala-cli"];

/// Files whose source code references the forbidden patterns as
/// detection data. Skipping them avoids the rule flagging itself.
const SKIP_FILES: &[&str] = &["crates/koala-core/src/invariant/rules/side_effect_boundary.rs"];

/// Network-side-effect patterns. Subprocess spawning is *allowed* in
/// domain crates — running git / reviewer commands is part of the
/// design. Sockets and bind ports are not. Substring match.
const FORBIDDEN_PATTERNS: &[&str] = &[
    "std::net::TcpListener",
    "std::net::TcpStream",
    "std::net::UdpSocket",
    "tokio::net::",
];

pub struct SideEffectBoundary;

impl Invariant for SideEffectBoundary {
    fn id(&self) -> &'static str {
        "arch.side-effect-boundary"
    }
    fn category(&self) -> Category {
        Category::Arch
    }
    fn intent(&self) -> &'static str {
        "Network listeners (TcpListener / TcpStream / tokio::net) are \
         confined to shell crates (currently `koala-cli`). Library \
         crates stay free of socket I/O so they can be tested without \
         binding ports."
    }
    fn adr(&self) -> Option<&'static str> {
        Some("ADR-0001")
    }

    fn evaluate(&self, ctx: &Context) -> Outcome {
        let mut hits: Vec<String> = Vec::new();
        for path in list_files_with_ext(ctx.root(), "rs") {
            let rel_str = rel(&path, ctx.root());
            if !rel_str.starts_with("crates/") {
                continue;
            }
            // Tests are allowed to spawn — they're verifying behaviour.
            let normalised = rel_str.replace('\\', "/");
            if normalised.contains("/tests/") {
                continue;
            }
            let crate_name = match normalised.strip_prefix("crates/") {
                Some(rest) => rest.split('/').next().unwrap_or(""),
                None => continue,
            };
            if SHELL_CRATES.contains(&crate_name) {
                continue;
            }
            if SKIP_FILES.iter().any(|s| normalised == *s) {
                continue;
            }
            let Ok(text) = fs::read_to_string(&path) else {
                continue;
            };
            for line in text.lines() {
                let trimmed = line.trim_start();
                if trimmed.starts_with("//") || trimmed.starts_with("/*") {
                    continue;
                }
                for pat in FORBIDDEN_PATTERNS {
                    if trimmed.contains(pat) {
                        hits.push(format!("{rel_str}: {pat}"));
                    }
                }
            }
        }
        if hits.is_empty() {
            Outcome::pass()
        } else {
            hits.sort();
            hits.dedup();
            Outcome::fail_repro(
                format!(
                    "{} network-side-effect leak(s) outside shell crates:\n  {}",
                    hits.len(),
                    hits.join("\n  ")
                ),
                "rg -n 'std::net::|tokio::net::' crates/ --glob '!crates/koala-cli/**'",
            )
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn write_src(root: &std::path::Path, rel: &str, body: &str) {
        let p = root.join(rel);
        fs::create_dir_all(p.parent().unwrap()).unwrap();
        fs::write(p, body).unwrap();
    }

    #[test]
    fn pure_lib_passes() {
        let tmp = TempDir::new().unwrap();
        write_src(
            tmp.path(),
            "crates/koala-core/src/lib.rs",
            "pub fn add(a: i32, b: i32) -> i32 { a + b }\n",
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(matches!(
            SideEffectBoundary.evaluate(&ctx),
            Outcome::Pass { .. }
        ));
    }

    #[test]
    fn subprocess_spawn_is_allowed_in_domain() {
        // Domain crates legitimately spawn git / reviewer commands;
        // the rule only flags socket I/O.
        let tmp = TempDir::new().unwrap();
        write_src(
            tmp.path(),
            "crates/koala-workflow/src/diff.rs",
            "use std::process::Command;\npub fn git_diff() { let _ = Command::new(\"git\"); }\n",
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(matches!(
            SideEffectBoundary.evaluate(&ctx),
            Outcome::Pass { .. }
        ));
    }

    #[test]
    fn shell_crate_is_exempt() {
        let tmp = TempDir::new().unwrap();
        write_src(
            tmp.path(),
            "crates/koala-cli/src/main.rs",
            "fn main() { let _ = std::net::TcpListener::bind(\"0.0.0.0:0\"); }\n",
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(matches!(
            SideEffectBoundary.evaluate(&ctx),
            Outcome::Pass { .. }
        ));
    }

    #[test]
    fn io_outside_shell_rejected() {
        let tmp = TempDir::new().unwrap();
        write_src(
            tmp.path(),
            "crates/koala-core/src/server.rs",
            "use std::net::TcpListener;\npub fn serve() { let _ = TcpListener::bind(\"0.0.0.0:0\"); }\n",
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        let out = SideEffectBoundary.evaluate(&ctx);
        assert!(matches!(out, Outcome::Fail { .. }), "{out:?}");
    }

    #[test]
    fn tests_dir_is_exempt() {
        let tmp = TempDir::new().unwrap();
        write_src(
            tmp.path(),
            "crates/koala-core/tests/server_test.rs",
            "#[test] fn s() { let _ = std::net::TcpListener::bind(\"0.0.0.0:0\"); }\n",
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(matches!(
            SideEffectBoundary.evaluate(&ctx),
            Outcome::Pass { .. }
        ));
    }
}