Skip to main content

drft/rules/
staleness.rs

1//! Staleness rules: the drift findings derived by joining the composed graph to
2//! the lockfile. Staleness is computed locally — per node (`hash` vs locked) and
3//! per edge (target hash vs locked) — with no recursive propagation, so a
4//! dependency cycle can't loop or produce ambiguous staleness.
5//!
6//! Findings: `stale-node`, `stale-edge`, `new-edge`, `removed-edge`,
7//! `removed-node`. A stale node subsumes its outbound `stale-edge` findings; a
8//! removed node subsumes its `removed-edge` findings.
9
10use std::collections::HashSet;
11
12use crate::diagnostic::Finding;
13use crate::lock::Lock;
14use crate::model::{Graph, Node};
15use crate::rules::{edge_provenance, provenance, short_hash};
16
17/// Evaluate staleness findings for `graph` against `lock`.
18pub fn evaluate(graph: &Graph, lock: &Lock) -> Vec<Finding> {
19    let mut findings = Vec::new();
20    let mut stale_nodes: HashSet<&str> = HashSet::new();
21
22    // stale-node: a node's current hash differs from its locked hash.
23    for (path, node) in &graph.nodes {
24        if let (Some(current), Some(locked)) = (
25            node.fs_hash(),
26            lock.nodes.get(path).and_then(|n| n.hash.as_deref()),
27        ) && current != locked
28        {
29            stale_nodes.insert(path.as_str());
30            findings.push(Finding::warn(
31                "stale-node",
32                path,
33                provenance(&node.metadata),
34                format!(
35                    "hash {} ≠ locked {}",
36                    short_hash(current),
37                    short_hash(locked)
38                ),
39            ));
40        }
41    }
42
43    // Edge findings. Track current (source, target) pairs for removed-edge.
44    let mut current_pairs: HashSet<(&str, &str)> = HashSet::new();
45    for edge in &graph.edges {
46        current_pairs.insert((edge.source.as_str(), edge.target.as_str()));
47
48        let locked_target_hash = lock
49            .nodes
50            .get(&edge.source)
51            .and_then(|n| n.edges.get(&edge.target));
52
53        match locked_target_hash {
54            // The edge is locked: compare the target's hash to derive staleness.
55            Some(locked_hash) => {
56                // A stale source subsumes its outbound stale-edge findings.
57                if stale_nodes.contains(edge.source.as_str()) {
58                    continue;
59                }
60                let current_target_hash = graph.nodes.get(&edge.target).and_then(Node::fs_hash);
61                if let (Some(locked_hash), Some(current)) =
62                    (locked_hash.as_deref(), current_target_hash)
63                    && locked_hash != current
64                {
65                    findings.push(
66                        Finding::warn(
67                            "stale-edge",
68                            &edge.source,
69                            edge_provenance(edge),
70                            format!(
71                                "hash {} ≠ locked {}",
72                                short_hash(current),
73                                short_hash(locked_hash)
74                            ),
75                        )
76                        .with_target(&edge.target)
77                        .with_lines(edge.lines()),
78                    );
79                }
80            }
81            // new-edge: a current edge has no locked target hash.
82            None => findings.push(
83                Finding::warn(
84                    "new-edge",
85                    &edge.source,
86                    edge_provenance(edge),
87                    "not locked",
88                )
89                .with_target(&edge.target)
90                .with_lines(edge.lines()),
91            ),
92        }
93    }
94
95    // removed-node and removed-edge: locked entries absent from the graph.
96    for (path, locked_node) in &lock.nodes {
97        match graph.nodes.get(path) {
98            None => {
99                // A removed node subsumes its removed-edge findings.
100                findings.push(Finding::warn(
101                    "removed-node",
102                    path,
103                    Vec::new(),
104                    "node is no longer present",
105                ));
106            }
107            Some(node) => {
108                for target in locked_node.edges.keys() {
109                    if !current_pairs.contains(&(path.as_str(), target.as_str())) {
110                        findings.push(
111                            Finding::warn(
112                                "removed-edge",
113                                path,
114                                provenance(&node.metadata),
115                                "edge no longer present",
116                            )
117                            .with_target(target),
118                        );
119                    }
120                }
121            }
122        }
123    }
124
125    findings
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::compose::compose;
132    use crate::model::{Edge, GraphSet, Node};
133    use serde_json::json;
134
135    fn fs_node(hash: &str) -> Node {
136        Node::new(
137            json!({ "type": "file", "hash": hash })
138                .as_object()
139                .unwrap()
140                .clone(),
141        )
142    }
143
144    /// fs fragment with `index.md -> setup.md`, then compose.
145    fn composed_with(index_hash: &str, setup_hash: &str) -> Graph {
146        let mut fs = Graph::labeled("fs");
147        fs.set_node("index.md", fs_node(index_hash));
148        fs.set_node("setup.md", fs_node(setup_hash));
149        fs.add_edge(Edge::new("index.md", "setup.md"));
150        compose(&GraphSet::new(vec![fs]))
151    }
152
153    fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
154        findings
155            .iter()
156            .map(|f| (f.name.as_str(), f.subject.as_str()))
157            .collect()
158    }
159
160    #[test]
161    fn edited_dependency_produces_stale_node_and_dependent_stale_edge() {
162        let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
163        // setup.md edited: its hash changed.
164        let current = composed_with("b3:idx", "b3:setup2");
165        let findings = evaluate(&current, &locked);
166
167        let n = names(&findings);
168        assert!(n.contains(&("stale-node", "setup.md")), "got {n:?}");
169        assert!(n.contains(&("stale-edge", "index.md")), "got {n:?}");
170    }
171
172    #[test]
173    fn clean_graph_has_no_findings() {
174        let composed = composed_with("b3:idx", "b3:setup");
175        let locked = Lock::from_composed(&composed);
176        assert!(evaluate(&composed, &locked).is_empty());
177    }
178
179    #[test]
180    fn stale_source_subsumes_its_outbound_stale_edge() {
181        // index.md edited AND setup.md edited: index is stale-node, so its
182        // outbound stale-edge to setup is subsumed; setup is stale-node.
183        let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
184        let current = composed_with("b3:idx2", "b3:setup2");
185        let findings = evaluate(&current, &locked);
186        let n = names(&findings);
187        assert!(n.contains(&("stale-node", "index.md")));
188        assert!(n.contains(&("stale-node", "setup.md")));
189        assert!(
190            !n.contains(&("stale-edge", "index.md")),
191            "stale node subsumes its outbound stale-edge, got {n:?}"
192        );
193    }
194
195    #[test]
196    fn removed_node_subsumes_its_outbound_removed_edge() {
197        // Lock index.md (with edge to setup.md) and setup.md; then remove the
198        // source node index.md entirely.
199        let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
200        let mut fs = Graph::labeled("fs");
201        fs.set_node("setup.md", fs_node("b3:setup"));
202        let current = compose(&GraphSet::new(vec![fs]));
203
204        let findings = evaluate(&current, &locked);
205        let n = names(&findings);
206        assert!(n.contains(&("removed-node", "index.md")), "got {n:?}");
207        assert!(
208            !n.iter().any(|(name, _)| *name == "removed-edge"),
209            "a removed source node subsumes its outbound removed-edge, got {n:?}"
210        );
211    }
212
213    #[test]
214    fn removed_edge_fires_when_source_survives() {
215        // index.md survives but no longer links setup.md.
216        let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
217        let mut fs = Graph::labeled("fs");
218        fs.set_node("index.md", fs_node("b3:idx"));
219        fs.set_node("setup.md", fs_node("b3:setup"));
220        let current = compose(&GraphSet::new(vec![fs]));
221
222        let findings = evaluate(&current, &locked);
223        assert!(names(&findings).contains(&("removed-edge", "index.md")));
224    }
225
226    #[test]
227    fn new_edge_when_unlocked_edge_appears() {
228        let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
229        // index.md gains a new edge to extra.md.
230        let mut fs = Graph::labeled("fs");
231        fs.set_node("index.md", fs_node("b3:idx"));
232        fs.set_node("setup.md", fs_node("b3:setup"));
233        fs.set_node("extra.md", fs_node("b3:extra"));
234        fs.add_edge(Edge::new("index.md", "setup.md"));
235        fs.add_edge(Edge::new("index.md", "extra.md"));
236        let current = compose(&GraphSet::new(vec![fs]));
237
238        let findings = evaluate(&current, &locked);
239        assert!(names(&findings).contains(&("new-edge", "index.md")));
240    }
241}