Skip to main content

speccoder_openspec/
crosscheck.rs

1//! speccoder's parse against OpenSpec's own answer: the JSON
2//! of `openspec validate <id> --strict --json` and
3//! `openspec instructions apply --change <id> --json`, which the runtime may
4//! run. A disagreement is evidence of a parser gap: shown, never resolved
5//! silently. This module only reads JSON; it runs nothing.
6
7use serde_json::Value;
8use speccoder_core::snapshot::{Diagnostic, ParsedTasks, Severity};
9
10const ARCHIVE_WOULD_REFUSE: &str = "Archive would refuse this delta";
11
12/// Diagnostics from comparing `tasks` (speccoder's parse of the change) with
13/// OpenSpec's `validate` and `instructions apply` output (either may be
14/// absent).
15#[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/// Why the cross-check did not run, as an informational diagnostic.
95#[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}