kova-docs-lint 0.8.3

Frontmatter linter, link checker, and write-boundary validator for docs/agent
//! The declared write boundary: the union of every surface the agent docs
//! allow writes to, plus the code-enforced research plan fence.
//!
//! File writes are allowed at:
//! - any Tier 2 `target_file` (exact path)
//! - any Tier 1 `writes` entry prefixed `file:` (path, or a directory when
//!   the value ends in `/`)
//! - the fenced research surface `research/plans/`
//!   (`kova-primitives::plan::validate_plan_name` + `plan_path`)
//!
//! State writes are allowed only for `state:<name>` surfaces named in some
//! Tier 1 `writes` list. Bare `writes` entries are output type contracts
//! (e.g. `PipelineState`, `Response`) and do not widen the boundary.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use crate::frontmatter;

#[derive(Debug)]
pub struct Boundary {
    /// Exact repo-relative file paths (Tier 2 `target_file`, `file:` entries).
    pub files: BTreeSet<PathBuf>,
    /// Repo-relative directory roots; anything under a root is allowed.
    pub dirs: BTreeSet<PathBuf>,
    /// Declared `state:` surfaces.
    pub state: BTreeSet<String>,
}

impl Boundary {
    pub fn file_allowed(&self, path: &Path) -> bool {
        self.files.contains(path) || self.dirs.iter().any(|d| path.starts_with(d))
    }

    pub fn state_allowed(&self, name: &str) -> bool {
        self.state.contains(name)
    }
}

/// A write entry as declared in a doc `writes` list or a plan fence.
#[derive(Debug, PartialEq, Eq)]
pub enum Entry {
    /// A repo-relative file path (`file:` prefix, or a bare path-like value).
    File(PathBuf),
    /// A declared state surface (`state:` prefix).
    State(String),
    /// A bare output type contract (e.g. `PipelineState`, `tool result (...)`).
    /// Never a file write; always allowed.
    Type(String),
}

impl Entry {
    pub fn parse(raw: &str) -> Entry {
        let t = raw.trim();
        if let Some(name) = t.strip_prefix("state:") {
            Entry::State(name.trim().to_string())
        } else if let Some(path) = t.strip_prefix("file:") {
            Entry::File(PathBuf::from(path.trim()))
        } else if t.contains('/') || t.contains('\\') || t.contains('.') {
            // Bare but path-like: treat as a file path.
            Entry::File(PathBuf::from(t))
        } else {
            // Bare type name: an output contract, not a file write.
            Entry::Type(t.to_string())
        }
    }

    pub fn allowed(&self, b: &Boundary) -> bool {
        match self {
            Entry::File(p) => b.file_allowed(p),
            Entry::State(s) => b.state_allowed(s),
            Entry::Type(_) => true,
        }
    }
}

/// Build the boundary from `docs/agent/tier1` + `docs/agent/tier2` under
/// `docs_root`. Every doc there must carry parseable frontmatter.
pub fn build(docs_root: &Path) -> Result<Boundary, String> {
    let mut b = Boundary {
        files: BTreeSet::new(),
        dirs: BTreeSet::new(),
        state: BTreeSet::new(),
    };
    // Fenced research surface (code-enforced, see kova-primitives::plan).
    b.dirs.insert(PathBuf::from("research/plans"));

    for tier in ["tier1", "tier2"] {
        let dir = docs_root.join("agent").join(tier);
        let read_dir =
            std::fs::read_dir(&dir).map_err(|e| format!("cannot read {:?}: {e}", dir))?;
        for de in read_dir {
            let de = de.map_err(|e| e.to_string())?;
            let path = de.path();
            if path.extension().and_then(|e| e.to_str()) != Some("md") {
                continue;
            }
            let text = std::fs::read_to_string(&path)
                .map_err(|e| format!("cannot read {:?}: {e}", path))?;
            let (meta, _) = frontmatter::split_frontmatter(&text)
                .map_err(|e| format!("{:?}: frontmatter: {e}", path))?;

            if tier == "tier1" {
                if let Some(writes) = meta.get("writes").and_then(|v| v.as_list()) {
                    for w in writes {
                        if let Some(name) = w.strip_prefix("state:") {
                            b.state.insert(name.to_string());
                        } else if let Some(path) = w.strip_prefix("file:") {
                            if path.ends_with('/') {
                                b.dirs.insert(PathBuf::from(path));
                            } else {
                                b.files.insert(PathBuf::from(path));
                            }
                        }
                        // bare entries are output type contracts, not surfaces
                    }
                }
            } else if let Some(file) = meta.get("target_file").and_then(|v| v.as_scalar()) {
                b.files.insert(PathBuf::from(file));
            }
        }
    }
    Ok(b)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write(p: impl AsRef<Path>, content: &str) {
        let p = p.as_ref();
        if let Some(parent) = p.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(p, content).unwrap();
    }

    #[test]
    fn boundary_from_docs_and_fence() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        write(root.join("crates/kova-x/src/lib.rs"), "pub struct X;\n");
        write(
            root.join("docs/agent/tier1/a.md"),
            "---\ndoc_tier: \"1\"\nmodule: kova-x\nstructs:\n  - X\nreads: []\nwrites:\n  - X\n  - state:state-a\n  - file:out/\n---\nbody\n",
        );
        write(
            root.join("docs/agent/tier2/b.md"),
            "---\ndoc_tier: \"2\"\ntarget_file: crates/kova-x/src/lib.rs\nprocs: []\ndrivers: []\nenv: []\n---\nbody\n",
        );

        let b = build(root.join("docs").as_path()).unwrap();

        // Fenced research surface.
        assert!(b.file_allowed(Path::new("research/plans/whatever.md")));
        assert!(!b.file_allowed(Path::new("research/other/plan.md")));
        // Tier 1 dir entry.
        assert!(b.file_allowed(Path::new("out/nested/deep.txt")));
        // Tier 2 target file (exact).
        assert!(b.file_allowed(Path::new("crates/kova-x/src/lib.rs")));
        assert!(!b.file_allowed(Path::new("crates/kova-x/src/other.rs")));
        // State surfaces.
        assert!(b.state_allowed("state-a"));
        assert!(!b.state_allowed("state-b"));
        // Entry parsing.
        assert_eq!(
            Entry::parse("file:out/x.txt"),
            Entry::File(PathBuf::from("out/x.txt"))
        );
        assert_eq!(Entry::parse("state:s"), Entry::State("s".into()));
        assert_eq!(
            Entry::parse("crates/kova-x/src/lib.rs"),
            Entry::File(PathBuf::from("crates/kova-x/src/lib.rs"))
        );
        assert!(Entry::parse("research/plans/p.md").allowed(&b));
        assert!(!Entry::parse("crates/kova-x/src/other.rs").allowed(&b));
        // Bare type names are output contracts, never file writes.
        assert_eq!(
            Entry::parse("PipelineState"),
            Entry::Type("PipelineState".into())
        );
        assert_eq!(
            Entry::parse("tool result ({content, isError})"),
            Entry::Type("tool result ({content, isError})".into())
        );
        assert!(Entry::parse("PipelineState").allowed(&b));
    }

    #[test]
    fn build_fails_on_bad_frontmatter() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        write(
            root.join("docs/agent/tier1/bad.md"),
            "---\nno frontmatter delimiter\n",
        );
        let err = build(root.join("docs").as_path()).unwrap_err();
        assert!(err.contains("bad.md"), "error should name the doc: {err}");
    }
}