Skip to main content

xtask/rmcp_release_monitor/
conformance.rs

1//! MCP conformance drift watch.
2//!
3//! Compares the pinned conformance-suite commit against upstream `main`, and -
4//! when they diverge - renders the new commits, the changed suite files, and
5//! the local impact shortlist into the issue body.
6
7use anyhow::{Context, Result};
8use serde::Deserialize;
9use std::collections::BTreeSet;
10use std::path::PathBuf;
11
12use super::diagnostics::json_parse_context;
13use super::impact::{RepoImpact, append_impact_section, collect_identifiers, scan_repo_impacts};
14use super::short_sha;
15
16#[derive(Debug)]
17pub(super) struct ConformanceMonitorInput {
18    pub(super) baseline_sha: String,
19    pub(super) head_json: String,
20    pub(super) compare_json: Option<String>,
21    pub(super) url: String,
22    pub(super) repo_root: PathBuf,
23}
24
25#[derive(Debug)]
26pub(super) struct ConformanceReport {
27    pub(super) drift: bool,
28    pub(super) baseline_sha: String,
29    pub(super) head_sha: String,
30    url: String,
31    head_date: String,
32    head_message: String,
33    head_html_url: String,
34    commits: Vec<ConformanceCommit>,
35    files: Vec<ConformanceFile>,
36    impacts: Vec<RepoImpact>,
37}
38
39#[derive(Debug, Deserialize)]
40struct ConformanceHead {
41    sha: String,
42    html_url: String,
43    commit: ConformanceCommitDetails,
44}
45
46#[derive(Debug, Deserialize)]
47struct ConformanceCompare {
48    #[serde(default)]
49    commits: Vec<ConformanceCommit>,
50    #[serde(default)]
51    files: Vec<ConformanceFile>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
55struct ConformanceCommit {
56    sha: String,
57    html_url: String,
58    commit: ConformanceCommitDetails,
59}
60
61#[derive(Debug, Clone, Deserialize)]
62struct ConformanceCommitDetails {
63    message: String,
64    author: ConformanceCommitAuthor,
65}
66
67#[derive(Debug, Clone, Deserialize)]
68struct ConformanceCommitAuthor {
69    date: String,
70}
71
72#[derive(Debug, Deserialize)]
73struct ConformanceFile {
74    filename: String,
75    status: String,
76    additions: u64,
77    deletions: u64,
78    changes: u64,
79    #[serde(default)]
80    blob_url: Option<String>,
81    #[serde(default)]
82    patch: Option<String>,
83}
84
85pub(super) fn build_conformance_report(
86    input: &ConformanceMonitorInput,
87) -> Result<ConformanceReport> {
88    let head: ConformanceHead = serde_json::from_str(&input.head_json).with_context(|| {
89        json_parse_context(
90            "MCP conformance head JSON (--conformance-head-json)",
91            &input.head_json,
92        )
93    })?;
94    let baseline_sha = input.baseline_sha.trim().to_owned();
95    let drift = baseline_sha != head.sha;
96    let compare = input
97        .compare_json
98        .as_deref()
99        .map(|json| {
100            serde_json::from_str::<ConformanceCompare>(json).with_context(|| {
101                json_parse_context(
102                    "MCP conformance compare JSON (--conformance-compare-json)",
103                    json,
104                )
105            })
106        })
107        .transpose()?;
108    let commits = compare
109        .as_ref()
110        .map(|compare| compare.commits.clone())
111        .unwrap_or_default();
112    let files = compare.map(|compare| compare.files).unwrap_or_default();
113    let changed_terms = if drift {
114        changed_terms_from_conformance_files(&files)
115    } else {
116        BTreeSet::new()
117    };
118    Ok(ConformanceReport {
119        drift,
120        baseline_sha,
121        head_sha: head.sha,
122        url: input.url.clone(),
123        head_date: head.commit.author.date,
124        head_message: head.commit.message,
125        head_html_url: head.html_url,
126        commits,
127        files,
128        impacts: if drift {
129            scan_repo_impacts(&input.repo_root, &changed_terms)?
130        } else {
131            Vec::new()
132        },
133    })
134}
135
136pub(super) fn append_conformance_section(body: &mut String, report: &ConformanceReport) {
137    body.push_str("## MCP Conformance Watch\n\n");
138    body.push_str(&format!(
139        "- Upstream repo: [{}]({})\n",
140        report.url, report.url
141    ));
142    body.push_str(&format!("- Baseline SHA: `{}`\n", report.baseline_sha));
143    body.push_str(&format!(
144        "- Head SHA: [`{}`]({})\n",
145        short_sha(&report.head_sha),
146        report.head_html_url
147    ));
148    body.push_str(&format!("- Head date: `{}`\n", report.head_date));
149    body.push_str(&format!("- Drift: `{}`\n\n", report.drift));
150    let head_summary = report.head_message.lines().next().unwrap_or("").trim();
151    if !head_summary.is_empty() {
152        body.push_str(&format!("Latest commit: {head_summary}\n\n"));
153    }
154    if !report.commits.is_empty() {
155        body.push_str("### New conformance commits\n\n");
156        for commit in report.commits.iter().take(10) {
157            let summary = commit.commit.message.lines().next().unwrap_or("").trim();
158            body.push_str(&format!(
159                "- [`{}`]({}) `{}` {}\n",
160                short_sha(&commit.sha),
161                commit.html_url,
162                commit.commit.author.date,
163                summary
164            ));
165        }
166        body.push('\n');
167    }
168    if !report.files.is_empty() {
169        body.push_str("### Changed conformance files\n\n");
170        body.push_str("| File | Status | +/- | Changes |\n");
171        body.push_str("|---|---:|---:|---:|\n");
172        for file in report.files.iter().take(20) {
173            let file_link = file
174                .blob_url
175                .as_ref()
176                .map(|url| format!("[`{}`]({url})", file.filename))
177                .unwrap_or_else(|| format!("`{}`", file.filename));
178            body.push_str(&format!(
179                "| {file_link} | `{}` | +{} / -{} | {} |\n",
180                file.status, file.additions, file.deletions, file.changes
181            ));
182        }
183        if report.files.len() > 20 {
184            body.push_str(&format!(
185                "| _{} more files_ |  |  |  |\n",
186                report.files.len() - 20
187            ));
188        }
189        body.push('\n');
190    }
191    append_impact_section(
192        body,
193        "Potential conformance impact in this repo",
194        &report.impacts,
195    );
196}
197
198fn changed_terms_from_conformance_files(files: &[ConformanceFile]) -> BTreeSet<String> {
199    let mut terms = BTreeSet::new();
200    for file in files {
201        collect_identifiers(&file.filename, &mut terms);
202        if let Some(patch) = &file.patch {
203            for line in patch.lines() {
204                if line.starts_with('+') || line.starts_with('-') {
205                    collect_identifiers(line, &mut terms);
206                }
207            }
208        }
209    }
210    terms
211}