use std::collections::BTreeMap;
use std::path::Path;
use crate::cache::ScanCache;
use crate::frontmatter;
use crate::mapfile::{self, CHAIN_BYTE_CAP};
#[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>,
pub stale: BTreeMap<String, bool>,
}
const SKIP_DIRS: [&str; 16] = [
".git",
".radar",
"target",
"node_modules",
"dist",
"build",
".venv",
"venv",
"__pycache__",
"vendor",
"third_party",
".idea",
".vscode",
".cache",
".tox",
"coverage",
];
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),
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
}
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("/"))
}
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;
}
}
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();
}
if !maps.contains_key("") {
v.push("N1: no root MAP.md (the tree has no anchor)".to_string());
}
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 };
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"
)),
},
}
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"
)),
}
}
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
));
}
}
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);
}
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,
}
}