speccoder_openspec/
crosscheck.rs1use serde_json::Value;
8use speccoder_core::snapshot::{Diagnostic, ParsedTasks, Severity};
9
10const ARCHIVE_WOULD_REFUSE: &str = "Archive would refuse this delta";
11
12#[must_use]
16pub fn compare(
17 change_dir: &str,
18 tasks: Option<&ParsedTasks>,
19 validate: Option<&Value>,
20 apply: Option<&Value>,
21) -> Vec<Diagnostic> {
22 let mut out = Vec::new();
23 for item in validate
24 .and_then(|v| v["items"].as_array())
25 .into_iter()
26 .flatten()
27 {
28 for issue in item["issues"].as_array().into_iter().flatten() {
29 let message = issue["message"].as_str().unwrap_or_default();
30 let severity = match issue["level"].as_str().unwrap_or_default() {
31 "ERROR" => Some(Severity::Error),
32 "INFO" if message.starts_with(ARCHIVE_WOULD_REFUSE) => Some(Severity::Error),
33 "WARNING" => Some(Severity::Warning),
34 _ => None,
35 };
36 if let Some(severity) = severity {
37 let path = issue["path"].as_str().map_or_else(
38 || change_dir.to_owned(),
39 |p| format!("{change_dir}/specs/{p}"),
40 );
41 out.push(
42 Diagnostic::new(
43 severity,
44 "crosscheck_error",
45 format!("openspec validate: {message}"),
46 )
47 .at(path),
48 );
49 }
50 }
51 }
52 if let (Some(apply), Some(tasks)) = (apply, tasks) {
53 let total = apply["progress"]["total"].as_u64();
54 let complete = apply["progress"]["complete"].as_u64();
55 let own_total = u64::try_from(tasks.totals.total).unwrap_or(u64::MAX);
56 let own_done = u64::try_from(tasks.totals.done).unwrap_or(u64::MAX);
57 if total.is_some_and(|t| t != own_total) || complete.is_some_and(|c| c != own_done) {
58 out.push(
59 Diagnostic::new(
60 Severity::Error,
61 "crosscheck_mismatch",
62 format!(
63 "OpenSpec counts {} task(s), {} done; speccoder counts {own_total}, {own_done} done",
64 total.unwrap_or_default(),
65 complete.unwrap_or_default()
66 ),
67 )
68 .at(&tasks.path)
69 .hint("report this tasks.md to speccoder: its parser disagrees with OpenSpec's"),
70 );
71 }
72 let theirs: std::collections::BTreeSet<u64> = apply["tasks"]
73 .as_array()
74 .into_iter()
75 .flatten()
76 .filter_map(|t| t["line"].as_u64())
77 .collect();
78 let ours: std::collections::BTreeSet<u64> =
79 tasks.tasks.iter().map(|t| u64::from(t.line)).collect();
80 if !theirs.is_empty() && theirs != ours {
81 out.push(
82 Diagnostic::new(
83 Severity::Warning,
84 "crosscheck_mismatch",
85 "OpenSpec and speccoder see tasks on different lines",
86 )
87 .at(&tasks.path),
88 );
89 }
90 }
91 out
92}
93
94#[must_use]
96pub fn skipped(why: &str) -> Diagnostic {
97 Diagnostic::new(
98 Severity::Info,
99 "crosscheck_skipped",
100 format!("cross-check with OpenSpec's CLI skipped: {why}"),
101 )
102}