Skip to main content

normalize_native_rules/
boundary_violations.rs

1//! `boundary-violations` native rule — flags cross-boundary imports.
2//!
3//! Users declare directory-level import boundaries in config. The rule checks
4//! all resolved imports in the structural index and reports any that violate a
5//! declared boundary.
6//!
7//! # Configuration
8//!
9//! ```toml
10//! [rules.rule."boundary-violations"]
11//! enabled = true
12//! boundaries = [
13//!   "services/ cannot import cli/",
14//!   "crates/normalize-facts/ cannot import crates/normalize/",
15//! ]
16//! ```
17//!
18//! Each boundary string has the form `"<from_glob> cannot import <to_glob>"`.
19//! Both globs are matched against root-relative file paths using [`glob::Pattern`].
20//! Trailing `/` is allowed — it is treated as a prefix match (the pattern
21//! `services/` matches any path that starts with `services/`).
22
23use normalize_output::diagnostics::{DiagnosticsReport, Issue, Severity, ToolFailure};
24use std::path::Path;
25
26/// A single configured boundary constraint.
27#[derive(Debug, Clone)]
28pub struct Boundary {
29    pub from_glob: String,
30    pub to_glob: String,
31    /// The original string the user wrote (for error messages).
32    pub raw: String,
33}
34
35/// Config for the `boundary-violations` rule, deserialized from
36/// `[rules.rule."boundary-violations"]` in `.normalize/config.toml`.
37#[derive(serde::Deserialize, Default, Debug)]
38pub struct BoundaryViolationsConfig {
39    /// Boundary strings in the form `"<from> cannot import <to>"`.
40    #[serde(default)]
41    pub boundaries: Vec<String>,
42}
43
44/// One boundary-violation finding.
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct BoundaryViolationFinding {
47    /// Root-relative path of the importing file.
48    pub importer: String,
49    /// Line of the import statement.
50    pub line: u32,
51    /// Root-relative path of the imported file.
52    pub imported: String,
53    /// The violated boundary string (for the message).
54    pub boundary: String,
55}
56
57/// Parse a boundary string of the form `"A cannot import B"`.
58/// Returns `None` if the string doesn't match the expected format.
59pub 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
74/// Expand a glob-like pattern that may end with `/` into a `glob::Pattern`
75/// suitable for matching root-relative file paths.
76///
77/// A trailing `/` means "any file under this directory prefix", so
78/// `services/` → `services/**`. Patterns that already contain `*` are passed
79/// through unchanged.
80fn 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
89/// Check whether a root-relative path matches a boundary side's glob.
90fn 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
102/// Build a `DiagnosticsReport` for the `boundary-violations` rule.
103///
104/// Requires the structural index (run `normalize structure rebuild` first).
105/// Returns an empty report (with a hint in `tool_errors`) if the index is
106/// absent or the boundaries list is empty.
107pub 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    // Compile glob patterns once.
118    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    // Open the structural index.
132    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    // Load all resolved import edges with line numbers.
149    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 each edge, check against every boundary.
161    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}