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                    );
78                }
79            }
80            // new-edge: a current edge has no locked target hash.
81            None => findings.push(
82                Finding::warn(
83                    "new-edge",
84                    &edge.source,
85                    edge_provenance(edge),
86                    "not locked",
87                )
88                .with_target(&edge.target),
89            ),
90        }
91    }
92
93    // removed-node and removed-edge: locked entries absent from the graph.
94    for (path, locked_node) in &lock.nodes {
95        match graph.nodes.get(path) {
96            None => {
97                // A removed node subsumes its removed-edge findings.
98                findings.push(Finding::warn(
99                    "removed-node",
100                    path,
101                    Vec::new(),
102                    "node is no longer present",
103                ));
104            }
105            Some(node) => {
106                for target in locked_node.edges.keys() {
107                    if !current_pairs.contains(&(path.as_str(), target.as_str())) {
108                        findings.push(
109                            Finding::warn(
110                                "removed-edge",
111                                path,
112                                provenance(&node.metadata),
113                                "edge no longer present",
114                            )
115                            .with_target(target),
116                        );
117                    }
118                }
119            }
120        }
121    }
122
123    findings
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::compose::compose;
130    use crate::model::{Edge, GraphSet, Node};
131    use serde_json::json;
132
133    fn fs_node(hash: &str) -> Node {
134        Node::new(
135            json!({ "type": "file", "hash": hash })
136                .as_object()
137                .unwrap()
138                .clone(),
139        )
140    }
141
142    /// fs fragment with `index.md -> setup.md`, then compose.
143    fn composed_with(index_hash: &str, setup_hash: &str) -> Graph {
144        let mut fs = Graph::labeled("fs");
145        fs.set_node("index.md", fs_node(index_hash));
146        fs.set_node("setup.md", fs_node(setup_hash));
147        fs.add_edge(Edge::new("index.md", "setup.md"));
148        compose(&GraphSet::new(vec![fs]))
149    }
150
151    fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
152        findings
153            .iter()
154            .map(|f| (f.name.as_str(), f.subject.as_str()))
155            .collect()
156    }
157
158    #[test]
159    fn edited_dependency_produces_stale_node_and_dependent_stale_edge() {
160        let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
161        // setup.md edited: its hash changed.
162        let current = composed_with("b3:idx", "b3:setup2");
163        let findings = evaluate(&current, &locked);
164
165        let n = names(&findings);
166        assert!(n.contains(&("stale-node", "setup.md")), "got {n:?}");
167        assert!(n.contains(&("stale-edge", "index.md")), "got {n:?}");
168    }
169
170    #[test]
171    fn clean_graph_has_no_findings() {
172        let composed = composed_with("b3:idx", "b3:setup");
173        let locked = Lock::from_composed(&composed);
174        assert!(evaluate(&composed, &locked).is_empty());
175    }
176
177    #[test]
178    fn stale_source_subsumes_its_outbound_stale_edge() {
179        // index.md edited AND setup.md edited: index is stale-node, so its
180        // outbound stale-edge to setup is subsumed; setup is stale-node.
181        let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
182        let current = composed_with("b3:idx2", "b3:setup2");
183        let findings = evaluate(&current, &locked);
184        let n = names(&findings);
185        assert!(n.contains(&("stale-node", "index.md")));
186        assert!(n.contains(&("stale-node", "setup.md")));
187        assert!(
188            !n.contains(&("stale-edge", "index.md")),
189            "stale node subsumes its outbound stale-edge, got {n:?}"
190        );
191    }
192
193    #[test]
194    fn removed_node_subsumes_its_outbound_removed_edge() {
195        // Lock index.md (with edge to setup.md) and setup.md; then remove the
196        // source node index.md entirely.
197        let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
198        let mut fs = Graph::labeled("fs");
199        fs.set_node("setup.md", fs_node("b3:setup"));
200        let current = compose(&GraphSet::new(vec![fs]));
201
202        let findings = evaluate(&current, &locked);
203        let n = names(&findings);
204        assert!(n.contains(&("removed-node", "index.md")), "got {n:?}");
205        assert!(
206            !n.iter().any(|(name, _)| *name == "removed-edge"),
207            "a removed source node subsumes its outbound removed-edge, got {n:?}"
208        );
209    }
210
211    #[test]
212    fn removed_edge_fires_when_source_survives() {
213        // index.md survives but no longer links setup.md.
214        let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
215        let mut fs = Graph::labeled("fs");
216        fs.set_node("index.md", fs_node("b3:idx"));
217        fs.set_node("setup.md", fs_node("b3:setup"));
218        let current = compose(&GraphSet::new(vec![fs]));
219
220        let findings = evaluate(&current, &locked);
221        assert!(names(&findings).contains(&("removed-edge", "index.md")));
222    }
223
224    #[test]
225    fn new_edge_when_unlocked_edge_appears() {
226        let locked = Lock::from_composed(&composed_with("b3:idx", "b3:setup"));
227        // index.md gains a new edge to extra.md.
228        let mut fs = Graph::labeled("fs");
229        fs.set_node("index.md", fs_node("b3:idx"));
230        fs.set_node("setup.md", fs_node("b3:setup"));
231        fs.set_node("extra.md", fs_node("b3:extra"));
232        fs.add_edge(Edge::new("index.md", "setup.md"));
233        fs.add_edge(Edge::new("index.md", "extra.md"));
234        let current = compose(&GraphSet::new(vec![fs]));
235
236        let findings = evaluate(&current, &locked);
237        assert!(names(&findings).contains(&("new-edge", "index.md")));
238    }
239}