normalize_native_rules/
boundary_violations.rs1use normalize_output::diagnostics::{DiagnosticsReport, Issue, Severity, ToolFailure};
24use std::path::Path;
25
26#[derive(Debug, Clone)]
28pub struct Boundary {
29 pub from_glob: String,
30 pub to_glob: String,
31 pub raw: String,
33}
34
35#[derive(serde::Deserialize, Default, Debug)]
38pub struct BoundaryViolationsConfig {
39 #[serde(default)]
41 pub boundaries: Vec<String>,
42}
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct BoundaryViolationFinding {
47 pub importer: String,
49 pub line: u32,
51 pub imported: String,
53 pub boundary: String,
55}
56
57pub fn parse_boundary(s: &str) -> Option<Boundary> {
60 let sep = " cannot import ";
61 let pos = s.find(sep)?;
62 let from_glob = s[..pos].trim().to_string();
63 let to_glob = s[pos + sep.len()..].trim().to_string();
64 if from_glob.is_empty() || to_glob.is_empty() {
65 return None;
66 }
67 Some(Boundary {
68 from_glob,
69 to_glob,
70 raw: s.to_string(),
71 })
72}
73
74fn compile_glob(raw: &str) -> Option<glob::Pattern> {
81 let expanded = if raw.ends_with('/') && !raw.contains('*') {
82 format!("{}**", raw)
83 } else {
84 raw.to_string()
85 };
86 glob::Pattern::new(&expanded).ok()
87}
88
89fn matches_glob(pattern: &glob::Pattern, path: &str) -> bool {
91 pattern.matches(path)
92 || pattern.matches_with(
93 path,
94 glob::MatchOptions {
95 case_sensitive: true,
96 require_literal_separator: false,
97 require_literal_leading_dot: false,
98 },
99 )
100}
101
102pub async fn build_boundary_violations_report(
108 root: &Path,
109 boundaries: &[Boundary],
110) -> DiagnosticsReport {
111 let mut report = DiagnosticsReport::new();
112
113 if boundaries.is_empty() {
114 return report;
115 }
116
117 let compiled: Vec<(glob::Pattern, glob::Pattern, &Boundary)> = boundaries
119 .iter()
120 .filter_map(|b| {
121 let from_pat = compile_glob(&b.from_glob)?;
122 let to_pat = compile_glob(&b.to_glob)?;
123 Some((from_pat, to_pat, b))
124 })
125 .collect();
126
127 if compiled.is_empty() {
128 return report;
129 }
130
131 let db_path = crate::check_refs::normalize_dir_for_root(root).join("index.sqlite");
133 let idx = match normalize_facts::FileIndex::open(&db_path, root).await {
134 Ok(idx) => idx,
135 Err(e) => {
136 report.tool_errors.push(ToolFailure {
137 tool: "boundary-violations".into(),
138 message: format!(
139 "failed to open index at {}: {}. Run `normalize structure rebuild` first.",
140 db_path.display(),
141 e
142 ),
143 });
144 return report;
145 }
146 };
147
148 let edges = match idx.all_resolved_imports_with_lines().await {
150 Ok(edges) => edges,
151 Err(e) => {
152 report.tool_errors.push(ToolFailure {
153 tool: "boundary-violations".into(),
154 message: format!("failed to query imports table: {e}"),
155 });
156 return report;
157 }
158 };
159
160 for (importer, line, imported) in &edges {
162 for (from_pat, to_pat, boundary) in &compiled {
163 if matches_glob(from_pat, importer) && matches_glob(to_pat, imported) {
164 report.issues.push(Issue {
165 file: importer.clone(),
166 line: Some(*line as usize),
167 column: None,
168 end_line: None,
169 end_column: None,
170 rule_id: "boundary-violations".into(),
171 message: format!(
172 "imports `{}` — violates boundary: {}",
173 imported, boundary.raw
174 ),
175 severity: Severity::Warning,
176 source: "boundary-violations".into(),
177 related: vec![],
178 suggestion: Some(
179 "move shared code to a layer both sides may depend on, or revise the boundary".into(),
180 ),
181 });
182 }
183 }
184 }
185
186 report.files_checked = edges
187 .iter()
188 .map(|(f, _, _)| f.as_str())
189 .collect::<std::collections::HashSet<_>>()
190 .len();
191
192 report.sources_run.push("boundary-violations".into());
193 report
194}