Skip to main content

alp_core/
validate.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local (offline) board.yaml validation.
3//!
4//! Mirrors the TypeScript `validateBoardYamlLocally` in
5//! `@alp-sdk/core/validation/service.ts`. This is the offline parity
6//! target shared by the conformance fixtures; full SDK-spawn validation
7//! arrives in a later phase.
8
9use crate::model::BoardModel;
10
11/// Validation outcome — stable string identifiers shared with the CLI
12/// envelope and the TS implementation.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Outcome {
15    /// Validation passed with no issues.
16    Clean,
17    /// SoM/preset could not be resolved (validator exit 2); treated as a warning.
18    MissingPreset,
19    /// Schema/structural violation (validator exit 1).
20    SchemaViolation,
21    /// Hardware-revision incompatibility (validator exit 3).
22    HardwareRevision,
23    /// Validation could not be completed (validator crashed / unknown exit status).
24    Failed,
25}
26
27impl Outcome {
28    /// Stable string identifier for this outcome, as emitted in the CLI envelope.
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Outcome::Clean => "clean",
32            Outcome::MissingPreset => "missing-preset",
33            Outcome::SchemaViolation => "schema-violation",
34            Outcome::HardwareRevision => "hardware-revision",
35            Outcome::Failed => "failed",
36        }
37    }
38}
39
40/// Per-issue severity level.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Severity {
43    Error,
44    Warning,
45    Suggestion,
46}
47
48impl Severity {
49    /// Stable lowercase string identifier for this severity.
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Severity::Error => "error",
53            Severity::Warning => "warning",
54            Severity::Suggestion => "suggestion",
55        }
56    }
57}
58
59/// A single validation finding: human-readable `message` plus its `severity`.
60#[derive(Debug, Clone)]
61pub struct ValidationIssue {
62    pub message: String,
63    pub severity: Severity,
64}
65
66/// Aggregate validation result: overall `outcome` plus the list of `issues`.
67#[derive(Debug, Clone)]
68pub struct ValidationResult {
69    pub outcome: Outcome,
70    pub issues: Vec<ValidationIssue>,
71}
72
73/// Error returned when board.yaml text cannot be parsed into a `BoardModel`.
74#[derive(Debug, thiserror::Error)]
75pub enum ParseError {
76    /// The input was not syntactically valid YAML.
77    #[error("board.yaml is not valid YAML: {0}")]
78    Yaml(#[from] serde_yaml::Error),
79}
80
81/// Parse board.yaml text into a [`BoardModel`].
82///
83/// Matches TS `parseBoardModel`: a YAML scalar/null parses to the default
84/// model rather than an error; only true syntax errors fail.
85pub fn parse_board_model(text: &str) -> Result<BoardModel, ParseError> {
86    match serde_yaml::from_str::<Option<BoardModel>>(text) {
87        Ok(Some(model)) => Ok(model),
88        Ok(None) => Ok(BoardModel::default()),
89        Err(e) => Err(ParseError::Yaml(e)),
90    }
91}
92
93/// Local structural validation. Mirrors `validateBoardYamlLocally`:
94/// for schema_version >= 2, top-level `os:` is rejected and a non-empty
95/// `cores:` block is required.
96pub fn validate_board_yaml_local(text: &str) -> Result<ValidationResult, ParseError> {
97    let model = parse_board_model(text)?;
98    let mut issues = Vec::new();
99
100    if model.effective_schema_version() >= 2 {
101        if model.os.is_some() {
102            issues.push(ValidationIssue {
103                message:
104                    "board.yaml v2: top-level 'os:' is not valid; move it into a 'cores:' block"
105                        .to_string(),
106                severity: Severity::Error,
107            });
108        }
109        let has_cores = model.cores.as_ref().is_some_and(|c| !c.is_empty());
110        if !has_cores {
111            issues.push(ValidationIssue {
112                message:
113                    "board.yaml v2: 'cores:' block is required and must have at least one entry"
114                        .to_string(),
115                severity: Severity::Error,
116            });
117        }
118    }
119
120    let outcome = if issues.is_empty() {
121        Outcome::Clean
122    } else {
123        Outcome::SchemaViolation
124    };
125
126    Ok(ValidationResult { outcome, issues })
127}
128
129// ───────────────────────── full (spawn) validation ─────────────────────────
130//
131// The real `alp validate` shells out to `<sdk>/scripts/validate_board_yaml.py`.
132// The analysis below mirrors TS `analyzeValidationResult` + `parseValidationIssues`:
133// classify by the process exit status, then turn stderr into structured issues.
134
135/// Result of spawning the Python validator (mirrors TS `ValidatorExecutionResult`).
136pub struct ValidatorExecution {
137    /// Process exit status; `None` if the process was killed / never started.
138    pub status: Option<i32>,
139    /// Captured standard output of the validator process.
140    pub stdout: String,
141    /// Captured standard error; the source of structured issues.
142    pub stderr: String,
143}
144
145/// Map the validator's exit status to an [`Outcome`] (TS `classifyValidationOutcome`).
146pub fn classify_validation_outcome(status: Option<i32>) -> Outcome {
147    match status {
148        Some(0) => Outcome::Clean,
149        Some(2) => Outcome::MissingPreset,
150        Some(3) => Outcome::HardwareRevision,
151        Some(1) => Outcome::SchemaViolation,
152        _ => Outcome::Failed,
153    }
154}
155
156fn severity_for_outcome(outcome: Outcome) -> Severity {
157    if outcome == Outcome::MissingPreset {
158        Severity::Warning
159    } else {
160        Severity::Error
161    }
162}
163
164/// True when validator stderr is an unhandled interpreter/environment crash
165/// rather than a validation verdict — detected by the Python traceback header.
166/// The validator exits 1 both for a genuine schema violation AND when it crashes
167/// (e.g. `ImportError: jsonschema` on a host missing the deps); a real
168/// validation failure never prints a traceback, so this header reliably tells a
169/// broken validator environment apart from a "board.yaml is invalid" result.
170fn is_interpreter_crash(stderr: &str) -> bool {
171    stderr.lines().any(|line| {
172        line.trim_start()
173            .starts_with("Traceback (most recent call last):")
174    })
175}
176
177/// Classify + parse a validator execution into a [`ValidationResult`]
178/// (TS `analyzeValidationResult`).
179pub fn analyze_validation_result(execution: &ValidatorExecution) -> ValidationResult {
180    let mut outcome = classify_validation_outcome(execution.status);
181    // A crashed validator (exit 1 with a Python traceback) collides with a
182    // genuine schema violation on exit code alone. Reclassify it as `Failed`
183    // (infra, exit 1) so a broken validator environment is never surfaced to
184    // consumers as a real board.yaml verdict (exit 2). (issue #38)
185    if outcome == Outcome::SchemaViolation && is_interpreter_crash(&execution.stderr) {
186        outcome = Outcome::Failed;
187    }
188    let issues = parse_validation_issues(&execution.stderr, severity_for_outcome(outcome));
189    ValidationResult { outcome, issues }
190}
191
192/// Parse validator stderr into structured issues — a faithful port of TS
193/// `parseValidationIssues`. Handles rich `error[ALP-B*]` blocks (with `-->`
194/// location arrows + indented source/hint continuation), legacy `FAIL`/`WARN`
195/// lines with indented continuations, and standalone `hint:` suggestions.
196///
197/// The CLI only consumes `message` + `severity` (it rewrites the issue code to
198/// `validate.<outcome>`), so block code/line/col are parsed for correct line
199/// skipping but not retained.
200fn parse_validation_issues(stderr: &str, severity: Severity) -> Vec<ValidationIssue> {
201    let lines: Vec<&str> = stderr
202        .split('\n')
203        .map(|l| l.strip_suffix('\r').unwrap_or(l))
204        .collect();
205
206    let mut issues = Vec::new();
207    let mut i = 0;
208    while i < lines.len() {
209        let line = lines[i];
210
211        if line.trim().is_empty() || is_summary_line(line) {
212            i += 1;
213            continue;
214        }
215
216        // Rich ALP-B* block.
217        if let Some((sev, message)) = parse_rich_header(line) {
218            let issue_severity = match sev {
219                "error" => Severity::Error,
220                "warning" => Severity::Warning,
221                _ => Severity::Suggestion,
222            };
223            let issue = ValidationIssue {
224                message: message.trim().to_string(),
225                severity: issue_severity,
226            };
227            if i + 1 < lines.len() && is_arrow_line(lines[i + 1]) {
228                i += 2;
229                while i < lines.len() && is_block_continuation(lines[i]) {
230                    i += 1;
231                }
232                issues.push(issue);
233                continue;
234            }
235            issues.push(issue);
236            i += 1;
237            continue;
238        }
239
240        // Legacy FAIL / WARN lines.
241        if let Some((level, rest)) = parse_fail_warn(line) {
242            let issue_severity = if level == "WARN" {
243                Severity::Warning
244            } else {
245                severity
246            };
247            let mut parts = vec![rest.trim().to_string()];
248            while i + 1 < lines.len() && is_fail_continuation(lines[i + 1]) {
249                i += 1;
250                parts.push(lines[i].trim().to_string());
251            }
252            issues.push(ValidationIssue {
253                message: parts.join("  "),
254                severity: issue_severity,
255            });
256            i += 1;
257            continue;
258        }
259
260        // Standalone hint / suggestion lines.
261        if is_hint_line(line) {
262            issues.push(ValidationIssue {
263                message: line.trim().to_string(),
264                severity: Severity::Suggestion,
265            });
266            i += 1;
267            continue;
268        }
269
270        i += 1;
271    }
272
273    issues
274}
275
276/// `^\S.*\.yaml:\s+(missing-preset|hardware|capability)` — non-actionable
277/// summary lines emitted by `validate_board_yaml.py main()`.
278fn is_summary_line(line: &str) -> bool {
279    let Some(first) = line.chars().next() else {
280        return false;
281    };
282    if first.is_whitespace() {
283        return false;
284    }
285    let after_first = &line[first.len_utf8()..];
286    let Some(rel) = after_first.find(".yaml:") else {
287        return false;
288    };
289    let rest = &after_first[rel + ".yaml:".len()..];
290    let trimmed = rest.trim_start();
291    if trimmed.len() == rest.len() {
292        return false; // needs at least one whitespace (\s+)
293    }
294    trimmed.starts_with("missing-preset")
295        || trimmed.starts_with("hardware")
296        || trimmed.starts_with("capability")
297}
298
299/// `^(error|warning|note)\[(ALP-[A-Z]\d+)\]:\s*(.+)` — returns (severity, message).
300fn parse_rich_header(line: &str) -> Option<(&str, &str)> {
301    for kw in ["error", "warning", "note"] {
302        if let Some(rest) = line.strip_prefix(kw) {
303            if let Some(rest) = rest.strip_prefix('[') {
304                if let Some(close) = rest.find("]:") {
305                    if is_alp_code(&rest[..close]) {
306                        let message = rest[close + 2..].trim_start();
307                        if !message.is_empty() {
308                            return Some((kw, message));
309                        }
310                    }
311                }
312            }
313        }
314    }
315    None
316}
317
318/// `ALP-[A-Z]\d+`
319fn is_alp_code(code: &str) -> bool {
320    let Some(rest) = code.strip_prefix("ALP-") else {
321        return false;
322    };
323    let mut chars = rest.chars();
324    match chars.next() {
325        Some(c) if c.is_ascii_uppercase() => {}
326        _ => return false,
327    }
328    let digits = chars.as_str();
329    !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
330}
331
332/// `^\s+-->\s+\S+:(\d+):(\d+)` — the location arrow inside a rich block.
333fn is_arrow_line(line: &str) -> bool {
334    if !line.starts_with(char::is_whitespace) {
335        return false;
336    }
337    let Some(after) = line.trim_start().strip_prefix("-->") else {
338        return false;
339    };
340    if !after.starts_with(char::is_whitespace) {
341        return false;
342    }
343    // `split_whitespace` already skips the leading `\s+` after `-->`.
344    let Some(token) = after.split_whitespace().next() else {
345        return false;
346    };
347    let parts: Vec<&str> = token.rsplitn(3, ':').collect();
348    if parts.len() < 3 {
349        return false;
350    }
351    let is_num = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
352    !parts[2].is_empty() && is_num(parts[0]) && is_num(parts[1])
353}
354
355/// `^\s+[|0-9^= ]` — indented source/underline/hint continuation of a rich block.
356fn is_block_continuation(line: &str) -> bool {
357    let mut chars = line.chars();
358    match chars.next() {
359        Some(c) if c.is_whitespace() => {}
360        _ => return false,
361    }
362    match chars.next() {
363        Some(c) if c.is_whitespace() => true, // ≥2 leading whitespace (space ∈ set)
364        Some(c) if c == '|' || c == '^' || c == '=' || c.is_ascii_digit() => true,
365        _ => false,
366    }
367}
368
369/// `^(FAIL|WARN)\s+(.+)` — returns (level, message-without-leading-ws).
370fn parse_fail_warn(line: &str) -> Option<(&str, &str)> {
371    for level in ["FAIL", "WARN"] {
372        if let Some(rest) = line.strip_prefix(level) {
373            if rest.starts_with(char::is_whitespace) {
374                let message = rest.trim_start();
375                if !message.is_empty() {
376                    return Some((level, message));
377                }
378            }
379        }
380    }
381    None
382}
383
384/// `^\s{2,}\S` — indented continuation of a legacy FAIL/WARN line.
385fn is_fail_continuation(line: &str) -> bool {
386    let leading_ws = line.chars().take_while(|c| c.is_whitespace()).count();
387    leading_ws >= 2 && !line.trim_start().is_empty()
388}
389
390/// `^\s*(hint|suggestion|suggest):` (case-insensitive)
391fn is_hint_line(line: &str) -> bool {
392    let lowered = line.trim_start().to_ascii_lowercase();
393    lowered.starts_with("hint:")
394        || lowered.starts_with("suggestion:")
395        || lowered.starts_with("suggest:")
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::model::{Carrier, Inference, Iot, Som, normalize_board_model};
402    use std::collections::BTreeMap;
403
404    #[test]
405    fn v1_board_passes_without_errors() {
406        let text = "som:\n  sku: E1M-AEN701\npreset: e1m-evk\n";
407        let r = validate_board_yaml_local(text).unwrap();
408        assert_eq!(r.outcome, Outcome::Clean);
409        assert!(r.issues.is_empty());
410    }
411
412    #[test]
413    fn v2_clean_board_passes() {
414        let text =
415            "schema_version: 2\nsom:\n  sku: E1M-AEN701\ncores:\n  m55_hp:\n    app: ./src\n";
416        let r = validate_board_yaml_local(text).unwrap();
417        assert_eq!(r.outcome, Outcome::Clean);
418    }
419
420    #[test]
421    fn v2_top_level_os_is_rejected() {
422        let text = "schema_version: 2\nos: zephyr\ncores:\n  m55_hp:\n    app: ./src\n";
423        let r = validate_board_yaml_local(text).unwrap();
424        assert_eq!(r.outcome, Outcome::SchemaViolation);
425        assert_eq!(r.issues.len(), 1);
426    }
427
428    #[test]
429    fn v2_without_cores_is_rejected() {
430        let text = "schema_version: 2\nsom:\n  sku: E1M-AEN701\n";
431        let r = validate_board_yaml_local(text).unwrap();
432        assert_eq!(r.outcome, Outcome::SchemaViolation);
433        assert_eq!(r.issues.len(), 1);
434    }
435
436    #[test]
437    fn parse_rich_board_fields() {
438        let text = r#"
439schema_version: 2
440som:
441  sku: E1M-AEN701
442cores:
443  m55_hp:
444    os: zephyr
445    app: ./src
446    image: app.bin
447    peripherals: [i2c, spi]
448    libraries: [mbedtls]
449    inference:
450      backend: ethos_u
451      default_arena_kib: 256
452    iot:
453      wifi: true
454ipc:
455  - name: telemetry
456    endpoints: [m55_hp, a32_cluster]
457    size_kib: 64
458"#;
459        let model = parse_board_model(text).unwrap();
460        let core = model.cores.unwrap().remove("m55_hp").unwrap();
461        assert_eq!(core.os.as_deref(), Some("zephyr"));
462        assert_eq!(core.peripherals.unwrap(), vec!["i2c", "spi"]);
463        assert_eq!(core.inference.unwrap().default_arena_kib, Some(256));
464        assert_eq!(model.ipc.unwrap()[0].size_kib, 64);
465    }
466
467    #[test]
468    fn normalize_v1_removes_empty_optional_blocks() {
469        let model = BoardModel {
470            schema_version: Some(1),
471            som: Some(Som {
472                sku: Some("E1M-AEN701".to_string()),
473            }),
474            carrier: Some(Carrier {
475                name: Some("E1M-EVK".to_string()),
476                populated: Some(BTreeMap::new()),
477            }),
478            inference: Some(Inference::default()),
479            libraries: Some(Vec::new()),
480            iot: Some(Iot::default()),
481            ..BoardModel::default()
482        };
483
484        let normalized = normalize_board_model(model);
485        assert!(normalized.libraries.is_none());
486        assert!(normalized.iot.is_none());
487        assert!(normalized.inference.is_none());
488        assert!(normalized.carrier.unwrap().populated.is_none());
489    }
490
491    #[test]
492    fn normalize_v2_removes_top_level_os() {
493        let model = BoardModel {
494            schema_version: Some(2),
495            os: Some("zephyr".to_string()),
496            ..BoardModel::default()
497        };
498
499        assert!(normalize_board_model(model).os.is_none());
500    }
501
502    #[test]
503    fn classify_maps_exit_status_to_outcome() {
504        assert_eq!(classify_validation_outcome(Some(0)), Outcome::Clean);
505        assert_eq!(
506            classify_validation_outcome(Some(1)),
507            Outcome::SchemaViolation
508        );
509        assert_eq!(classify_validation_outcome(Some(2)), Outcome::MissingPreset);
510        assert_eq!(
511            classify_validation_outcome(Some(3)),
512            Outcome::HardwareRevision
513        );
514        assert_eq!(classify_validation_outcome(Some(9)), Outcome::Failed);
515        assert_eq!(classify_validation_outcome(None), Outcome::Failed);
516    }
517
518    #[test]
519    fn analyze_parses_rich_alp_block() {
520        let stderr = "error[ALP-B005]: SoM SKU 'E1M-NX9999' does not resolve\n  --> board.yaml:3:8\n   |\n 3 | som: {sku: E1M-NX9999}\n   |          ^^^^^^^^^^^^\n   = hint: did you mean E1M-NX9?\n   = see: docs/diagnostics/ALP-B005.md\n";
521        let execution = ValidatorExecution {
522            status: Some(1),
523            stdout: String::new(),
524            stderr: stderr.to_string(),
525        };
526        let result = analyze_validation_result(&execution);
527        assert_eq!(result.outcome, Outcome::SchemaViolation);
528        assert_eq!(result.issues.len(), 1, "block continuation must be skipped");
529        assert_eq!(result.issues[0].severity, Severity::Error);
530        assert_eq!(
531            result.issues[0].message,
532            "SoM SKU 'E1M-NX9999' does not resolve"
533        );
534    }
535
536    #[test]
537    fn analyze_parses_legacy_fail_warn_with_continuation() {
538        let stderr = "FAIL som preset: no preset for E1M-NX9999\n     expected shared definition at metadata/boards/...\nWARN hw_compat: minor version mismatch\n";
539        let execution = ValidatorExecution {
540            status: Some(2),
541            stdout: String::new(),
542            stderr: stderr.to_string(),
543        };
544        let result = analyze_validation_result(&execution);
545        assert_eq!(result.outcome, Outcome::MissingPreset);
546        assert_eq!(result.issues.len(), 2);
547        // FAIL line folds in its indented continuation; severity follows outcome.
548        assert_eq!(
549            result.issues[0].message,
550            "som preset: no preset for E1M-NX9999  expected shared definition at metadata/boards/..."
551        );
552        assert_eq!(result.issues[0].severity, Severity::Warning); // missing-preset outcome
553        // WARN line is always a warning regardless of outcome.
554        assert_eq!(result.issues[1].severity, Severity::Warning);
555        assert_eq!(
556            result.issues[1].message,
557            "hw_compat: minor version mismatch"
558        );
559    }
560
561    #[test]
562    fn analyze_skips_summary_lines_and_keeps_hints() {
563        let stderr = "board.yaml: missing-preset\nhint: run `alp presets` to list valid SKUs\n";
564        let execution = ValidatorExecution {
565            status: Some(2),
566            stdout: String::new(),
567            stderr: stderr.to_string(),
568        };
569        let result = analyze_validation_result(&execution);
570        assert_eq!(result.issues.len(), 1);
571        assert_eq!(result.issues[0].severity, Severity::Suggestion);
572        assert!(result.issues[0].message.starts_with("hint:"));
573    }
574
575    #[test]
576    fn clean_execution_has_no_issues() {
577        let execution = ValidatorExecution {
578            status: Some(0),
579            stdout: String::new(),
580            stderr: String::new(),
581        };
582        let result = analyze_validation_result(&execution);
583        assert_eq!(result.outcome, Outcome::Clean);
584        assert!(result.issues.is_empty());
585    }
586
587    #[test]
588    fn rich_header_rejects_non_alp_code() {
589        assert!(parse_rich_header("error[B005]: nope").is_none());
590        assert!(parse_rich_header("error[ALP-B005]: ok").is_some());
591        assert!(parse_rich_header("note[ALP-Z9]: hi").is_some());
592    }
593
594    #[test]
595    fn crashed_validator_reclassifies_exit1_traceback_as_failed() {
596        // A missing python dep: the validator crashes with a traceback and exits
597        // 1 — the same exit code as a real schema violation. It must NOT be a
598        // schema-violation verdict; a broken env is an infra failure. (issue #38)
599        let stderr = "Traceback (most recent call last):\n  File \"/sdk/scripts/validate_board_yaml.py\", line 7, in <module>\n    import jsonschema\nModuleNotFoundError: No module named 'jsonschema'\n";
600        let execution = ValidatorExecution {
601            status: Some(1),
602            stdout: String::new(),
603            stderr: stderr.to_string(),
604        };
605        let result = analyze_validation_result(&execution);
606        assert_eq!(result.outcome, Outcome::Failed);
607        // A traceback yields no parseable diagnostics.
608        assert!(result.issues.is_empty());
609    }
610
611    #[test]
612    fn genuine_schema_violation_on_exit1_stays_schema_violation() {
613        // A real validation failure (no traceback) is unchanged by the crash guard.
614        let stderr = "FAIL som preset: no preset for E1M-NX9999\n";
615        let execution = ValidatorExecution {
616            status: Some(1),
617            stdout: String::new(),
618            stderr: stderr.to_string(),
619        };
620        let result = analyze_validation_result(&execution);
621        assert_eq!(result.outcome, Outcome::SchemaViolation);
622        assert_eq!(result.issues.len(), 1);
623    }
624
625    #[test]
626    fn crash_guard_detects_indented_traceback_and_ignores_other_exits() {
627        assert!(is_interpreter_crash(
628            "  Traceback (most recent call last):\n    ...\n"
629        ));
630        assert!(!is_interpreter_crash("FAIL som preset: nope\n"));
631        // The guard only fires on the exit-1 collision; a traceback on a
632        // non-schema-violation exit code leaves that outcome untouched.
633        let execution = ValidatorExecution {
634            status: Some(2),
635            stdout: String::new(),
636            stderr: "Traceback (most recent call last):\n".to_string(),
637        };
638        assert_eq!(
639            analyze_validation_result(&execution).outcome,
640            Outcome::MissingPreset
641        );
642    }
643}