Skip to main content

drft/analyses/
graph_boundaries.rs

1use super::{Analysis, AnalysisContext};
2use crate::lockfile::read_lockfile;
3
4#[derive(Debug, Clone, serde::Serialize)]
5pub struct GraphEscape {
6    pub source: String,
7    pub target: String,
8}
9
10#[derive(Debug, Clone, serde::Serialize)]
11pub struct EncapsulationViolation {
12    pub source: String,
13    pub target: String,
14    pub graph: String,
15}
16
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct GraphBoundariesResult {
19    pub sealed: bool,
20    pub escapes: Vec<GraphEscape>,
21    pub encapsulation_violations: Vec<EncapsulationViolation>,
22}
23
24pub struct GraphBoundaries;
25
26impl Analysis for GraphBoundaries {
27    type Output = GraphBoundariesResult;
28
29    fn name(&self) -> &str {
30        "graph-boundaries"
31    }
32
33    fn run(&self, ctx: &AnalysisContext) -> GraphBoundariesResult {
34        let graph = ctx.graph;
35        let root = ctx.root;
36        let sealed = root.join("drft.lock").exists() || root.join("drft.toml").exists();
37
38        // Find graph escapes: nodes with graph: ".."
39        let escapes = if sealed {
40            graph
41                .edges
42                .iter()
43                .filter(|edge| {
44                    graph
45                        .nodes
46                        .get(&edge.target)
47                        .is_some_and(|n| n.graph.as_deref() == Some(".."))
48                })
49                .map(|edge| GraphEscape {
50                    source: edge.source.clone(),
51                    target: edge.target.clone(),
52                })
53                .collect()
54        } else {
55            Vec::new()
56        };
57
58        // Find encapsulation violations
59        let mut encapsulation_violations = Vec::new();
60
61        for (path, node) in &graph.nodes {
62            if !node.is_graph {
63                continue;
64            }
65
66            let child_dir = root.join(path);
67
68            // Try reading interface from child lockfile
69            let interface_nodes = if let Ok(Some(lf)) = read_lockfile(&child_dir) {
70                match &lf.interface {
71                    Some(iface) => iface.files.clone(),
72                    None => continue, // No interface = open graph, no violations
73                }
74            } else {
75                // No lockfile — try reading child's drft.toml for interface
76                let child_config = crate::config::Config::load(&child_dir);
77                match child_config {
78                    Ok(config) => match config.interface {
79                        Some(iface) => iface.files,
80                        None => continue, // No interface = open graph
81                    },
82                    Err(_) => continue,
83                }
84            };
85
86            for edge in &graph.edges {
87                // Skip sources that aren't local (child-graph coupling edges, etc.)
88                if let Some(source_node) = graph.nodes.get(&edge.source)
89                    && source_node.graph.as_deref() != Some(".")
90                {
91                    continue;
92                }
93
94                // Check if this edge target belongs to this child graph
95                let target_node = match graph.nodes.get(&edge.target) {
96                    Some(n) => n,
97                    None => continue,
98                };
99                if target_node.graph.as_deref() != Some(path.as_str()) {
100                    continue;
101                }
102
103                // Strip the child graph prefix to get the relative path
104                let prefix = format!("{path}/");
105                let relative_target = match edge.target.strip_prefix(&prefix) {
106                    Some(rel) => rel,
107                    None => continue,
108                };
109                if !interface_nodes.iter().any(|n| n == relative_target) {
110                    encapsulation_violations.push(EncapsulationViolation {
111                        source: edge.source.clone(),
112                        target: edge.target.clone(),
113                        graph: path.clone(),
114                    });
115                }
116            }
117        }
118
119        GraphBoundariesResult {
120            sealed,
121            escapes,
122            encapsulation_violations,
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::analyses::AnalysisContext;
131    use crate::config::Config;
132    use crate::graph::test_helpers::{make_edge, make_node};
133    use crate::graph::{Graph, Node, NodeType};
134    use crate::lockfile::{Lockfile, LockfileInterface, LockfileNode, write_lockfile};
135    use std::collections::BTreeMap;
136    use std::collections::HashMap;
137    use std::fs;
138    use std::path::Path;
139    use tempfile::TempDir;
140
141    fn make_ctx<'a>(graph: &'a Graph, root: &'a Path, config: &'a Config) -> AnalysisContext<'a> {
142        AnalysisContext {
143            graph,
144            root,
145            config,
146            lockfile: None,
147        }
148    }
149
150    #[test]
151    fn detects_graph_escape() {
152        let dir = TempDir::new().unwrap();
153        fs::write(dir.path().join("drft.lock"), "lockfile_version = 2\n").unwrap();
154
155        let mut graph = Graph::new();
156        graph.add_node(make_node("index.md"));
157        graph.add_node(Node {
158            path: "../README.md".into(),
159            node_type: NodeType::External,
160            hash: None,
161            graph: Some("..".into()),
162            is_graph: false,
163            metadata: HashMap::new(),
164        });
165        graph.add_edge(make_edge("index.md", "../README.md"));
166
167        let config = Config::defaults();
168        let ctx = make_ctx(&graph, dir.path(), &config);
169        let result = GraphBoundaries.run(&ctx);
170        assert!(result.sealed);
171        assert_eq!(result.escapes.len(), 1);
172        assert_eq!(result.escapes[0].target, "../README.md");
173    }
174
175    #[test]
176    fn no_escape_without_lockfile() {
177        let dir = TempDir::new().unwrap();
178
179        let mut graph = Graph::new();
180        graph.add_node(make_node("index.md"));
181        graph.add_edge(make_edge("index.md", "../README.md"));
182
183        let config = Config::defaults();
184        let ctx = make_ctx(&graph, dir.path(), &config);
185        let result = GraphBoundaries.run(&ctx);
186        assert!(!result.sealed);
187        assert!(result.escapes.is_empty());
188    }
189
190    #[test]
191    fn detects_encapsulation_violation() {
192        let dir = TempDir::new().unwrap();
193        let research = dir.path().join("research");
194        fs::create_dir_all(&research).unwrap();
195        fs::write(research.join("overview.md"), "# Overview").unwrap();
196        fs::write(research.join("internal.md"), "# Internal").unwrap();
197
198        let mut nodes = BTreeMap::new();
199        nodes.insert(
200            "overview.md".into(),
201            LockfileNode {
202                node_type: NodeType::File,
203                hash: Some("b3:aaa".into()),
204                graph: None,
205            },
206        );
207        let lockfile = Lockfile {
208            lockfile_version: 2,
209            interface: Some(LockfileInterface {
210                files: vec!["overview.md".into()],
211            }),
212            nodes,
213        };
214        write_lockfile(&research, &lockfile).unwrap();
215
216        let mut graph = Graph::new();
217        graph.add_node(make_node("index.md"));
218        graph.add_node(Node {
219            path: "research".into(),
220            node_type: NodeType::Directory,
221            hash: None,
222            graph: Some(".".into()),
223            is_graph: true,
224            metadata: HashMap::new(),
225        });
226        graph.add_node(Node {
227            path: "research/internal.md".into(),
228            node_type: NodeType::External,
229            hash: None,
230            graph: Some("research".into()),
231            is_graph: false,
232            metadata: HashMap::new(),
233        });
234        graph.add_edge(make_edge("index.md", "research/internal.md"));
235
236        let config = Config::defaults();
237        let ctx = make_ctx(&graph, dir.path(), &config);
238        let result = GraphBoundaries.run(&ctx);
239        assert_eq!(result.encapsulation_violations.len(), 1);
240        assert_eq!(
241            result.encapsulation_violations[0].target,
242            "research/internal.md"
243        );
244        assert_eq!(result.encapsulation_violations[0].graph, "research");
245    }
246
247    #[test]
248    fn interface_file_is_not_violation() {
249        let dir = TempDir::new().unwrap();
250        let research = dir.path().join("research");
251        fs::create_dir_all(&research).unwrap();
252        fs::write(research.join("overview.md"), "# Overview").unwrap();
253
254        let mut nodes = BTreeMap::new();
255        nodes.insert(
256            "overview.md".into(),
257            LockfileNode {
258                node_type: NodeType::File,
259                hash: Some("b3:aaa".into()),
260                graph: None,
261            },
262        );
263        let lockfile = Lockfile {
264            lockfile_version: 2,
265            interface: Some(LockfileInterface {
266                files: vec!["overview.md".into()],
267            }),
268            nodes,
269        };
270        write_lockfile(&research, &lockfile).unwrap();
271
272        let mut graph = Graph::new();
273        graph.add_node(make_node("index.md"));
274        graph.add_node(Node {
275            path: "research".into(),
276            node_type: NodeType::Directory,
277            hash: None,
278            graph: Some(".".into()),
279            is_graph: true,
280            metadata: HashMap::new(),
281        });
282        graph.add_node(Node {
283            path: "research/overview.md".into(),
284            node_type: NodeType::External,
285            hash: None,
286            graph: Some("research".into()),
287            is_graph: false,
288            metadata: HashMap::new(),
289        });
290        graph.add_edge(make_edge("index.md", "research/overview.md"));
291
292        let config = Config::defaults();
293        let ctx = make_ctx(&graph, dir.path(), &config);
294        let result = GraphBoundaries.run(&ctx);
295        assert!(result.encapsulation_violations.is_empty());
296    }
297}