Skip to main content

ucp_schema/
linter.rs

1//! Schema linting - static analysis of UCP schema files.
2//!
3//! Validates schema files for:
4//! - JSON syntax errors
5//! - Broken $ref references (file not found, anchor not found)
6//! - Invalid ucp_* annotation values
7
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11use serde_json::Value;
12
13use crate::loader::{load_schema, navigate_fragment};
14use crate::types::{
15    is_valid_schema_transition, is_valid_version, json_type_name, VersionConstraint, Visibility,
16    UCP_ANNOTATIONS, VALID_OPERATIONS,
17};
18
19/// Severity level for diagnostics.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Severity {
23    Error,
24    Warning,
25}
26
27/// A single diagnostic message from linting.
28#[derive(Debug, Clone, Serialize)]
29pub struct Diagnostic {
30    pub severity: Severity,
31    pub code: String,
32    pub file: PathBuf,
33    /// JSON path to the issue (e.g., "/properties/id/ucp_request")
34    pub path: String,
35    pub message: String,
36}
37
38/// Result of linting a single file.
39#[derive(Debug, Clone, Serialize)]
40pub struct FileResult {
41    pub file: PathBuf,
42    pub status: FileStatus,
43    #[serde(skip_serializing_if = "Vec::is_empty")]
44    pub diagnostics: Vec<Diagnostic>,
45}
46
47/// Status of a linted file.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "lowercase")]
50pub enum FileStatus {
51    Ok,
52    Error,
53    Warning,
54}
55
56/// Result of linting a directory or set of files.
57#[derive(Debug, Clone, Serialize)]
58pub struct LintResult {
59    pub path: PathBuf,
60    pub files_checked: usize,
61    pub passed: usize,
62    pub failed: usize,
63    pub errors: usize,
64    pub warnings: usize,
65    pub results: Vec<FileResult>,
66}
67
68impl LintResult {
69    /// Returns true if all files passed (no errors).
70    pub fn is_ok(&self) -> bool {
71        self.errors == 0
72    }
73}
74
75/// Lint a file or directory.
76///
77/// If path is a directory, recursively finds all .json files.
78/// If `strict` is true, warnings are treated as errors.
79/// Returns aggregated results for all files.
80pub fn lint(path: &Path, strict: bool) -> LintResult {
81    let files = collect_schema_files(path);
82    let mut results = Vec::new();
83    let mut total_errors = 0;
84    let mut total_warnings = 0;
85
86    for file in &files {
87        let file_result = lint_file(file, path);
88        let file_errors = file_result
89            .diagnostics
90            .iter()
91            .filter(|d| d.severity == Severity::Error)
92            .count();
93        let file_warnings = file_result
94            .diagnostics
95            .iter()
96            .filter(|d| d.severity == Severity::Warning)
97            .count();
98
99        total_errors += file_errors;
100        total_warnings += file_warnings;
101        results.push(file_result);
102    }
103
104    let failed = results
105        .iter()
106        .filter(|r| {
107            if strict {
108                r.status != FileStatus::Ok
109            } else {
110                r.status == FileStatus::Error
111            }
112        })
113        .count();
114
115    LintResult {
116        path: path.to_path_buf(),
117        files_checked: files.len(),
118        passed: files.len() - failed,
119        failed,
120        errors: total_errors,
121        warnings: total_warnings,
122        results,
123    }
124}
125
126/// Lint a single schema file.
127pub fn lint_file(file: &Path, base_path: &Path) -> FileResult {
128    let mut diagnostics = Vec::new();
129
130    // Try to load the file (checks syntax)
131    let schema = match load_schema(file) {
132        Ok(s) => s,
133        Err(e) => {
134            diagnostics.push(Diagnostic {
135                severity: Severity::Error,
136                code: "E001".to_string(),
137                file: file.to_path_buf(),
138                path: "/".to_string(),
139                message: format!("syntax error: {}", e),
140            });
141            return FileResult {
142                file: file.strip_prefix(base_path).unwrap_or(file).to_path_buf(),
143                status: FileStatus::Error,
144                diagnostics,
145            };
146        }
147    };
148
149    // Check $refs
150    let file_dir = file.parent().unwrap_or(Path::new("."));
151    check_refs(&schema, file, file_dir, "", &schema, &mut diagnostics);
152
153    // Check ucp_* annotations
154    check_annotations(&schema, file, "", &mut diagnostics);
155
156    // Check `requires` field (version constraints on extension schemas)
157    check_requires(&schema, file, &mut diagnostics);
158
159    // Check for missing $id (warning)
160    if schema.get("$id").is_none() {
161        diagnostics.push(Diagnostic {
162            severity: Severity::Warning,
163            code: "W002".to_string(),
164            file: file.to_path_buf(),
165            path: "/".to_string(),
166            message: "schema missing $id field".to_string(),
167        });
168    }
169
170    let has_errors = diagnostics.iter().any(|d| d.severity == Severity::Error);
171    let has_warnings = diagnostics.iter().any(|d| d.severity == Severity::Warning);
172
173    let status = if has_errors {
174        FileStatus::Error
175    } else if has_warnings {
176        FileStatus::Warning
177    } else {
178        FileStatus::Ok
179    };
180
181    FileResult {
182        file: file.strip_prefix(base_path).unwrap_or(file).to_path_buf(),
183        status,
184        diagnostics,
185    }
186}
187
188/// Recursively check $ref values in a schema.
189fn check_refs(
190    value: &Value,
191    file: &Path,
192    file_dir: &Path,
193    path: &str,
194    root: &Value,
195    diagnostics: &mut Vec<Diagnostic>,
196) {
197    match value {
198        Value::Object(map) => {
199            if let Some(Value::String(ref_val)) = map.get("$ref") {
200                check_single_ref(ref_val, file, file_dir, path, root, diagnostics);
201            }
202
203            for (key, val) in map {
204                let child_path = format!("{}/{}", path, key);
205                check_refs(val, file, file_dir, &child_path, root, diagnostics);
206            }
207        }
208        Value::Array(arr) => {
209            for (i, item) in arr.iter().enumerate() {
210                let child_path = format!("{}/{}", path, i);
211                check_refs(item, file, file_dir, &child_path, root, diagnostics);
212            }
213        }
214        _ => {}
215    }
216}
217
218/// Check a single $ref value.
219fn check_single_ref(
220    ref_val: &str,
221    file: &Path,
222    file_dir: &Path,
223    path: &str,
224    root: &Value,
225    diagnostics: &mut Vec<Diagnostic>,
226) {
227    // External URLs can't be validated locally - skip silently
228    if ref_val.starts_with("http://") || ref_val.starts_with("https://") {
229        return;
230    }
231
232    if ref_val.starts_with('#') {
233        // Internal reference - check anchor resolves
234        if ref_val != "#" && navigate_fragment(root, ref_val).is_err() {
235            diagnostics.push(Diagnostic {
236                severity: Severity::Error,
237                code: "E003".to_string(),
238                file: file.to_path_buf(),
239                path: path.to_string(),
240                message: format!("anchor not found: {}", ref_val),
241            });
242        }
243        return;
244    }
245
246    // File reference (possibly with anchor)
247    let (file_part, fragment) = match ref_val.find('#') {
248        Some(idx) => (&ref_val[..idx], Some(&ref_val[idx..])),
249        None => (ref_val, None),
250    };
251
252    let ref_path = file_dir.join(file_part);
253    if !ref_path.exists() {
254        diagnostics.push(Diagnostic {
255            severity: Severity::Error,
256            code: "E002".to_string(),
257            file: file.to_path_buf(),
258            path: path.to_string(),
259            message: format!("file not found: {}", file_part),
260        });
261        return;
262    }
263
264    // If there's a fragment, check it resolves in the referenced file
265    if let Some(frag) = fragment {
266        if frag != "#" {
267            match load_schema(&ref_path) {
268                Ok(ref_schema) => {
269                    if navigate_fragment(&ref_schema, frag).is_err() {
270                        diagnostics.push(Diagnostic {
271                            severity: Severity::Error,
272                            code: "E003".to_string(),
273                            file: file.to_path_buf(),
274                            path: path.to_string(),
275                            message: format!("anchor not found in {}: {}", file_part, frag),
276                        });
277                    }
278                }
279                Err(_) => {
280                    // If we can't load the ref'd file, that's already an error
281                    // from a different check, so don't duplicate
282                }
283            }
284        }
285    }
286}
287
288/// Recursively check ucp_* annotation values.
289fn check_annotations(value: &Value, file: &Path, path: &str, diagnostics: &mut Vec<Diagnostic>) {
290    if let Value::Object(map) = value {
291        // Check all UCP annotations
292        for &annotation_key in UCP_ANNOTATIONS {
293            if let Some(annotation) = map.get(annotation_key) {
294                check_annotation_value(annotation, annotation_key, file, path, diagnostics);
295            }
296        }
297
298        // Recurse
299        for (key, val) in map {
300            let child_path = format!("{}/{}", path, key);
301            check_annotations(val, file, &child_path, diagnostics);
302        }
303    } else if let Value::Array(arr) = value {
304        for (i, item) in arr.iter().enumerate() {
305            let child_path = format!("{}/{}", path, i);
306            check_annotations(item, file, &child_path, diagnostics);
307        }
308    }
309}
310
311/// Check a single ucp_* annotation value is valid.
312fn check_annotation_value(
313    annotation: &Value,
314    key: &str,
315    file: &Path,
316    path: &str,
317    diagnostics: &mut Vec<Diagnostic>,
318) {
319    let annotation_path = format!("{}/{}", path, key);
320
321    match annotation {
322        Value::String(s) => {
323            if Visibility::parse(s).is_none() {
324                diagnostics.push(Diagnostic {
325                    severity: Severity::Error,
326                    code: "E004".to_string(),
327                    file: file.to_path_buf(),
328                    path: annotation_path,
329                    message: format!(
330                        "invalid {} value \"{}\": expected omit, required, or optional",
331                        key, s
332                    ),
333                });
334            }
335        }
336        Value::Object(map) => {
337            // Object form: { "create": "omit", "update": "required" }
338            for (op, val) in map {
339                let op_path = format!("{}/{}", annotation_path, op);
340
341                // Handle shorthand transition key
342                if op == "transition" {
343                    check_transition_object(val, key, file, &op_path, diagnostics);
344                    continue;
345                }
346
347                // Warn on unknown operations
348                if !VALID_OPERATIONS.contains(&op.as_str()) {
349                    diagnostics.push(Diagnostic {
350                        severity: Severity::Warning,
351                        code: "W003".to_string(),
352                        file: file.to_path_buf(),
353                        path: op_path.clone(),
354                        message: format!(
355                            "unknown operation \"{}\": expected {}",
356                            op,
357                            VALID_OPERATIONS.join(", ")
358                        ),
359                    });
360                }
361
362                // Check value is valid
363                match val {
364                    Value::String(s) => {
365                        if Visibility::parse(s).is_none() {
366                            diagnostics.push(Diagnostic {
367                                severity: Severity::Error,
368                                code: "E004".to_string(),
369                                file: file.to_path_buf(),
370                                path: op_path,
371                                message: format!(
372                                    "invalid {} value \"{}\": expected omit, required, or optional",
373                                    key, s
374                                ),
375                            });
376                        }
377                    }
378                    Value::Object(obj) => {
379                        // Per-operation transition: { "update": { "transition": { ... } } }
380                        if let Some(t) = obj.get("transition") {
381                            check_transition_object(t, key, file, &op_path, diagnostics);
382                        } else {
383                            diagnostics.push(Diagnostic {
384                                severity: Severity::Error,
385                                code: "E005".to_string(),
386                                file: file.to_path_buf(),
387                                path: op_path,
388                                message: format!(
389                                    "invalid {} value type: expected string or transition object, got {}",
390                                    key,
391                                    json_type_name(val)
392                                ),
393                            });
394                        }
395                    }
396                    _ => {
397                        diagnostics.push(Diagnostic {
398                            severity: Severity::Error,
399                            code: "E005".to_string(),
400                            file: file.to_path_buf(),
401                            path: op_path,
402                            message: format!(
403                                "invalid {} value type: expected string or transition object, got {}",
404                                key,
405                                json_type_name(val)
406                            ),
407                        });
408                    }
409                }
410            }
411        }
412        other => {
413            diagnostics.push(Diagnostic {
414                severity: Severity::Error,
415                code: "E005".to_string(),
416                file: file.to_path_buf(),
417                path: annotation_path,
418                message: format!(
419                    "invalid {} type: expected string or object, got {}",
420                    key,
421                    json_type_name(other)
422                ),
423            });
424        }
425    }
426}
427
428/// Validate a schema transition object { "from", "to", "description" }.
429fn check_transition_object(
430    value: &Value,
431    key: &str,
432    file: &Path,
433    path: &str,
434    diagnostics: &mut Vec<Diagnostic>,
435) {
436    let Some(obj) = value.as_object() else {
437        diagnostics.push(Diagnostic {
438            severity: Severity::Error,
439            code: "E005".to_string(),
440            file: file.to_path_buf(),
441            path: path.to_string(),
442            message: format!(
443                "invalid {} transition: expected object, got {}",
444                key,
445                json_type_name(value)
446            ),
447        });
448        return;
449    };
450
451    let from = obj.get("from").and_then(|v| v.as_str()).unwrap_or("");
452    let to = obj.get("to").and_then(|v| v.as_str()).unwrap_or("");
453    let description = obj
454        .get("description")
455        .and_then(|v| v.as_str())
456        .unwrap_or("");
457
458    if description.is_empty() {
459        diagnostics.push(Diagnostic {
460            severity: Severity::Error,
461            code: "E004".to_string(),
462            file: file.to_path_buf(),
463            path: path.to_string(),
464            message: format!(
465                "invalid {} transition: missing required field \"description\"",
466                key
467            ),
468        });
469    }
470
471    if !is_valid_schema_transition(from, to) {
472        diagnostics.push(Diagnostic {
473            severity: Severity::Error,
474            code: "E004".to_string(),
475            file: file.to_path_buf(),
476            path: path.to_string(),
477            message: format!(
478                "invalid {} schema transition: \"from\" ({}) and \"to\" ({}) must be distinct visibility values (omit, required, optional)",
479                key, from, to
480            ),
481        });
482    }
483}
484
485/// Validate a `version_constraint` object at the given path.
486/// Returns the parsed constraint on success for further checks.
487fn check_version_constraint(
488    value: &Value,
489    file: &Path,
490    path: &str,
491    diagnostics: &mut Vec<Diagnostic>,
492) -> Option<VersionConstraint> {
493    let obj = match value.as_object() {
494        Some(o) => o,
495        None => {
496            diagnostics.push(Diagnostic {
497                severity: Severity::Error,
498                code: "E006".to_string(),
499                file: file.to_path_buf(),
500                path: path.to_string(),
501                message: format!(
502                    "invalid version constraint: expected object, got {}",
503                    json_type_name(value)
504                ),
505            });
506            return None;
507        }
508    };
509
510    // Warn about unknown keys (catch typos like "maxx")
511    const KNOWN_CONSTRAINT_KEYS: &[&str] = &["min", "max"];
512    for key in obj.keys() {
513        if !KNOWN_CONSTRAINT_KEYS.contains(&key.as_str()) {
514            diagnostics.push(Diagnostic {
515                severity: Severity::Warning,
516                code: "W005".to_string(),
517                file: file.to_path_buf(),
518                path: format!("{}/{}", path, key),
519                message: format!(
520                    "unknown key \"{}\" in version constraint: expected min, max",
521                    key
522                ),
523            });
524        }
525    }
526
527    let min = match obj.get("min").and_then(|v| v.as_str()) {
528        Some(s) => s,
529        None => {
530            diagnostics.push(Diagnostic {
531                severity: Severity::Error,
532                code: "E006".to_string(),
533                file: file.to_path_buf(),
534                path: path.to_string(),
535                message: "version constraint missing required field \"min\"".to_string(),
536            });
537            return None;
538        }
539    };
540
541    if !is_valid_version(min) {
542        diagnostics.push(Diagnostic {
543            severity: Severity::Error,
544            code: "E006".to_string(),
545            file: file.to_path_buf(),
546            path: format!("{}/min", path),
547            message: format!("invalid version format \"{}\": expected YYYY-MM-DD", min),
548        });
549        return None;
550    }
551
552    let mut max_str = None;
553    if let Some(max_val) = obj.get("max") {
554        match max_val.as_str() {
555            Some(s) => {
556                if !is_valid_version(s) {
557                    diagnostics.push(Diagnostic {
558                        severity: Severity::Error,
559                        code: "E006".to_string(),
560                        file: file.to_path_buf(),
561                        path: format!("{}/max", path),
562                        message: format!("invalid version format \"{}\": expected YYYY-MM-DD", s),
563                    });
564                    return None;
565                }
566                max_str = Some(s.to_string());
567            }
568            None => {
569                diagnostics.push(Diagnostic {
570                    severity: Severity::Error,
571                    code: "E006".to_string(),
572                    file: file.to_path_buf(),
573                    path: format!("{}/max", path),
574                    message: "\"max\" must be a string".to_string(),
575                });
576                return None;
577            }
578        }
579    }
580
581    let vc = VersionConstraint {
582        min: min.to_string(),
583        max: max_str,
584    };
585
586    // Warn if min > max (likely authoring error)
587    if let Some(ref max) = vc.max {
588        if vc.min.as_str() > max.as_str() {
589            diagnostics.push(Diagnostic {
590                severity: Severity::Warning,
591                code: "W004".to_string(),
592                file: file.to_path_buf(),
593                path: path.to_string(),
594                message: format!("version constraint has min ({}) > max ({})", vc.min, max),
595            });
596        }
597    }
598
599    Some(vc)
600}
601
602/// Validate the top-level `requires` field on an extension schema.
603///
604/// Checks:
605/// - E006: Invalid structure (wrong types, bad version format)
606/// - E007: `requires.capabilities` key not found in `$defs`
607/// - W004: `min` > `max` in a version constraint
608fn check_requires(schema: &Value, file: &Path, diagnostics: &mut Vec<Diagnostic>) {
609    let Some(requires) = schema.get("requires") else {
610        return;
611    };
612
613    let requires_path = "/requires";
614
615    let obj = match requires.as_object() {
616        Some(o) => o,
617        None => {
618            diagnostics.push(Diagnostic {
619                severity: Severity::Error,
620                code: "E006".to_string(),
621                file: file.to_path_buf(),
622                path: requires_path.to_string(),
623                message: format!(
624                    "\"requires\" must be an object, got {}",
625                    json_type_name(requires)
626                ),
627            });
628            return;
629        }
630    };
631
632    // Warn about unknown keys (catch typos)
633    const KNOWN_REQUIRES_KEYS: &[&str] = &["protocol", "capabilities"];
634    for key in obj.keys() {
635        if !KNOWN_REQUIRES_KEYS.contains(&key.as_str()) {
636            diagnostics.push(Diagnostic {
637                severity: Severity::Warning,
638                code: "W005".to_string(),
639                file: file.to_path_buf(),
640                path: format!("{}/{}", requires_path, key),
641                message: format!(
642                    "unknown key \"{}\" in requires: expected protocol, capabilities",
643                    key
644                ),
645            });
646        }
647    }
648
649    // Validate requires.protocol
650    if let Some(protocol) = obj.get("protocol") {
651        check_version_constraint(
652            protocol,
653            file,
654            &format!("{}/protocol", requires_path),
655            diagnostics,
656        );
657    }
658
659    // Validate requires.capabilities
660    if let Some(caps) = obj.get("capabilities") {
661        let caps_path = format!("{}/capabilities", requires_path);
662        let caps_obj = match caps.as_object() {
663            Some(o) => o,
664            None => {
665                diagnostics.push(Diagnostic {
666                    severity: Severity::Error,
667                    code: "E006".to_string(),
668                    file: file.to_path_buf(),
669                    path: caps_path,
670                    message: format!(
671                        "\"requires.capabilities\" must be an object, got {}",
672                        json_type_name(caps)
673                    ),
674                });
675                return;
676            }
677        };
678
679        // Collect $defs keys for cross-reference check
680        let defs_keys: std::collections::HashSet<&str> = schema
681            .get("$defs")
682            .and_then(|d| d.as_object())
683            .map(|d| d.keys().map(|k| k.as_str()).collect())
684            .unwrap_or_default();
685
686        for (cap_name, constraint) in caps_obj {
687            let cap_path = format!("{}/{}", caps_path, cap_name);
688
689            check_version_constraint(constraint, file, &cap_path, diagnostics);
690
691            // Cross-reference: capability key must exist in $defs
692            if !defs_keys.contains(cap_name.as_str()) {
693                diagnostics.push(Diagnostic {
694                    severity: Severity::Error,
695                    code: "E007".to_string(),
696                    file: file.to_path_buf(),
697                    path: cap_path,
698                    message: format!(
699                        "requires.capabilities key \"{}\" not found in $defs",
700                        cap_name
701                    ),
702                });
703            }
704        }
705    }
706}
707
708/// Collect all .json files in a path (file or directory).
709fn collect_schema_files(path: &Path) -> Vec<PathBuf> {
710    if path.is_file() {
711        if path.extension().map(|e| e == "json").unwrap_or(false) {
712            return vec![path.to_path_buf()];
713        }
714        return vec![];
715    }
716
717    let mut files = Vec::new();
718    collect_files_recursive(path, &mut files);
719    files.sort();
720    files
721}
722
723fn collect_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
724    let Ok(entries) = std::fs::read_dir(dir) else {
725        return;
726    };
727
728    for entry in entries.flatten() {
729        let path = entry.path();
730        if path.is_dir() {
731            collect_files_recursive(&path, files);
732        } else if path.extension().map(|e| e == "json").unwrap_or(false) {
733            files.push(path);
734        }
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use std::io::Write;
742    use tempfile::{tempdir, NamedTempFile};
743
744    #[test]
745    fn lint_valid_schema() {
746        let mut file = NamedTempFile::new().unwrap();
747        writeln!(
748            file,
749            r#"{{
750            "$id": "https://example.com/test.json",
751            "type": "object",
752            "properties": {{
753                "id": {{ "type": "string" }}
754            }}
755        }}"#
756        )
757        .unwrap();
758
759        let result = lint_file(file.path(), file.path().parent().unwrap());
760        assert_eq!(result.status, FileStatus::Ok);
761        assert!(result.diagnostics.is_empty());
762    }
763
764    #[test]
765    fn lint_invalid_json_syntax() {
766        let mut file = NamedTempFile::new().unwrap();
767        writeln!(file, "{{ not valid json }}").unwrap();
768
769        let result = lint_file(file.path(), file.path().parent().unwrap());
770        assert_eq!(result.status, FileStatus::Error);
771        assert_eq!(result.diagnostics.len(), 1);
772        assert_eq!(result.diagnostics[0].code, "E001");
773    }
774
775    #[test]
776    fn lint_broken_internal_ref() {
777        let mut file = NamedTempFile::new().unwrap();
778        writeln!(
779            file,
780            r##"{{
781            "$id": "https://example.com/test.json",
782            "type": "object",
783            "properties": {{
784                "data": {{ "$ref": "#/$defs/missing" }}
785            }}
786        }}"##
787        )
788        .unwrap();
789
790        let result = lint_file(file.path(), file.path().parent().unwrap());
791        assert_eq!(result.status, FileStatus::Error);
792        assert!(result.diagnostics.iter().any(|d| d.code == "E003"));
793    }
794
795    #[test]
796    fn lint_broken_file_ref() {
797        let mut file = NamedTempFile::new().unwrap();
798        writeln!(
799            file,
800            r#"{{
801            "$id": "https://example.com/test.json",
802            "properties": {{
803                "data": {{ "$ref": "nonexistent.json" }}
804            }}
805        }}"#
806        )
807        .unwrap();
808
809        let result = lint_file(file.path(), file.path().parent().unwrap());
810        assert_eq!(result.status, FileStatus::Error);
811        assert!(result.diagnostics.iter().any(|d| d.code == "E002"));
812    }
813
814    #[test]
815    fn lint_invalid_ucp_request_value() {
816        let mut file = NamedTempFile::new().unwrap();
817        writeln!(
818            file,
819            r#"{{
820            "$id": "https://example.com/test.json",
821            "properties": {{
822                "id": {{
823                    "type": "string",
824                    "ucp_request": "invalid_value"
825                }}
826            }}
827        }}"#
828        )
829        .unwrap();
830
831        let result = lint_file(file.path(), file.path().parent().unwrap());
832        assert_eq!(result.status, FileStatus::Error);
833        assert!(result.diagnostics.iter().any(|d| d.code == "E004"));
834    }
835
836    #[test]
837    fn lint_valid_ucp_annotations() {
838        let mut file = NamedTempFile::new().unwrap();
839        writeln!(
840            file,
841            r#"{{
842            "$id": "https://example.com/test.json",
843            "properties": {{
844                "id": {{
845                    "type": "string",
846                    "ucp_request": {{
847                        "create": "omit",
848                        "update": "required"
849                    }},
850                    "ucp_response": "omit"
851                }}
852            }}
853        }}"#
854        )
855        .unwrap();
856
857        let result = lint_file(file.path(), file.path().parent().unwrap());
858        assert_eq!(result.status, FileStatus::Ok);
859        assert!(result.diagnostics.is_empty());
860    }
861
862    #[test]
863    fn lint_valid_schema_transition_object() {
864        let mut file = NamedTempFile::new().unwrap();
865        writeln!(
866            file,
867            r#"{{
868            "$id": "https://example.com/test.json",
869            "properties": {{
870                "legacy_id": {{
871                    "type": "string",
872                    "ucp_request": {{
873                        "update": {{
874                            "transition": {{
875                                "from": "required",
876                                "to": "omit",
877                                "description": "Will be removed in v2."
878                            }}
879                        }}
880                    }}
881                }}
882            }}
883        }}"#
884        )
885        .unwrap();
886
887        let result = lint_file(file.path(), file.path().parent().unwrap());
888        assert_eq!(result.status, FileStatus::Ok);
889        assert!(result.diagnostics.is_empty());
890    }
891
892    #[test]
893    fn lint_invalid_schema_transition() {
894        let mut file = NamedTempFile::new().unwrap();
895        writeln!(
896            file,
897            r#"{{
898            "$id": "https://example.com/test.json",
899            "properties": {{
900                "x": {{
901                    "type": "string",
902                    "ucp_request": {{
903                        "transition": {{
904                            "from": "required",
905                            "to": "required",
906                            "description": "from and to must be distinct"
907                        }}
908                    }}
909                }}
910            }}
911        }}"#
912        )
913        .unwrap();
914
915        let result = lint_file(file.path(), file.path().parent().unwrap());
916        assert_eq!(result.status, FileStatus::Error);
917        assert!(result.diagnostics.iter().any(|d| d.code == "E004"));
918    }
919
920    #[test]
921    fn lint_schema_transition_missing_description() {
922        let mut file = NamedTempFile::new().unwrap();
923        writeln!(
924            file,
925            r#"{{
926            "$id": "https://example.com/test.json",
927            "properties": {{
928                "x": {{
929                    "type": "string",
930                    "ucp_request": {{
931                        "transition": {{
932                            "from": "required",
933                            "to": "omit"
934                        }}
935                    }}
936                }}
937            }}
938        }}"#
939        )
940        .unwrap();
941
942        let result = lint_file(file.path(), file.path().parent().unwrap());
943        assert_eq!(result.status, FileStatus::Error);
944        assert!(result
945            .diagnostics
946            .iter()
947            .any(|d| d.code == "E004" && d.message.contains("description")));
948    }
949
950    #[test]
951    fn lint_invalid_ucp_type() {
952        let mut file = NamedTempFile::new().unwrap();
953        writeln!(
954            file,
955            r#"{{
956            "$id": "https://example.com/test.json",
957            "properties": {{
958                "id": {{
959                    "type": "string",
960                    "ucp_request": 123
961                }}
962            }}
963        }}"#
964        )
965        .unwrap();
966
967        let result = lint_file(file.path(), file.path().parent().unwrap());
968        assert_eq!(result.status, FileStatus::Error);
969        assert!(result.diagnostics.iter().any(|d| d.code == "E005"));
970    }
971
972    #[test]
973    fn lint_missing_id_warning() {
974        let mut file = NamedTempFile::new().unwrap();
975        writeln!(
976            file,
977            r#"{{
978            "type": "object",
979            "properties": {{}}
980        }}"#
981        )
982        .unwrap();
983
984        let result = lint_file(file.path(), file.path().parent().unwrap());
985        assert_eq!(result.status, FileStatus::Warning);
986        assert!(result.diagnostics.iter().any(|d| d.code == "W002"));
987    }
988
989    #[test]
990    fn lint_directory() {
991        let dir = tempdir().unwrap();
992
993        // Create valid schema
994        let valid_path = dir.path().join("valid.json");
995        std::fs::write(
996            &valid_path,
997            r#"{"$id": "https://example.com/valid.json", "type": "object"}"#,
998        )
999        .unwrap();
1000
1001        // Create invalid schema
1002        let invalid_path = dir.path().join("invalid.json");
1003        std::fs::write(&invalid_path, "{ not json }").unwrap();
1004
1005        let result = lint(dir.path(), false);
1006        assert_eq!(result.files_checked, 2);
1007        assert_eq!(result.passed, 1);
1008        assert_eq!(result.failed, 1);
1009        assert!(!result.is_ok());
1010    }
1011
1012    #[test]
1013    fn lint_strict_mode() {
1014        let dir = tempdir().unwrap();
1015        let file_path = dir.path().join("test.json");
1016        // Schema with warning only (missing $id)
1017        std::fs::write(&file_path, r#"{"type": "object"}"#).unwrap();
1018
1019        // Non-strict: warnings don't cause failure
1020        let result = lint(&file_path, false);
1021        assert_eq!(result.files_checked, 1);
1022        assert_eq!(result.passed, 1);
1023        assert_eq!(result.failed, 0);
1024
1025        // Strict: warnings cause failure
1026        let result = lint(&file_path, true);
1027        assert_eq!(result.files_checked, 1);
1028        assert_eq!(result.passed, 0);
1029        assert_eq!(result.failed, 1);
1030    }
1031
1032    #[test]
1033    fn lint_valid_ref_with_anchor() {
1034        let dir = tempdir().unwrap();
1035
1036        // Create referenced schema with $defs
1037        let ref_path = dir.path().join("types.json");
1038        std::fs::write(
1039            &ref_path,
1040            r#"{"$id": "https://example.com/types.json", "$defs": {"thing": {"type": "string"}}}"#,
1041        )
1042        .unwrap();
1043
1044        // Create schema that references it
1045        let main_path = dir.path().join("main.json");
1046        std::fs::write(
1047            &main_path,
1048            r#"{"$id": "https://example.com/main.json", "properties": {"x": {"$ref": "types.json#/$defs/thing"}}}"#,
1049        )
1050        .unwrap();
1051
1052        let result = lint_file(&main_path, dir.path());
1053        assert_eq!(result.status, FileStatus::Ok);
1054    }
1055
1056    #[test]
1057    fn lint_valid_requires() {
1058        let mut file = NamedTempFile::new().unwrap();
1059        writeln!(
1060            file,
1061            r#"{{
1062            "$id": "https://example.com/loyalty.json",
1063            "requires": {{
1064                "protocol": {{ "min": "2026-01-23" }},
1065                "capabilities": {{
1066                    "dev.ucp.shopping.checkout": {{ "min": "2026-06-01" }}
1067                }}
1068            }},
1069            "$defs": {{
1070                "dev.ucp.shopping.checkout": {{ "type": "object" }}
1071            }}
1072        }}"#
1073        )
1074        .unwrap();
1075
1076        let result = lint_file(file.path(), file.path().parent().unwrap());
1077        assert_eq!(result.status, FileStatus::Ok);
1078        assert!(result.diagnostics.is_empty());
1079    }
1080
1081    #[test]
1082    fn lint_requires_with_range() {
1083        let mut file = NamedTempFile::new().unwrap();
1084        writeln!(
1085            file,
1086            r#"{{
1087            "$id": "https://example.com/loyalty.json",
1088            "requires": {{
1089                "protocol": {{ "min": "2026-01-23", "max": "2026-09-01" }}
1090            }}
1091        }}"#
1092        )
1093        .unwrap();
1094
1095        let result = lint_file(file.path(), file.path().parent().unwrap());
1096        assert_eq!(result.status, FileStatus::Ok);
1097        assert!(result.diagnostics.is_empty());
1098    }
1099
1100    #[test]
1101    fn lint_requires_not_object() {
1102        let mut file = NamedTempFile::new().unwrap();
1103        writeln!(
1104            file,
1105            r#"{{
1106            "$id": "https://example.com/test.json",
1107            "requires": "bad"
1108        }}"#
1109        )
1110        .unwrap();
1111
1112        let result = lint_file(file.path(), file.path().parent().unwrap());
1113        assert_eq!(result.status, FileStatus::Error);
1114        assert!(result.diagnostics.iter().any(|d| d.code == "E006"));
1115    }
1116
1117    #[test]
1118    fn lint_requires_bad_version_format() {
1119        let mut file = NamedTempFile::new().unwrap();
1120        writeln!(
1121            file,
1122            r#"{{
1123            "$id": "https://example.com/test.json",
1124            "requires": {{
1125                "protocol": {{ "min": "not-a-date" }}
1126            }}
1127        }}"#
1128        )
1129        .unwrap();
1130
1131        let result = lint_file(file.path(), file.path().parent().unwrap());
1132        assert_eq!(result.status, FileStatus::Error);
1133        assert!(result.diagnostics.iter().any(|d| d.code == "E006"));
1134    }
1135
1136    #[test]
1137    fn lint_requires_missing_min() {
1138        let mut file = NamedTempFile::new().unwrap();
1139        writeln!(
1140            file,
1141            r#"{{
1142            "$id": "https://example.com/test.json",
1143            "requires": {{
1144                "protocol": {{ "max": "2026-09-01" }}
1145            }}
1146        }}"#
1147        )
1148        .unwrap();
1149
1150        let result = lint_file(file.path(), file.path().parent().unwrap());
1151        assert_eq!(result.status, FileStatus::Error);
1152        assert!(result
1153            .diagnostics
1154            .iter()
1155            .any(|d| d.code == "E006" && d.message.contains("min")));
1156    }
1157
1158    #[test]
1159    fn lint_requires_min_greater_than_max() {
1160        let mut file = NamedTempFile::new().unwrap();
1161        writeln!(
1162            file,
1163            r#"{{
1164            "$id": "https://example.com/test.json",
1165            "requires": {{
1166                "protocol": {{ "min": "2026-09-01", "max": "2026-01-23" }}
1167            }}
1168        }}"#
1169        )
1170        .unwrap();
1171
1172        let result = lint_file(file.path(), file.path().parent().unwrap());
1173        assert!(result.diagnostics.iter().any(|d| d.code == "W004"));
1174    }
1175
1176    #[test]
1177    fn lint_requires_capability_not_in_defs() {
1178        let mut file = NamedTempFile::new().unwrap();
1179        writeln!(
1180            file,
1181            r#"{{
1182            "$id": "https://example.com/loyalty.json",
1183            "requires": {{
1184                "capabilities": {{
1185                    "dev.ucp.shopping.checkout": {{ "min": "2026-06-01" }}
1186                }}
1187            }},
1188            "$defs": {{
1189                "dev.ucp.shopping.order": {{ "type": "object" }}
1190            }}
1191        }}"#
1192        )
1193        .unwrap();
1194
1195        let result = lint_file(file.path(), file.path().parent().unwrap());
1196        assert_eq!(result.status, FileStatus::Error);
1197        assert!(result.diagnostics.iter().any(|d| d.code == "E007"));
1198    }
1199
1200    #[test]
1201    fn lint_requires_capability_no_defs() {
1202        let mut file = NamedTempFile::new().unwrap();
1203        writeln!(
1204            file,
1205            r#"{{
1206            "$id": "https://example.com/test.json",
1207            "requires": {{
1208                "capabilities": {{
1209                    "dev.ucp.shopping.checkout": {{ "min": "2026-06-01" }}
1210                }}
1211            }}
1212        }}"#
1213        )
1214        .unwrap();
1215
1216        let result = lint_file(file.path(), file.path().parent().unwrap());
1217        assert_eq!(result.status, FileStatus::Error);
1218        assert!(result.diagnostics.iter().any(|d| d.code == "E007"));
1219    }
1220
1221    #[test]
1222    fn lint_requires_unknown_key_in_requires() {
1223        let mut file = NamedTempFile::new().unwrap();
1224        writeln!(
1225            file,
1226            r#"{{
1227            "$id": "https://example.com/test.json",
1228            "requires": {{
1229                "proto_version": {{ "min": "2026-01-23" }}
1230            }}
1231        }}"#
1232        )
1233        .unwrap();
1234
1235        let result = lint_file(file.path(), file.path().parent().unwrap());
1236        assert!(result
1237            .diagnostics
1238            .iter()
1239            .any(|d| d.code == "W005" && d.message.contains("proto_version")));
1240    }
1241
1242    #[test]
1243    fn lint_requires_unknown_key_in_constraint() {
1244        let mut file = NamedTempFile::new().unwrap();
1245        writeln!(
1246            file,
1247            r#"{{
1248            "$id": "https://example.com/test.json",
1249            "requires": {{
1250                "protocol": {{ "min": "2026-01-23", "maxx": "2026-09-01" }}
1251            }}
1252        }}"#
1253        )
1254        .unwrap();
1255
1256        let result = lint_file(file.path(), file.path().parent().unwrap());
1257        assert!(result
1258            .diagnostics
1259            .iter()
1260            .any(|d| d.code == "W005" && d.message.contains("maxx")));
1261    }
1262
1263    #[test]
1264    fn lint_requires_empty_capabilities_ok() {
1265        let mut file = NamedTempFile::new().unwrap();
1266        writeln!(
1267            file,
1268            r#"{{
1269            "$id": "https://example.com/test.json",
1270            "requires": {{
1271                "capabilities": {{}}
1272            }}
1273        }}"#
1274        )
1275        .unwrap();
1276
1277        let result = lint_file(file.path(), file.path().parent().unwrap());
1278        assert_eq!(result.status, FileStatus::Ok);
1279    }
1280
1281    #[test]
1282    fn lint_schema_without_requires_unchanged() {
1283        // Backwards compat: schemas without `requires` pass unchanged
1284        let mut file = NamedTempFile::new().unwrap();
1285        writeln!(
1286            file,
1287            r#"{{
1288            "$id": "https://example.com/test.json",
1289            "type": "object",
1290            "properties": {{
1291                "id": {{ "type": "string" }}
1292            }}
1293        }}"#
1294        )
1295        .unwrap();
1296
1297        let result = lint_file(file.path(), file.path().parent().unwrap());
1298        assert_eq!(result.status, FileStatus::Ok);
1299    }
1300
1301    #[test]
1302    fn lint_broken_ref_anchor() {
1303        let dir = tempdir().unwrap();
1304
1305        // Create referenced schema without the expected $def
1306        let ref_path = dir.path().join("types.json");
1307        std::fs::write(
1308            &ref_path,
1309            r#"{"$id": "https://example.com/types.json", "$defs": {}}"#,
1310        )
1311        .unwrap();
1312
1313        // Create schema that references missing anchor
1314        let main_path = dir.path().join("main.json");
1315        std::fs::write(
1316            &main_path,
1317            r#"{"$id": "https://example.com/main.json", "properties": {"x": {"$ref": "types.json#/$defs/missing"}}}"#,
1318        )
1319        .unwrap();
1320
1321        let result = lint_file(&main_path, dir.path());
1322        assert_eq!(result.status, FileStatus::Error);
1323        assert!(result.diagnostics.iter().any(|d| d.code == "E003"));
1324    }
1325}