Skip to main content

agm_core/validator/
mod.rs

1//! Validator: checks a parsed AST for AGM spec compliance.
2//!
3//! Produces a `DiagnosticCollection` of errors and warnings covering file-level
4//! checks, node-level checks, reference resolution, cycle detection, field
5//! compatibility, code block validation, and more.
6//!
7//! # Validation passes
8//!
9//! 1. **File-level** (`file`): header required fields, at least one node.
10//! 2. **Node-level** (`node`): ID pattern/uniqueness, summary, dates, status.
11//! 3. **Structural per-node** (`code`, `verify`, `context`, `orchestration`,
12//!    `execution`, `memory`): structured sub-field validation.
13//! 4. **Type schema** (`type_schema`): per-type field schema enforcement.
14//! 5. **Cross-node** (`references`, `cycles`, `compatibility`): reference
15//!    resolution, cycle detection, conflict detection.
16//! 6. **Cross-package** (`imports`): import resolution and cross-package refs.
17//!    Only runs when `ValidateOptions.import_resolver` is `Some`.
18
19use std::collections::HashSet;
20use std::sync::Arc;
21
22use crate::error::diagnostic::{AgmError, DiagnosticCollection};
23use crate::import::ImportResolver;
24use crate::model::file::AgmFile;
25use crate::model::schema::EnforcementLevel;
26
27pub mod code;
28pub mod compatibility;
29pub mod context;
30pub mod cycles;
31pub mod execution;
32pub mod file;
33pub mod imports;
34pub mod memory;
35pub mod node;
36pub mod orchestration;
37pub mod references;
38pub mod ticket;
39pub mod type_schema;
40pub mod verify;
41
42// ---------------------------------------------------------------------------
43// ValidateOptions
44// ---------------------------------------------------------------------------
45
46/// Options controlling validation behavior.
47///
48/// Note: `import_resolver` uses `Arc<dyn ImportResolver + Send + Sync>` to allow
49/// the struct to be `Clone` while still supporting dynamic dispatch.
50#[derive(Clone)]
51pub struct ValidateOptions {
52    /// Schema enforcement level applied in Pass 4.
53    /// Default: `Standard`.
54    pub enforcement_level: EnforcementLevel,
55    /// Optional import resolver for cross-package validation (Pass 6).
56    /// If `None`, import-related rules (I001–I005) are skipped.
57    pub import_resolver: Option<Arc<dyn ImportResolver + Send + Sync>>,
58}
59
60impl std::fmt::Debug for ValidateOptions {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("ValidateOptions")
63            .field("enforcement_level", &self.enforcement_level)
64            .field("import_resolver", &self.import_resolver.is_some())
65            .finish()
66    }
67}
68
69impl Default for ValidateOptions {
70    fn default() -> Self {
71        Self {
72            enforcement_level: EnforcementLevel::Standard,
73            import_resolver: None,
74        }
75    }
76}
77
78// ---------------------------------------------------------------------------
79// validate()
80// ---------------------------------------------------------------------------
81
82/// Validates a parsed AGM file against all spec rules.
83///
84/// Runs all validation passes in dependency order and returns a
85/// `DiagnosticCollection` with all diagnostics sorted by line number
86/// (ascending), then by severity (errors before warnings at the same line).
87#[must_use]
88pub fn validate(
89    agm_file: &AgmFile,
90    source: &str,
91    file_name: &str,
92    options: &ValidateOptions,
93) -> DiagnosticCollection {
94    let mut collection = DiagnosticCollection::new(file_name, source);
95    let mut all_errors: Vec<AgmError> = Vec::new();
96
97    // Pass 1: File-level checks
98    all_errors.extend(file::validate_file(agm_file, file_name));
99
100    // Pass 2: Node-level checks (also builds all_ids set for later passes)
101    all_errors.extend(node::validate_node_ids(&agm_file.nodes, file_name));
102    let all_ids: HashSet<String> = agm_file.nodes.iter().map(|n| n.id.clone()).collect();
103    for n in &agm_file.nodes {
104        all_errors.extend(node::validate_node(n, &all_ids, file_name));
105    }
106
107    // Build set of all memory topics declared across nodes (used in Pass 3)
108    let all_memory_topics: HashSet<String> = agm_file
109        .nodes
110        .iter()
111        .filter_map(|n| n.memory.as_ref())
112        .flat_map(|entries| entries.iter().map(|e| e.topic.clone()))
113        .collect();
114
115    // Pass 3: Structural per-node checks
116    for n in &agm_file.nodes {
117        all_errors.extend(code::validate_code(n, file_name));
118        all_errors.extend(verify::validate_verify(n, &all_ids, file_name));
119        all_errors.extend(context::validate_context(
120            n,
121            &all_ids,
122            &all_memory_topics,
123            file_name,
124        ));
125        all_errors.extend(orchestration::validate_orchestration(
126            n, &all_ids, file_name,
127        ));
128        all_errors.extend(execution::validate_execution(n, file_name));
129        all_errors.extend(memory::validate_memory(n, file_name));
130        all_errors.extend(ticket::validate_ticket(n, file_name));
131    }
132
133    // Pass 4: Type schema enforcement
134    for n in &agm_file.nodes {
135        all_errors.extend(type_schema::validate_type_schema(
136            n,
137            &options.enforcement_level,
138            file_name,
139        ));
140    }
141
142    // Pass 5: Cross-node checks (only meaningful if there are nodes)
143    if !agm_file.nodes.is_empty() {
144        all_errors.extend(references::validate_references(
145            agm_file, &all_ids, file_name,
146        ));
147        all_errors.extend(cycles::validate_cycles(agm_file, file_name));
148        all_errors.extend(compatibility::validate_compatibility(agm_file, file_name));
149    }
150
151    // Pass 6: Cross-package checks (only runs with an import resolver)
152    if let Some(ref resolver) = options.import_resolver {
153        all_errors.extend(imports::validate_imports(
154            agm_file,
155            resolver.as_ref(),
156            file_name,
157        ));
158    }
159
160    // Sort: ascending line number, then errors before warnings before info
161    sort_diagnostics(&mut all_errors);
162
163    collection.extend(all_errors);
164    collection
165}
166
167/// Sorts diagnostics by line number (ascending), then by severity
168/// (errors before warnings before info at the same line number).
169fn sort_diagnostics(errors: &mut [AgmError]) {
170    errors.sort_by(|a, b| {
171        let line_a = a.location.line.unwrap_or(0);
172        let line_b = b.location.line.unwrap_or(0);
173
174        line_a
175            .cmp(&line_b)
176            .then_with(|| severity_rank(a.severity).cmp(&severity_rank(b.severity)))
177    });
178}
179
180/// Maps severity to a sort rank: Error=0 (highest), Warning=1, Info=2.
181fn severity_rank(sev: crate::error::diagnostic::Severity) -> u8 {
182    use crate::error::diagnostic::Severity;
183    match sev {
184        Severity::Error => 0,
185        Severity::Warning => 1,
186        Severity::Info => 2,
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Tests
192// ---------------------------------------------------------------------------
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::error::codes::ErrorCode;
198    use crate::model::fields::{NodeType, Span};
199    use crate::model::file::{AgmFile, Header};
200    use crate::model::node::Node;
201
202    fn valid_header() -> Header {
203        Header {
204            agm: "1.0".to_owned(),
205            package: "test.pkg".to_owned(),
206            version: "0.1.0".to_owned(),
207            title: None,
208            owner: None,
209            imports: None,
210            default_load: None,
211            description: None,
212            tags: None,
213            status: None,
214            load_profiles: None,
215            target_runtime: None,
216        }
217    }
218
219    fn minimal_node(id: &str, line: usize) -> Node {
220        Node {
221            id: id.to_owned(),
222            node_type: NodeType::Facts,
223            summary: "a test node".to_owned(),
224            span: Span::new(line, line + 2),
225            ..Default::default()
226        }
227    }
228
229    #[test]
230    fn test_validate_valid_file_returns_no_errors() {
231        let file = AgmFile {
232            header: valid_header(),
233            nodes: vec![minimal_node("auth.login", 5)],
234        };
235        let result = validate(&file, "", "test.agm", &Default::default());
236        assert!(!result.has_errors(), "Valid file should produce no errors");
237    }
238
239    #[test]
240    fn test_validate_default_options_uses_standard_level() {
241        let opts = ValidateOptions::default();
242        assert_eq!(opts.enforcement_level, EnforcementLevel::Standard);
243        assert!(opts.import_resolver.is_none());
244    }
245
246    #[test]
247    fn test_validate_multiple_errors_sorted_by_line() {
248        // Node on line 20 has an unresolved dep; node on line 5 has too-long summary.
249        let mut node_a = minimal_node("auth.a", 5);
250        node_a.summary = "x".repeat(201); // V012 warning, line 5
251
252        let mut node_b = minimal_node("auth.b", 20);
253        node_b.depends = Some(vec!["missing.dep".to_owned()]); // V004 error, line 20
254
255        let file = AgmFile {
256            header: valid_header(),
257            nodes: vec![node_a, node_b],
258        };
259        let result = validate(&file, "", "test.agm", &Default::default());
260        let diags = result.diagnostics();
261        // All diagnostics should be present
262        assert!(!diags.is_empty());
263        // Verify sort order: earlier lines should come first
264        for i in 1..diags.len() {
265            let prev_line = diags[i - 1].location.line.unwrap_or(0);
266            let curr_line = diags[i].location.line.unwrap_or(0);
267            assert!(
268                prev_line <= curr_line,
269                "Diagnostics not sorted by line: {} > {}",
270                prev_line,
271                curr_line
272            );
273        }
274    }
275
276    #[test]
277    fn test_validate_empty_header_fields_produce_p001() {
278        let mut file = AgmFile {
279            header: valid_header(),
280            nodes: vec![minimal_node("test.node", 5)],
281        };
282        file.header.agm = String::new();
283        let result = validate(&file, "", "test.agm", &Default::default());
284        assert!(
285            result
286                .diagnostics()
287                .iter()
288                .any(|d| d.code == ErrorCode::P001)
289        );
290    }
291
292    #[test]
293    fn test_validate_no_nodes_produces_p008() {
294        let file = AgmFile {
295            header: valid_header(),
296            nodes: vec![],
297        };
298        let result = validate(&file, "", "test.agm", &Default::default());
299        assert!(
300            result
301                .diagnostics()
302                .iter()
303                .any(|d| d.code == ErrorCode::P008)
304        );
305    }
306
307    #[test]
308    fn test_validate_options_clone() {
309        let opts = ValidateOptions::default();
310        let _cloned = opts.clone();
311    }
312
313    #[test]
314    fn test_sort_diagnostics_orders_by_line_then_severity() {
315        use crate::error::diagnostic::{AgmError, ErrorLocation, Severity};
316
317        let mut errors = vec![
318            AgmError::with_severity(
319                ErrorCode::V012,
320                Severity::Warning,
321                "warn",
322                ErrorLocation::file_line("f", 10),
323            ),
324            AgmError::with_severity(
325                ErrorCode::V004,
326                Severity::Error,
327                "err",
328                ErrorLocation::file_line("f", 10),
329            ),
330            AgmError::with_severity(
331                ErrorCode::V003,
332                Severity::Error,
333                "earlier",
334                ErrorLocation::file_line("f", 5),
335            ),
336        ];
337
338        sort_diagnostics(&mut errors);
339
340        // Line 5 error should be first
341        assert_eq!(errors[0].location.line, Some(5));
342        // Line 10 error before warning
343        assert_eq!(errors[1].severity, Severity::Error);
344        assert_eq!(errors[2].severity, Severity::Warning);
345    }
346}