rdar 0.6.3

radar - the repository cartographer for AI agents: compiles a repo into tiny committed MAP.md routers, with measured token benchmarks
Documentation
//! `radar check` - invariants N1–N6, staleness, budgets, and the static 4×
//! routing-chain cap. One violation per line,
//! stable `RULE:` prefixes, grep-able.

use std::collections::BTreeMap;
use std::path::Path;

use crate::cache::ScanCache;
use crate::frontmatter;
use crate::mapfile::{self, CHAIN_BYTE_CAP};

/// A parsed on-disk map.
#[derive(Debug)]
pub struct DiskMap {
    pub parent_link: Option<String>,
    pub children_links: Vec<String>,
    pub api_hash: Option<String>,
    pub bytes_actual: usize,
    pub tokens: Option<String>,
    pub fidelity: String,
    pub slot_filled: bool,
}

#[derive(Debug, Default)]
pub struct CheckReport {
    pub maps: BTreeMap<String, DiskMap>,
    pub violations: Vec<String>,
    /// scope → stale? (declared api_hash ≠ recomputed)
    pub stale: BTreeMap<String, bool>,
}

/// Directory names that must never contain maps (mirror of the scan skip-set).
const SKIP_DIRS: [&str; 16] = [
    ".git",
    ".radar",
    "target",
    "node_modules",
    "dist",
    "build",
    ".venv",
    "venv",
    "__pycache__",
    "vendor",
    "third_party",
    ".idea",
    ".vscode",
    ".cache",
    ".tox",
    "coverage",
];

/// Find and parse every MAP.md under `root` (skip-set respected).
pub fn load_maps(root: &Path) -> BTreeMap<String, DiskMap> {
    let mut maps = BTreeMap::new();
    let walker = ignore::WalkBuilder::new(root)
        .filter_entry(|entry| {
            let name = entry.file_name().to_string_lossy();
            !(entry.file_type().is_some_and(|t| t.is_dir()) && SKIP_DIRS.contains(&name.as_ref()))
        })
        .build();
    for entry in walker.flatten() {
        if entry.file_name() != "MAP.md" {
            continue;
        }
        let Ok(doc) = std::fs::read_to_string(entry.path()) else {
            continue;
        };
        let Some((fm, body)) = frontmatter::parse(&doc) else {
            continue;
        };
        let scope = entry
            .path()
            .parent()
            .and_then(|d| d.strip_prefix(root).ok())
            .map(|p| p.to_string_lossy().replace('\\', "/"))
            .unwrap_or_default();
        maps.insert(
            scope.clone(),
            DiskMap {
                parent_link: fm.get("parent").map(str::to_string),
                children_links: fm.get_list("children").unwrap_or_default(),
                api_hash: fm.get("api_hash").map(str::to_string),
                // \r-normalized so CRLF checkout variants never trigger
                // false N6 violations.
                bytes_actual: body.replace('\r', "").len(),
                tokens: fm.get("tokens").map(str::to_string),
                fidelity: fm.get("fidelity").unwrap_or("syntax").to_string(),
                slot_filled: mapfile::slot_text(body, "purpose").is_some(),
            },
        );
    }
    maps
}

/// Resolve a file-relative link from a scope dir to a scope string.
pub fn resolve_link(from_scope: &str, link: &str) -> Option<String> {
    let mut parts: Vec<&str> = if from_scope.is_empty() {
        vec![]
    } else {
        from_scope.split('/').collect()
    };
    for seg in link.split('/') {
        match seg {
            ".." => {
                parts.pop()?;
            }
            "." | "" => {}
            "MAP.md" => break,
            other => parts.push(other),
        }
    }
    Some(parts.join("/"))
}

/// Nearest strict-ancestor scope among `maps` for `scope`.
fn nearest_map_ancestor<'m>(maps: &'m BTreeMap<String, DiskMap>, scope: &str) -> Option<&'m str> {
    let mut cur = scope;
    loop {
        let parent = match cur.rfind('/') {
            Some(i) => &cur[..i],
            None if !cur.is_empty() => "",
            None => return None,
        };
        if let Some((k, _)) = maps.get_key_value(parent) {
            return Some(k.as_str());
        }
        if parent.is_empty() {
            return None;
        }
        cur = parent;
    }
}

