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 type_schema;
39pub mod verify;
40
41// ---------------------------------------------------------------------------
42// ValidateOptions
43// ---------------------------------------------------------------------------
44
45/// Options controlling validation behavior.
46///
47/// Note: `import_resolver` uses `Arc<dyn ImportResolver + Send + Sync>` to allow
48/// the struct to be `Clone` while still supporting dynamic dispatch.
49#[derive(Clone)]
50pub struct ValidateOptions {
51    /// Schema enforcement level applied in Pass 4.
52    /// Default: `Standard`.
53    pub enforcement_level: EnforcementLevel,
54    /// Optional import resolver for cross-package validation (Pass 6).
55    /// If `None`, import-related rules (I001–I005) are skipped.
56    pub import_resolver: Option<Arc<dyn ImportResolver + Send + Sync>>,
57}
58
59impl std::fmt::Debug for ValidateOptions {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("ValidateOptions")
62            .field("enforcement_level", &self.enforcement_level)
63            .field("import_resolver", &self.import_resolver.is_some())
64            .finish()
65    }
66}
67
68impl Default for ValidateOptions {
69    fn default() -> Self {
70        Self {
71            enforcement_level: EnforcementLevel::Standard,
72            import_resolver: None,
73        }
74    }
75}
76
77// ---------------------------------------------------------------------------
78// validate()
79// ---------------------------------------------------------------------------
80
81/// Validates a parsed AGM file against all spec rules.
82///
83/// Runs all validation passes in dependency order and returns a
84/// `DiagnosticCollection` with all diagnostics sorted by line number
85/// (ascending), then by severity (errors before warnings at the same line).
86#[must_use]
87pub fn validate(
88    agm_file: &AgmFile,
89    source: &str,
90    file_name: &str,
91    options: &ValidateOptions,
92) -> DiagnosticCollection {
93    let mut collection = DiagnosticCollection::new(file_name, source);
94    let mut all_errors: Vec<AgmError> = Vec::new();
95
96    // Pass 1: File-level checks
97    all_errors.extend(file::validate_file(agm_file, file_name));
98
99    // Pass 2: Node-level checks (also builds all_ids set for later passes)
100    all_errors.extend(node::validate_node_ids(&agm_file.nodes, file_name));
101    let all_ids: HashSet<String> = agm_file.nodes.iter().map(|n| n.id.clone()).collect();
102    for n in &agm_file.nodes {
103        all_errors.extend(node::validate_node(n, &all_ids, file_name));
104    }
105
106    // Build set of all memory topics declared across nodes (used in Pass 3)
107    let all_memory_topics: HashSet<String> = agm_file
108        .nodes
109        .iter()
110        .filter_map(|n| n.memory.as_ref())
111        .flat_map(|entries| entries.iter().map(|e| e.topic.clone()))
112        .collect();
113
114    // Pass 3: Structural per-node checks
115    for n in &agm_file.nodes {
116        all_errors.extend(code::validate_code(n, file_name));
117        all_errors.extend(verify::validate_verify(n, &all_ids, file_name));
118        all_errors.extend(context::validate_context(
119            n,
120            &all_ids,
121            &all_memory_topics,
122            file_name,
123        ));
124        all_errors.extend(orchestration::validate_orchestration(
125            n, &all_ids, file_name,
126        ));
127        all_errors.extend(execution::validate_execution(n, file_name));
128        all_errors.extend(memory::validate_memory(n, file_name));
129    }
130
131    // Pass 4: Type schema enforcement
132    for n in &agm_file.nodes {
133        all_errors.extend(type_schema::validate_type_schema(
134            n,
135            &options.enforcement_level,
136            file_name,
137        ));
138    }
139
140    // Pass 5: Cross-node checks (only meaningful if there are nodes)
141    if !agm_file.nodes.is_empty() {
142        all_errors.extend(references::validate_references(
143            agm_file, &all_ids, file_name,
144        ));
145        all_errors.extend(cycles::validate_cycles(agm_file, file_name));
146        all_errors.extend(compatibility::validate_compatibility(agm_file, file_name));
147    }
148
149    // Pass 6: Cross-package checks (only runs with an import resolver)
150    if let Some(ref resolver) = options.import_resolver {
151        all_errors.extend(imports::validate_imports(
152            agm_file,
153            resolver.as_ref(),
154            file_name,
155        ));
156    }
157
158    // Sort: ascending line number, then errors before warnings before info
159    sort_diagnostics(&mut all_errors);
160
161    collection.extend(all_errors);
162    collection
163}
164
165/// Sorts diagnostics by line number (ascending), then by severity
166/// (errors before warnings before info at the same line number).
167fn sort_diagnostics(errors: &mut [AgmError]) {
168    errors.sort_by(|a, b| {
169        let line_a = a.location.line.unwrap_or(0);
170        let line_b = b.location.line.unwrap_or(0);
171
172        line_a
173            .cmp(&line_b)
174            .then_with(|| severity_rank(a.severity).cmp(&severity_rank(b.severity)))
175    });
176}
177
178/// Maps severity to a sort rank: Error=0 (highest), Warning=1, Info=2.
179fn severity_rank(sev: crate::error::diagnostic::Severity) -> u8 {
180    use crate::error::diagnostic::Severity;
181    match sev {
182        Severity::Error => 0,
183        Severity::Warning => 1,
184        Severity::Info => 2,
185    }
186}
187
188// ---------------------------------------------------------------------------
189// Tests
190// ---------------------------------------------------------------------------
191
192#[cfg(test)]
193mod tests {
194    use std::collections::BTreeMap;
195
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            priority: None,
225            stability: None,
226            confidence: None,
227            status: None,
228            depends: None,
229            related_to: None,
230            replaces: None,
231            conflicts: None,
232            see_also: None,
233            items: None,
234            steps: None,
235            fields: None,
236            input: None,
237            output: None,
238            detail: None,
239            rationale: None,
240            tradeoffs: None,
241            resolution: None,
242            examples: None,
243            notes: None,
244            code: None,
245            code_blocks: None,
246            verify: None,
247            agent_context: None,
248            target: None,
249            execution_status: None,
250            executed_by: None,
251            executed_at: None,
252            execution_log: None,
253            retry_count: None,
254            parallel_groups: None,
255            memory: None,
256            scope: None,
257            applies_when: None,
258            valid_from: None,
259            valid_until: None,
260            tags: None,
261            aliases: None,
262            keywords: None,
263            extra_fields: BTreeMap::new(),
264            span: Span::new(line, line + 2),
265        }
266    }
267
268    #[test]
269    fn test_validate_valid_file_returns_no_errors() {
270        let file = AgmFile {
271            header: valid_header(),
272            nodes: vec![minimal_node("auth.login", 5)],
273        };
274        let result = validate(&file, "", "test.agm", &Default::default());
275        assert!(!result.has_errors(), "Valid file should produce no errors");
276    }
277
278    #[test]
279    fn test_validate_default_options_uses_standard_level() {
280        let opts = ValidateOptions::default();
281        assert_eq!(opts.enforcement_level, EnforcementLevel::Standard);
282        assert!(opts.import_resolver.is_none());
283    }
284
285    #[test]
286    fn test_validate_multiple_errors_sorted_by_line() {
287        // Node on line 20 has an unresolved dep; node on line 5 has too-long summary.
288        let mut node_a = minimal_node("auth.a", 5);
289        node_a.summary = "x".repeat(201); // V012 warning, line 5
290
291        let mut node_b = minimal_node("auth.b", 20);
292        node_b.depends = Some(vec!["missing.dep".to_owned()]); // V004 error, line 20
293
294        let file = AgmFile {
295            header: valid_header(),
296            nodes: vec![node_a, node_b],
297        };
298        let result = validate(&file, "", "test.agm", &Default::default());
299        let diags = result.diagnostics();
300        // All diagnostics should be present
301        assert!(!diags.is_empty());
302        // Verify sort order: earlier lines should come first
303        for i in 1..diags.len() {
304            let prev_line = diags[i - 1].location.line.unwrap_or(0);
305            let curr_line = diags[i].location.line.unwrap_or(0);
306            assert!(
307                prev_line <= curr_line,
308                "Diagnostics not sorted by line: {} > {}",
309                prev_line,
310                curr_line
311            );
312        }
313    }
314
315    #[test]
316    fn test_validate_empty_header_fields_produce_p001() {
317        let mut file = AgmFile {
318            header: valid_header(),
319            nodes: vec![minimal_node("test.node", 5)],
320        };
321        file.header.agm = String::new();
322        let result = validate(&file, "", "test.agm", &Default::default());
323        assert!(
324            result
325                .diagnostics()
326                .iter()
327                .any(|d| d.code == ErrorCode::P001)
328        );
329    }
330
331    #[test]
332    fn test_validate_no_nodes_produces_p008() {
333        let file = AgmFile {
334            header: valid_header(),
335            nodes: vec![],
336        };
337        let result = validate(&file, "", "test.agm", &Default::default());
338        assert!(
339            result
340                .diagnostics()
341                .iter()
342                .any(|d| d.code == ErrorCode::P008)
343        );
344    }
345
346    #[test]
347    fn test_validate_options_clone() {
348        let opts = ValidateOptions::default();
349        let _cloned = opts.clone();
350    }
351
352    #[test]
353    fn test_sort_diagnostics_orders_by_line_then_severity() {
354        use crate::error::diagnostic::{AgmError, ErrorLocation, Severity};
355
356        let mut errors = vec![
357            AgmError::with_severity(
358                ErrorCode::V012,
359                Severity::Warning,
360                "warn",
361                ErrorLocation::file_line("f", 10),
362            ),
363            AgmError::with_severity(
364                ErrorCode::V004,
365                Severity::Error,
366                "err",
367                ErrorLocation::file_line("f", 10),
368            ),
369            AgmError::with_severity(
370                ErrorCode::V003,
371                Severity::Error,
372                "earlier",
373                ErrorLocation::file_line("f", 5),
374            ),
375        ];
376
377        sort_diagnostics(&mut errors);
378
379        // Line 5 error should be first
380        assert_eq!(errors[0].location.line, Some(5));
381        // Line 10 error before warning
382        assert_eq!(errors[1].severity, Severity::Error);
383        assert_eq!(errors[2].severity, Severity::Warning);
384    }
385}