Skip to main content

drft/rules/
structural.rs

1//! Structural rules: findings derived from graph shape alone, no lockfile.
2//!
3//! Findings: `unresolved-edge` (an edge target with no `@fs` block — no defining
4//! node) and `detached-node` (a node with no inbound or outbound edges). URI
5//! targets are intentional external references, not unresolved.
6
7use std::collections::HashSet;
8
9use crate::diagnostic::Finding;
10use crate::model::{Graph, Node};
11use crate::rules::{edge_provenance, provenance};
12use crate::util::is_uri;
13
14/// Evaluate structural findings for `graph`.
15pub fn evaluate(graph: &Graph) -> Vec<Finding> {
16    let mut findings = Vec::new();
17
18    // unresolved-edge: a non-URI edge target with no defining node.
19    for edge in &graph.edges {
20        if is_uri(&edge.target) {
21            continue;
22        }
23        let resolved = graph.nodes.get(&edge.target).is_some_and(Node::is_resolved);
24        if !resolved {
25            let mut finding = Finding::warn(
26                "unresolved-edge",
27                &edge.source,
28                edge_provenance(edge),
29                "no defining node",
30            )
31            .with_target(&edge.target)
32            .with_lines(edge.lines());
33            if let Some(hint) = wrong_base_hint(graph, edge) {
34                finding = finding.with_hint(hint);
35            }
36            findings.push(finding);
37        }
38    }
39
40    // detached-node: a file touched by no edge in either direction. Directories
41    // are structural scaffolding — links point at the files inside them, not at
42    // the directory — so a link-less directory is normal, not orphaned content.
43    let mut connected: HashSet<&str> = HashSet::new();
44    for edge in &graph.edges {
45        connected.insert(edge.source.as_str());
46        connected.insert(edge.target.as_str());
47    }
48    for (path, node) in &graph.nodes {
49        if node.fs_type() == Some("directory") {
50            continue;
51        }
52        if !connected.contains(path.as_str()) {
53            findings.push(Finding::warn(
54                "detached-node",
55                path,
56                provenance(&node.metadata),
57                "no connections",
58            ));
59        }
60    }
61
62    findings
63}
64
65/// A path written against the graph root when links resolve against the
66/// declaring file reads as a typo — the reported target is a path the author
67/// never wrote. Name the cause when the literal text resolves from the root.
68///
69/// Gated on the raw text carrying no explicit `./`, `../` or `/` prefix: those
70/// are unambiguously relative by intent, so a root file of the same name is a
71/// coincidence rather than the mistake. That leaves the bare-path case, where a
72/// hit is all but certainly a wrong base.
73fn wrong_base_hint(graph: &Graph, edge: &crate::model::Edge) -> Option<String> {
74    let raw = edge.raw_links().into_iter().find(|raw| {
75        !(raw.starts_with("./") || raw.starts_with("../") || raw.starts_with('/'))
76            && graph.nodes.get(*raw).is_some_and(Node::is_resolved)
77    })?;
78    let suggestion = crate::util::relative_from(&edge.source, raw);
79    Some(format!(
80        "`{raw}` resolves from the graph root, but paths resolve relative to the declaring file (did you mean `{suggestion}`?)"
81    ))
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::compose::compose;
88    use crate::model::{Edge, GraphSet, Metadata, Node};
89    use serde_json::json;
90
91    fn fs_node() -> Node {
92        Node::new(
93            json!({ "type": "file", "hash": "b3:x" })
94                .as_object()
95                .unwrap()
96                .clone(),
97        )
98    }
99
100    fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
101        findings
102            .iter()
103            .map(|f| (f.name.as_str(), f.subject.as_str()))
104            .collect()
105    }
106
107    #[test]
108    fn flags_unresolved_target() {
109        let mut fs = Graph::labeled("fs");
110        fs.set_node("index.md", fs_node());
111        fs.add_edge(Edge::new("index.md", "gone.md"));
112        let composed = compose(&GraphSet::new(vec![fs]));
113
114        let findings = evaluate(&composed);
115        assert!(names(&findings).contains(&("unresolved-edge", "index.md")));
116    }
117
118    /// Compose an fs graph holding `files`, plus one edge carrying `raw`.
119    fn graph_with_raw_edge(files: &[&str], source: &str, target: &str, raw: &str) -> Graph {
120        let mut fs = Graph::labeled("fs");
121        for f in files {
122            fs.set_node(*f, fs_node());
123        }
124        let mut meta = Metadata::new();
125        meta.insert("raw".into(), json!(raw));
126        fs.add_edge(Edge::with_metadata(source, target, meta));
127        compose(&GraphSet::new(vec![fs]))
128    }
129
130    #[test]
131    fn wrong_base_hint_names_the_cause() {
132        // The #72 case: a repo-relative path in a doc one level down. The target
133        // reported is a path nobody wrote, so the finding reads as a typo.
134        let composed = graph_with_raw_edge(
135            &["docs/taxonomy.md", "predicated/artifact/src/lib.rs"],
136            "docs/taxonomy.md",
137            "docs/predicated/artifact/src/lib.rs",
138            "predicated/artifact/src/lib.rs",
139        );
140        let findings = evaluate(&composed);
141        let hint = findings
142            .iter()
143            .find(|f| f.name == "unresolved-edge")
144            .and_then(|f| f.hint.as_deref())
145            .expect("expected a hint");
146        assert!(hint.contains("resolves from the graph root"), "got: {hint}");
147        assert!(
148            hint.contains("../predicated/artifact/src/lib.rs"),
149            "suggestion missing: {hint}"
150        );
151    }
152
153    #[test]
154    fn no_hint_when_root_path_also_missing() {
155        // An ordinary typo: nothing resolves either way, so there is no cause to
156        // name and the finding stands on its own.
157        let composed = graph_with_raw_edge(
158            &["docs/taxonomy.md"],
159            "docs/taxonomy.md",
160            "docs/typo.rs",
161            "typo.rs",
162        );
163        let findings = evaluate(&composed);
164        let f = findings
165            .iter()
166            .find(|f| f.name == "unresolved-edge")
167            .unwrap();
168        assert!(f.hint.is_none(), "got: {:?}", f.hint);
169    }
170
171    #[test]
172    fn no_hint_for_explicitly_relative_paths() {
173        // `./x.md` is relative by intent. A root `x.md` of the same name is a
174        // coincidence, not a wrong base — hinting here would be noise.
175        let composed = graph_with_raw_edge(
176            &["docs/taxonomy.md", "x.md"],
177            "docs/taxonomy.md",
178            "docs/x.md",
179            "./x.md",
180        );
181        let findings = evaluate(&composed);
182        let f = findings
183            .iter()
184            .find(|f| f.name == "unresolved-edge")
185            .unwrap();
186        assert!(f.hint.is_none(), "got: {:?}", f.hint);
187    }
188
189    #[test]
190    fn unresolved_edge_carries_link_lines() {
191        // A markdown link to a missing target on line 3 — the finding points there.
192        let mut markdown = Graph::labeled("markdown");
193        let mut meta = Metadata::new();
194        meta.insert("lines".into(), json!([3]));
195        markdown.add_edge(Edge::with_metadata("index.md", "gone.md", meta));
196        let mut fs = Graph::labeled("fs");
197        fs.set_node("index.md", fs_node());
198        let composed = compose(&GraphSet::new(vec![fs, markdown]));
199
200        let findings = evaluate(&composed);
201        let f = findings
202            .iter()
203            .find(|f| f.name == "unresolved-edge")
204            .unwrap();
205        assert_eq!(f.lines, vec![3]);
206        assert!(
207            f.format_text().contains("index.md:3 → gone.md"),
208            "got: {}",
209            f.format_text()
210        );
211    }
212
213    #[test]
214    fn does_not_flag_uri_target() {
215        let mut markdown = Graph::labeled("markdown");
216        markdown.add_edge(Edge::new("index.md", "https://example.com"));
217        let mut fs = Graph::labeled("fs");
218        fs.set_node("index.md", fs_node());
219        let composed = compose(&GraphSet::new(vec![fs, markdown]));
220
221        assert!(
222            !names(&evaluate(&composed))
223                .iter()
224                .any(|(name, _)| *name == "unresolved-edge")
225        );
226    }
227
228    #[test]
229    fn flags_detached_node() {
230        let mut fs = Graph::labeled("fs");
231        fs.set_node("lonely.md", fs_node());
232        fs.set_node("a.md", fs_node());
233        fs.set_node("b.md", fs_node());
234        fs.add_edge(Edge::new("a.md", "b.md"));
235        let composed = compose(&GraphSet::new(vec![fs]));
236
237        let findings = evaluate(&composed);
238        let n = names(&findings);
239        assert!(n.contains(&("detached-node", "lonely.md")), "got {n:?}");
240        assert!(!n.contains(&("detached-node", "a.md")));
241        assert!(!n.contains(&("detached-node", "b.md")));
242    }
243
244    #[test]
245    fn directory_node_is_not_detached() {
246        // A link-less directory is scaffolding, not orphaned content.
247        let mut fs = Graph::labeled("fs");
248        fs.set_node(
249            "guides",
250            Node::new(json!({ "type": "directory" }).as_object().unwrap().clone()),
251        );
252        fs.set_node("lonely.md", fs_node());
253        let composed = compose(&GraphSet::new(vec![fs]));
254
255        let findings = evaluate(&composed);
256        let n = names(&findings);
257        assert!(
258            !n.contains(&("detached-node", "guides")),
259            "directory should not be flagged detached, got {n:?}"
260        );
261        // A genuinely orphaned file is still flagged.
262        assert!(n.contains(&("detached-node", "lonely.md")));
263    }
264}