/// Full check. `cache` supplies current extractions for staleness.
pub fn check(root: &Path, cache: &ScanCache) -> CheckReport {
    let maps = load_maps(root);
    let mut v: Vec<String> = Vec::new();

    if maps.is_empty() {
        return CheckReport::default();
    }

    // N1: a root map must exist.
    if !maps.contains_key("") {
        v.push("N1: no root MAP.md (the tree has no anchor)".to_string());
    }

    // N5: maps never live inside skip dirs (defensive: symlinked layouts).
    for scope in maps.keys() {
        if scope.split('/').any(|part| SKIP_DIRS.contains(&part)) {
            v.push(format!("N5: {scope}/MAP.md is inside a skip directory"));
        }
    }

    for (scope, map) in &maps {
        let loc = if scope.is_empty() { "." } else { scope };

        // N3: parent link resolves to the nearest mapped ancestor.
        match (&map.parent_link, nearest_map_ancestor(&maps, scope)) {
            (None, None) => {}
            (None, Some(_)) if scope.is_empty() => {}
            (None, Some(expected)) => {
                v.push(format!(
                    "N3: {loc}/MAP.md missing parent (expected → {expected})"
                ));
            }
            (Some(link), expected) => match resolve_link(scope, link) {
                Some(resolved) if maps.contains_key(&resolved) => {
                    if let Some(exp) = expected
                        && resolved != exp
                    {
                        v.push(format!(
                            "N3: {loc}/MAP.md parent resolves to {resolved}, nearest mapped ancestor is {exp}"
                        ));
                    }
                }
                Some(resolved) => {
                    v.push(format!(
                        "N3: {loc}/MAP.md parent → {resolved} which has no MAP.md"
                    ));
                }
                None => v.push(format!(
                    "N3: {loc}/MAP.md parent link {link} escapes the repo"
                )),
            },
        }

        // N4: children resolve and point back.
        for link in &map.children_links {
            match resolve_link(scope, link) {
                Some(child) if maps.contains_key(&child) => {
                    let child_map = &maps[&child];
                    let back = child_map
                        .parent_link
                        .as_deref()
                        .and_then(|l| resolve_link(&child, l));
                    if back.as_deref() != Some(scope.as_str()) {
                        v.push(format!(
                            "N4: {loc}/MAP.md lists child {child} whose parent does not point back"
                        ));
                    }
                }
                Some(child) => v.push(format!(
                    "N4: {loc}/MAP.md child → {child} which has no MAP.md"
                )),
                None => v.push(format!(
                    "N4: {loc}/MAP.md child link {link} escapes the repo"
                )),
            }
        }

        // N6: the body fits the tier budget.
        let budget = if scope.is_empty() {
            mapfile::BUDGET_ROOT
        } else if !map.children_links.is_empty() {
            mapfile::BUDGET_INNER
        } else {
            mapfile::BUDGET_LEAF
        };
        if map.bytes_actual > budget {
            v.push(format!(
                "N6: {loc}/MAP.md body {} bytes exceeds tier budget {budget}",
                map.bytes_actual
            ));
        }
    }

    // Staleness: recompute each scope's api_hash from the CURRENT cache,
    // owning files by nearest on-disk map.
    let mut stale = BTreeMap::new();
    let owner_of = |rel: &str| -> String {
        let dir = match rel.rfind('/') {
            Some(i) => &rel[..i],
            None => "",
        };
        let mut cur = dir.to_string();
        loop {
            if maps.contains_key(&cur) {
                return cur;
            }
            match cur.rfind('/') {
                Some(i) => cur.truncate(i),
                None => return String::new(),
            }
        }
    };
    let mut per_scope: BTreeMap<String, Vec<crate::extract::Symbol>> = BTreeMap::new();
    for (rel, entry) in &cache.files {
        let Some(lang) = entry.lang else { continue };
        let Some(x) = cache.parses.get(&(lang, entry.hash)) else {
            continue;
        };
        per_scope
            .entry(owner_of(rel))
            .or_default()
            .extend(x.defs.iter().cloned());
    }
    for (scope, map) in &maps {
        let symbols = per_scope.remove(scope).unwrap_or_default();
        let current = mapfile::api_hash(symbols.into_iter());
        let is_stale = map.api_hash.as_deref() != Some(current.as_str());
        if is_stale {
            let loc = if scope.is_empty() { "." } else { scope };
            v.push(format!("STALE: {loc}/MAP.md api_hash out of date"));
        }
        stale.insert(scope.clone(), is_stale);
    }

    // Static 4× rule: worst root→leaf chain of ≤4 maps within the byte cap.
    for (scope, _) in maps.iter().filter(|(_, m)| m.children_links.is_empty()) {
        let mut chain_bytes = 0usize;
        let mut hops = 0usize;
        let mut cur = Some(scope.clone());
        while let Some(s) = cur {
            if let Some(m) = maps.get(&s) {
                chain_bytes += m.bytes_actual;
                hops += 1;
            }
            if hops >= 4 {
                break;
            }
            cur = nearest_map_ancestor(&maps, &s).map(str::to_string);
        }
        if chain_bytes > CHAIN_BYTE_CAP {
            let loc = if scope.is_empty() { "." } else { scope };
            v.push(format!(
                "CHAIN: routing to {loc} costs {chain_bytes} body bytes over {hops} hops (cap {CHAIN_BYTE_CAP})"
            ));
        }
    }

    CheckReport {
        maps,
        violations: v,
        stale,
    }
}