Skip to main content

ailint_core/rules/consistency/
orphaned_document.rs

1//! AIL340 `orphaned-document` — a Markdown file exists in the corpus but is
2//! not reachable via local links from a root document (typically
3//! `README.md`).
4//!
5//! See: `docs/rules/consistency/AIL340.md`
6
7use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
8use std::path::{Path, PathBuf};
9
10use crate::file_type::FileType;
11use crate::parser::{DocumentContent, ParsedDocument};
12use crate::rules::consistency::AIL340;
13use crate::rules::{BatchRule, RuleContext, RuleId, Severity, Violation};
14
15/// AIL340 orphaned-document: flags guidance files no other document references.
16#[derive(Debug, Default)]
17pub struct OrphanedDocumentRule;
18
19impl BatchRule for OrphanedDocumentRule {
20    fn id(&self) -> RuleId {
21        AIL340
22    }
23
24    fn default_severity(&self) -> Severity {
25        Severity::Info
26    }
27
28    fn description(&self) -> &'static str {
29        "Document is not reachable via local links from any README or AGENTS root."
30    }
31
32    fn fix_hint(&self) -> &'static str {
33        "Link it from a README or AGENTS file, or delete it."
34    }
35
36    /// Applies to every Markdown document — this rule needs generic docs in
37    /// scope to discover islands.
38    fn applies_to(&self, file_type: FileType) -> bool {
39        file_type.is_markdown()
40    }
41
42    fn run_batch(&self, docs: &[ParsedDocument], _ctx: &RuleContext<'_>) -> Vec<Violation> {
43        // Canonicalize all doc paths for graph keys.
44        let mut canonical: HashMap<PathBuf, usize> = HashMap::new();
45        let mut paths: Vec<PathBuf> = Vec::with_capacity(docs.len());
46        for (i, d) in docs.iter().enumerate() {
47            let p = canonicalize(&d.path);
48            canonical.insert(p.clone(), i);
49            paths.push(p);
50        }
51
52        // Build adjacency list from resolved local links.
53        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); docs.len()];
54        for (i, d) in docs.iter().enumerate() {
55            let md = match &d.content {
56                DocumentContent::Markdown(m) => m,
57                _ => continue,
58            };
59            let doc_dir = d.path.parent().unwrap_or(Path::new(""));
60            for link in &md.links {
61                let Some(target) = resolve_link(doc_dir, &link.url) else {
62                    continue;
63                };
64                if let Some(&j) = canonical.get(&target) {
65                    adj[i].push(j);
66                }
67            }
68        }
69
70        // Roots for reachability:
71        //   * every AI guidance file (each is an entry point for its tool)
72        //   * the shallowest `README.md` and `AGENTS.md` files
73        let roots = find_roots(docs, &paths);
74        if roots.is_empty() {
75            return Vec::new();
76        }
77
78        // BFS reachability.
79        let mut reached: HashSet<usize> = HashSet::new();
80        let mut queue: VecDeque<usize> = VecDeque::new();
81        for &r in &roots {
82            reached.insert(r);
83            queue.push_back(r);
84        }
85        while let Some(i) = queue.pop_front() {
86            for &j in &adj[i] {
87                if reached.insert(j) {
88                    queue.push_back(j);
89                }
90            }
91        }
92
93        // Anything unreached is an orphan.
94        let mut out = Vec::new();
95        let mut orphans: BTreeSet<usize> = BTreeSet::new();
96        for i in 0..docs.len() {
97            if !reached.contains(&i) {
98                orphans.insert(i);
99            }
100        }
101        for i in orphans {
102            let doc = &docs[i];
103            let v = Violation::new(
104                AIL340,
105                self.default_severity(),
106                doc.path.clone(),
107                "orphaned document",
108            );
109            out.push(v);
110        }
111        out
112    }
113}
114
115fn canonicalize(path: &Path) -> PathBuf {
116    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
117}
118
119fn resolve_link(doc_dir: &Path, url: &str) -> Option<PathBuf> {
120    if url.is_empty() || url.starts_with('#') || url.starts_with("//") {
121        return None;
122    }
123    if url.starts_with("mailto:") || url.starts_with("tel:") {
124        return None;
125    }
126    if has_scheme(url) {
127        return None;
128    }
129    let path_part = match url.find('#') {
130        Some(i) => &url[..i],
131        None => url,
132    };
133    if path_part.is_empty() {
134        return None;
135    }
136    let joined = doc_dir.join(path_part.trim_start_matches('/'));
137    Some(canonicalize(&joined))
138}
139
140fn has_scheme(url: &str) -> bool {
141    let bytes = url.as_bytes();
142    if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
143        return false;
144    }
145    for (i, &b) in bytes.iter().enumerate().skip(1) {
146        if b == b':' {
147            return i > 0;
148        }
149        if !(b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.') {
150            return false;
151        }
152    }
153    false
154}
155
156/// Roots for reachability: every AI guidance document (each is an entry
157/// point for its tool) plus the shallowest `README.md` and `AGENTS.md`
158/// files (either counts as a project entry point).
159fn find_roots(docs: &[ParsedDocument], paths: &[PathBuf]) -> Vec<usize> {
160    let mut roots: BTreeSet<usize> = BTreeSet::new();
161
162    for (i, d) in docs.iter().enumerate() {
163        if d.file_type.is_ai_guidance() {
164            roots.insert(i);
165        }
166    }
167
168    // README.md and AGENTS.md are treated the same: shallowest instance(s)
169    // of each name count as roots. We compute the minimum depth per name
170    // separately so a shallow README doesn't shadow a deeper AGENTS or vice
171    // versa.
172    for target in ["README.md", "AGENTS.md"] {
173        let mut candidates: Vec<(usize, usize)> = Vec::new();
174        for (i, p) in paths.iter().enumerate() {
175            if p.file_name().and_then(|n| n.to_str()) == Some(target) {
176                candidates.push((i, p.components().count()));
177            }
178        }
179        if let Some(min_depth) = candidates.iter().map(|(_, d)| *d).min() {
180            for (i, d) in candidates {
181                if d == min_depth {
182                    roots.insert(i);
183                }
184            }
185        }
186    }
187
188    roots.into_iter().collect()
189}