mod boundary;
mod frontmatter;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use frontmatter::MetaValue;
#[derive(Parser)]
#[command(
name = "kova-docs-lint",
version,
about = "Lints docs/agent contracts and enforces the declared write boundary"
)]
struct Cli {
#[arg(long, default_value = ".")]
repo_root: PathBuf,
#[arg(long, default_value = "docs")]
docs_dir: PathBuf,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Lint,
CheckWrites {
#[arg(value_name = "WRITE")]
entries: Vec<String>,
#[arg(long)]
stdin: bool,
},
CheckPlan {
plan: PathBuf,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(code) => code,
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(2)
}
}
}
fn run(cli: Cli) -> Result<ExitCode, String> {
match cli.cmd {
Cmd::Lint => lint(&cli.repo_root, &cli.docs_dir),
Cmd::CheckWrites { entries, stdin } => check_writes(&cli.docs_dir, entries, stdin),
Cmd::CheckPlan { plan } => check_plan(&cli.docs_dir, &plan),
}
}
fn lint(repo_root: &Path, docs_dir: &Path) -> Result<ExitCode, String> {
let agent = docs_dir.join("agent");
if !agent.is_dir() {
return Err(format!("no agent docs at {:?}", agent.display()));
}
let mut issues: Vec<String> = Vec::new();
let mut checked = 0usize;
for path in walk_md(&agent) {
checked += 1;
let rel = path.strip_prefix(repo_root).unwrap_or(&path);
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("cannot read {:?}: {e}", rel.display()))?;
let (meta, body) = match frontmatter::split_frontmatter(&text) {
Ok(v) => v,
Err(e) => {
issues.push(format!("{:?}: frontmatter: {e}", rel.display()));
continue;
}
};
let tier = meta
.get("doc_tier")
.and_then(|v| v.as_scalar())
.map(str::to_string);
let is_code_flow = meta.get("doc_kind").and_then(|v| v.as_scalar()) == Some("code_flow");
if let Some(t) = &tier {
if t == "1" {
lint_tier1(repo_root, rel, &meta, &body, &mut issues);
} else if t == "2" {
lint_tier2(repo_root, rel, &meta, &mut issues);
} else {
issues.push(format!(
"{:?}: doc_tier must be 1 or 2, got {t:?}",
rel.display()
));
}
} else if !is_code_flow {
issues.push(format!(
"{:?}: unknown doc kind: expected doc_tier 1|2 or doc_kind code_flow",
rel.display()
));
} else {
lint_code_flow(repo_root, rel, &meta, &mut issues);
}
for (line_no, target) in md_links(&body) {
let t = target.split('#').next().unwrap_or("").trim();
if t.is_empty()
|| t.starts_with("http://")
|| t.starts_with("https://")
|| t.starts_with("mailto:")
|| t.starts_with('/')
|| !t.ends_with(".md")
{
continue;
}
let resolved = match path.parent() {
Some(parent) => parent.join(t),
None => PathBuf::from(t),
};
if !resolved.is_file() {
issues.push(format!(
"{:?}:{}: link to {} does not resolve (resolved: {:?})",
rel.display(),
line_no,
t,
resolved.display()
));
}
}
}
if issues.is_empty() {
println!("kova-docs-lint: {checked} docs checked, no issues");
Ok(ExitCode::SUCCESS)
} else {
for issue in &issues {
eprintln!("{issue}");
}
eprintln!(
"kova-docs-lint: {checked} docs checked, {} issue(s)",
issues.len()
);
Ok(ExitCode::from(1))
}
}
fn lint_tier1(
repo_root: &Path,
rel: &Path,
meta: &std::collections::BTreeMap<String, MetaValue>,
body: &str,
issues: &mut Vec<String>,
) {
let module = match meta.get("module").and_then(|v| v.as_scalar()) {
Some(m) if !m.is_empty() => Some(m),
_ => {
issues.push(format!(
"{:?}: tier1 requires a non-empty `module`",
rel.display()
));
None
}
};
let structs = match meta.get("structs") {
Some(MetaValue::List(items)) => items.clone(),
Some(_) => {
issues.push(format!("{:?}: `structs` must be a list", rel.display()));
Vec::new()
}
None => {
issues.push(format!(
"{:?}: tier1 requires a `structs` list",
rel.display()
));
Vec::new()
}
};
if structs.is_empty() {
issues.push(format!("{:?}: `structs` must be non-empty", rel.display()));
}
if let Some(m) = module {
if !repo_root.join("crates").join(m).is_dir() {
issues.push(format!(
"{:?}: module `{m}` does not resolve (no crates/{m}/)",
rel.display()
));
} else {
for s in &structs {
if !type_declared(repo_root, m, s) {
issues.push(format!(
"{:?}: struct `{s}` not found in crates/{m}/src",
rel.display()
));
}
}
}
}
for key in ["reads", "writes"] {
match meta.get(key) {
Some(MetaValue::List(_)) => {}
Some(_) => issues.push(format!("{:?}: `{key}` must be a list", rel.display())),
None => issues.push(format!(
"{:?}: tier1 requires a `{key}` list",
rel.display()
)),
}
}
let writes_nonempty = meta
.get("writes")
.and_then(|v| v.as_list())
.is_some_and(|w| !w.is_empty());
if writes_nonempty {
let links_tier2 = md_links(body).iter().any(|(_, target)| {
let t = target.split('#').next().unwrap_or("").trim();
let resolved = match rel.parent() {
Some(parent) => parent.join(t),
None => PathBuf::from(t),
};
let canon = std::fs::canonicalize(&resolved).ok();
canon.is_some_and(|c| {
c.to_string_lossy().contains("agent")
&& c.to_string_lossy()
.contains(&format!("{sep}tier2", sep = std::path::MAIN_SEPARATOR))
})
});
if !links_tier2 {
issues.push(format!(
"{:?}: non-empty `writes` requires a cross-link to a docs/agent/tier2/ doc",
rel.display()
));
}
}
}
fn lint_tier2(
repo_root: &Path,
rel: &Path,
meta: &std::collections::BTreeMap<String, MetaValue>,
issues: &mut Vec<String>,
) {
let target = match meta.get("target_file").and_then(|v| v.as_scalar()) {
Some(f) if !f.is_empty() => f,
_ => {
issues.push(format!(
"{:?}: tier2 requires a non-empty `target_file`",
rel.display()
));
return;
}
};
if !repo_root.join(target).is_file() {
issues.push(format!(
"{:?}: target_file `{target}` does not exist",
rel.display()
));
}
for key in ["procs", "drivers", "env"] {
match meta.get(key) {
Some(MetaValue::List(_)) => {}
Some(_) => issues.push(format!("{:?}: `{key}` must be a list", rel.display())),
None => issues.push(format!(
"{:?}: tier2 requires a `{key}` list",
rel.display()
)),
}
}
}
fn lint_code_flow(
repo_root: &Path,
rel: &Path,
meta: &std::collections::BTreeMap<String, MetaValue>,
issues: &mut Vec<String>,
) {
let module = match meta.get("module").and_then(|v| v.as_scalar()) {
Some(m) if !m.is_empty() => m,
_ => {
issues.push(format!(
"{:?}: code_flow requires a non-empty `module`",
rel.display()
));
return;
}
};
if !repo_root.join("crates").join(module).is_dir() {
issues.push(format!(
"{:?}: module `{module}` does not resolve (no crates/{module}/)",
rel.display()
));
}
match meta.get("chain") {
Some(MetaValue::List(items)) if !items.is_empty() => {}
Some(MetaValue::List(_)) => {
issues.push(format!("{:?}: `chain` must be non-empty", rel.display()))
}
_ => issues.push(format!(
"{:?}: code_flow requires a `chain` list",
rel.display()
)),
}
}
fn check_writes(
docs_dir: &Path,
mut entries: Vec<String>,
stdin: bool,
) -> Result<ExitCode, String> {
if stdin {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| format!("stdin: {e}"))?;
for line in buf.lines() {
let t = line.trim();
if t.is_empty() || t.starts_with('#') {
continue;
}
entries.push(t.to_string());
}
}
if entries.is_empty() {
return Err("no write entries given (pass entries or use --stdin)".into());
}
let b = boundary::build(docs_dir)?;
let mut bad = 0usize;
for e in &entries {
let entry = boundary::Entry::parse(e);
if entry.allowed(&b) {
println!("ok {e}");
} else {
bad += 1;
println!("VIOLATION {e} — not in the declared boundary");
}
}
if bad == 0 {
println!(
"kova-docs-lint: {} write(s) checked, all in bounds",
entries.len()
);
Ok(ExitCode::SUCCESS)
} else {
eprintln!(
"kova-docs-lint: {} of {} write(s) out of bounds",
bad,
entries.len()
);
Ok(ExitCode::from(1))
}
}
fn check_plan(docs_dir: &Path, plan: &Path) -> Result<ExitCode, String> {
let text = std::fs::read_to_string(plan)
.map_err(|e| format!("cannot read {:?}: {e}", plan.display()))?;
let writes = match frontmatter::plan_writes(&text).map_err(|e| e.to_string())? {
None => {
println!(
"{:?}: no `writes` fence — nothing to check \
(research surface defaults to research/plans/)",
plan.display()
);
return Ok(ExitCode::SUCCESS);
}
Some(v) => v,
};
let (line_no, items) = writes;
let b = boundary::build(docs_dir)?;
println!(
"{:?}: {} declared write(s) from yaml fence at line {line_no}",
plan.display(),
items.len()
);
let mut bad = 0usize;
for e in &items {
let entry = boundary::Entry::parse(e);
if entry.allowed(&b) {
println!("ok {e}");
} else {
bad += 1;
println!("VIOLATION {e} — not in the declared boundary");
}
}
if bad == 0 {
Ok(ExitCode::SUCCESS)
} else {
eprintln!(
"{:?}: REJECTED — {} write(s) outside the declared boundary",
plan.display(),
bad
);
Ok(ExitCode::from(1))
}
}
fn walk_md(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let read_dir = match std::fs::read_dir(&d) {
Ok(r) => r,
Err(_) => continue,
};
for de in read_dir.flatten() {
let p = de.path();
if p.is_dir() {
if p.file_name().and_then(|n| n.to_str()) == Some("templates") {
continue;
}
stack.push(p);
} else if p.extension().and_then(|e| e.to_str()) == Some("md") {
out.push(p);
}
}
}
out.sort();
out
}
fn walk_rs(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let read_dir = match std::fs::read_dir(&d) {
Ok(r) => r,
Err(_) => continue,
};
for de in read_dir.flatten() {
let p = de.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(p);
}
}
}
out.sort();
out
}
fn type_declared(repo_root: &Path, module: &str, name: &str) -> bool {
let src = repo_root.join("crates").join(module).join("src");
for file in walk_rs(&src) {
let Ok(text) = std::fs::read_to_string(&file) else {
continue;
};
for line in text.lines() {
let t = line.trim();
if t.starts_with("//") {
continue;
}
let mut rest = t;
loop {
if let Some(r) = rest.strip_prefix("pub") {
rest = r.trim_start();
if let Some(r2) = rest.strip_prefix('(') {
if let Some(close) = r2.find(')') {
rest = r2[close + 1..].trim_start();
}
}
continue;
}
break;
}
for kw in ["struct ", "enum ", "trait ", "type "] {
if let Some(after) = rest.strip_prefix(kw) {
let after = after.trim_start();
let id: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if id == name {
return true;
}
}
}
}
}
false
}
fn md_links(body: &str) -> Vec<(usize, String)> {
let mut out = Vec::new();
for (idx, line) in body.lines().enumerate() {
let mut rest = line;
while let Some(open) = rest.find('[') {
let after = &rest[open + 1..];
let Some(close) = after.find("](") else {
rest = after;
continue;
};
let target_part = &after[close + 2..];
let target = target_part.split(')').next().unwrap_or(target_part).trim();
out.push((idx + 1, target.to_string()));
rest = target_part;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn md_links_finds_targets_and_skips_images() {
let body = "see [a](../tier2/engine_ops.md) and [b](https://x.dev) and [c](x.md#frag)\n";
let links = md_links(body);
assert_eq!(links.len(), 3);
assert_eq!(links[0].1, "../tier2/engine_ops.md");
assert_eq!(links[2].1, "x.md#frag");
}
#[test]
fn type_declared_matches_visibility_forms() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("crates/kova-x/src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("lib.rs"),
"pub struct Foo;\npub(crate) enum Bar;\npub trait Baz;\n// struct NotReal;\nstruct Qux;\n",
)
.unwrap();
let root = tmp.path();
assert!(type_declared(root, "kova-x", "Foo"));
assert!(type_declared(root, "kova-x", "Bar"));
assert!(type_declared(root, "kova-x", "Baz"));
assert!(type_declared(root, "kova-x", "Qux"));
assert!(!type_declared(root, "kova-x", "NotReal"));
assert!(!type_declared(root, "kova-x", "Foo2"));
}
#[test]
fn walk_md_skips_templates() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("agent/tier1")).unwrap();
std::fs::create_dir_all(tmp.path().join("agent/templates")).unwrap();
std::fs::write(tmp.path().join("agent/tier1/a.md"), "x").unwrap();
std::fs::write(tmp.path().join("agent/templates/t.md"), "x").unwrap();
let files = walk_md(&tmp.path().join("agent"));
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("tier1/a.md"));
}
}