ailint_core/rules/consistency/
orphaned_document.rs1use 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#[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 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 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 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 let roots = find_roots(docs, &paths);
74 if roots.is_empty() {
75 return Vec::new();
76 }
77
78 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 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
156fn 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 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}