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    /// Applies to every Markdown document — this rule needs generic docs in
29    /// scope to discover islands.
30    fn applies_to(&self, file_type: FileType) -> bool {
31        file_type.is_markdown()
32    }
33
34    fn run_batch(&self, docs: &[ParsedDocument], _ctx: &RuleContext<'_>) -> Vec<Violation> {
35        // Canonicalize all doc paths for graph keys.
36        let mut canonical: HashMap<PathBuf, usize> = HashMap::new();
37        let mut paths: Vec<PathBuf> = Vec::with_capacity(docs.len());
38        for (i, d) in docs.iter().enumerate() {
39            let p = canonicalize(&d.path);
40            canonical.insert(p.clone(), i);
41            paths.push(p);
42        }
43
44        // Build adjacency list from resolved local links.
45        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); docs.len()];
46        for (i, d) in docs.iter().enumerate() {
47            let md = match &d.content {
48                DocumentContent::Markdown(m) => m,
49                _ => continue,
50            };
51            let doc_dir = d.path.parent().unwrap_or(Path::new(""));
52            for link in &md.links {
53                let Some(target) = resolve_link(doc_dir, &link.url) else {
54                    continue;
55                };
56                if let Some(&j) = canonical.get(&target) {
57                    adj[i].push(j);
58                }
59            }
60        }
61
62        // Roots for reachability:
63        //   * every AI guidance file (each is an entry point for its tool)
64        //   * the shallowest `README.md` files (for generic project docs)
65        let roots = find_roots(docs, &paths);
66        if roots.is_empty() {
67            return Vec::new();
68        }
69
70        // BFS reachability.
71        let mut reached: HashSet<usize> = HashSet::new();
72        let mut queue: VecDeque<usize> = VecDeque::new();
73        for &r in &roots {
74            reached.insert(r);
75            queue.push_back(r);
76        }
77        while let Some(i) = queue.pop_front() {
78            for &j in &adj[i] {
79                if reached.insert(j) {
80                    queue.push_back(j);
81                }
82            }
83        }
84
85        // Anything unreached is an orphan.
86        let mut out = Vec::new();
87        let mut orphans: BTreeSet<usize> = BTreeSet::new();
88        for i in 0..docs.len() {
89            if !reached.contains(&i) {
90                orphans.insert(i);
91            }
92        }
93        for i in orphans {
94            let doc = &docs[i];
95            let mut v = Violation::new(
96                AIL340,
97                self.default_severity(),
98                doc.path.clone(),
99                "document is not reachable via local links from any README root",
100            )
101            .at(1, 1);
102            v.fix_hint = Some(
103                "add a link from a README or another reachable document, or remove the file"
104                    .to_string(),
105            );
106            out.push(v);
107        }
108        out
109    }
110}
111
112fn canonicalize(path: &Path) -> PathBuf {
113    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
114}
115
116fn resolve_link(doc_dir: &Path, url: &str) -> Option<PathBuf> {
117    if url.is_empty() || url.starts_with('#') || url.starts_with("//") {
118        return None;
119    }
120    if url.starts_with("mailto:") || url.starts_with("tel:") {
121        return None;
122    }
123    if has_scheme(url) {
124        return None;
125    }
126    let path_part = match url.find('#') {
127        Some(i) => &url[..i],
128        None => url,
129    };
130    if path_part.is_empty() {
131        return None;
132    }
133    let joined = doc_dir.join(path_part.trim_start_matches('/'));
134    Some(canonicalize(&joined))
135}
136
137fn has_scheme(url: &str) -> bool {
138    let bytes = url.as_bytes();
139    if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
140        return false;
141    }
142    for (i, &b) in bytes.iter().enumerate().skip(1) {
143        if b == b':' {
144            return i > 0;
145        }
146        if !(b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.') {
147            return false;
148        }
149    }
150    false
151}
152
153/// Roots for reachability: every AI guidance document (each is an entry
154/// point for its tool) plus the shallowest `README.md` files.
155fn find_roots(docs: &[ParsedDocument], paths: &[PathBuf]) -> Vec<usize> {
156    let mut roots: BTreeSet<usize> = BTreeSet::new();
157
158    for (i, d) in docs.iter().enumerate() {
159        if d.file_type.is_ai_guidance() {
160            roots.insert(i);
161        }
162    }
163
164    let mut readme_candidates: Vec<(usize, usize)> = Vec::new();
165    for (i, p) in paths.iter().enumerate() {
166        if p.file_name().and_then(|n| n.to_str()) == Some("README.md") {
167            readme_candidates.push((i, p.components().count()));
168        }
169    }
170    if let Some(min_depth) = readme_candidates.iter().map(|(_, d)| *d).min() {
171        for (i, d) in readme_candidates {
172            if d == min_depth {
173                roots.insert(i);
174            }
175        }
176    }
177
178    roots.into_iter().collect()
179}