mcp_trace_validator/report.rs
1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Validation reports: per-requirement outcomes with actionable findings.
5//!
6//! Reports are artifacts: they get committed as golden files, diffed in CI, and cited
7//! in published results. Two consequences shape this module: serialization order is
8//! fixed (registry order; struct fields in declaration order), and nothing
9//! environment-dependent (paths, timestamps, hostnames) is ever included.
10
11use core::fmt;
12
13use serde::{Deserialize, Serialize};
14
15mod human;
16
17/// One concrete violation, addressed to a requirement and (where possible) an event.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct Finding {
21 /// The validator check that produced this finding.
22 pub check: String,
23 /// The event `seq` the finding points at, when one event is identifiable.
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub seq: Option<u64>,
26 /// What was observed and what was expected, in one actionable sentence.
27 pub detail: String,
28}
29
30/// The outcome of evaluating one requirement against one trace.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33#[non_exhaustive]
34pub enum Outcome {
35 /// All covering checks ran and produced no findings.
36 Pass,
37 /// A MUST / MUST NOT requirement has findings.
38 Fail,
39 /// A SHOULD / SHOULD NOT requirement has findings.
40 Warn,
41 /// The registry documents that this requirement is not judged from traces.
42 Excluded,
43 /// The registry references a check this validator build does not implement.
44 Unsupported,
45 /// The requirement is gated on a capability this session never declared
46 /// (ADR-0006); its checks were not run.
47 NotApplicable,
48 /// Every covering check ran and found nothing to judge: this session
49 /// carried none of the traffic the clause binds to.
50 ///
51 /// Distinct from [`Self::Pass`], and the distinction is the whole point. A
52 /// clause about `subscriptions/listen` cannot be *complied with* by a
53 /// session that never opened a stream — there was no opportunity to break
54 /// it — so reporting `pass` states evidence the trace does not carry.
55 /// Distinct from [`Self::NotApplicable`] too: that one is the registry
56 /// saying the clause is gated on a capability nobody declared, this one is
57 /// the trace saying it had nothing to show.
58 NotObserved,
59}
60
61/// Aggregate counts, in report order. `excluded` and `unsupported` are first-class:
62/// inflating pass rates by hiding them is how conformance tools lose trust.
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[non_exhaustive]
65pub struct Totals {
66 /// Requirements with outcome [`Outcome::Pass`].
67 pub pass: u32,
68 /// Requirements with outcome [`Outcome::Fail`].
69 pub fail: u32,
70 /// Requirements with outcome [`Outcome::Warn`].
71 pub warn: u32,
72 /// Requirements with outcome [`Outcome::Excluded`].
73 pub excluded: u32,
74 /// Requirements with outcome [`Outcome::Unsupported`].
75 pub unsupported: u32,
76 /// Requirements with outcome [`Outcome::NotApplicable`].
77 pub not_applicable: u32,
78 /// Requirements with outcome [`Outcome::NotObserved`].
79 pub not_observed: u32,
80}
81
82impl Totals {
83 /// Every outcome's report label and count, in report order.
84 ///
85 /// Destructured exhaustively on purpose, and that is the whole point of the
86 /// method existing: a field added to [`Totals`] fails to compile here until
87 /// it is given a label, and every summary line in the crate is formatted
88 /// from this one list. Hand-written `write!` arms could not offer that —
89 /// the single-revision line named all seven outcomes while the
90 /// multi-revision line named six, so the same run reported 140 clauses as
91 /// human text and 140 as JSON but only accounted for 125 of them in the
92 /// former. Counts that do not add up are how a conformance tool overstates
93 /// what it judged.
94 #[must_use]
95 pub const fn labelled(&self) -> [(&'static str, u32); 7] {
96 let Self {
97 pass,
98 fail,
99 warn,
100 excluded,
101 unsupported,
102 not_applicable,
103 not_observed,
104 } = *self;
105 [
106 ("pass", pass),
107 ("fail", fail),
108 ("warn", warn),
109 ("excluded", excluded),
110 ("unsupported", unsupported),
111 ("not applicable", not_applicable),
112 ("not observed", not_observed),
113 ]
114 }
115}
116
117/// The counts as one phrase — `23 pass, 0 fail, …` — naming every outcome.
118///
119/// The summary lines differ in what surrounds them (`totals: ` on a
120/// single-revision report, the revision and its verdict on a multi-revision
121/// one) and agree on what is inside, so what is inside is written once.
122impl fmt::Display for Totals {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 for (index, (label, count)) in self.labelled().into_iter().enumerate() {
125 if index > 0 {
126 f.write_str(", ")?;
127 }
128 write!(f, "{count} {label}")?;
129 }
130 Ok(())
131 }
132}
133
134/// One requirement's row in the report.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[non_exhaustive]
137pub struct RequirementReport {
138 /// The requirement ID (`AREA-NNN`).
139 pub id: String,
140 /// The requirement's RFC 2119 level, as registry text (`"MUST"`, …).
141 pub level: String,
142 /// The evaluation outcome.
143 pub outcome: Outcome,
144 /// Findings, in event order. Empty unless `outcome` is `fail` or `warn`.
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub findings: Vec<Finding>,
147 /// The documented exclusion reason, when `outcome` is `excluded`.
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub exclusion: Option<String>,
150 /// Check IDs the build lacks, when `outcome` is `unsupported`.
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub missing_checks: Vec<String>,
153 /// The undeclared capability gate, when `outcome` is `not-applicable`.
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub capability: Option<String>,
156}
157
158/// A complete validation report for one trace against one registry.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[non_exhaustive]
161pub struct Report {
162 /// The registry's protocol revision (`YYYY-MM-DD`).
163 pub revision: String,
164 /// The revisions the *session* declared, when it declared some and
165 /// [`Self::revision`] is not among them — so the reader is told that these
166 /// findings judge the trace against rules it was not playing by.
167 ///
168 /// Absent whenever there is nothing to say, which is the common case; see
169 /// [`crate::declared`] for what counts as a declaration and why the rule is
170 /// deliberately quiet.
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub revision_mismatch: Option<Vec<String>>,
173 /// Aggregate counts.
174 pub totals: Totals,
175 /// Per-requirement outcomes, in registry order.
176 pub requirements: Vec<RequirementReport>,
177}
178
179impl Report {
180 /// `true` when any requirement failed (errors, not warnings).
181 #[must_use]
182 pub const fn has_errors(&self) -> bool {
183 self.totals.fail > 0
184 }
185
186 /// `true` when any SHOULD-level requirement produced findings.
187 #[must_use]
188 pub const fn has_warnings(&self) -> bool {
189 self.totals.warn > 0
190 }
191
192 /// `true` when the registry referenced checks this build does not implement.
193 #[must_use]
194 pub const fn has_unsupported(&self) -> bool {
195 self.totals.unsupported > 0
196 }
197
198 /// One-word verdict for the trailing summary line.
199 #[must_use]
200 pub const fn verdict(&self) -> Verdict {
201 if self.totals.unsupported > 0 {
202 Verdict::Unsupported
203 } else if self.totals.fail > 0 {
204 Verdict::Fail
205 } else if self.totals.warn > 0 {
206 Verdict::PassWithWarnings
207 } else {
208 Verdict::Pass
209 }
210 }
211}
212
213/// Overall verdict of a validation run.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "kebab-case")]
216#[non_exhaustive]
217pub enum Verdict {
218 /// No findings at all.
219 Pass,
220 /// Only SHOULD-level findings.
221 PassWithWarnings,
222 /// At least one MUST-level violation.
223 Fail,
224 /// The registry and this build disagree about available checks.
225 Unsupported,
226}
227
228impl fmt::Display for Verdict {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 let text = match self {
231 Self::Pass => "pass",
232 Self::PassWithWarnings => "pass-with-warnings",
233 Self::Fail => "fail",
234 Self::Unsupported => "unsupported",
235 };
236 f.write_str(text)
237 }
238}
239
240#[cfg(test)]
241#[allow(clippy::unwrap_used)]
242mod tests {
243 use super::*;
244
245 fn row(id: &str, level: &str, outcome: Outcome) -> RequirementReport {
246 RequirementReport {
247 id: id.to_owned(),
248 level: level.to_owned(),
249 outcome,
250 findings: vec![],
251 exclusion: None,
252 missing_checks: vec![],
253 capability: None,
254 }
255 }
256
257 /// One row of every outcome the renderer can produce, so the totals line
258 /// and the per-row text are pinned against the full set rather than a
259 /// convenient subset.
260 fn sample() -> Report {
261 let mut failed = row("LIFE-001", "MUST", Outcome::Fail);
262 failed.findings = vec![Finding {
263 check: "lifecycle.first-interaction-initialize".to_owned(),
264 seq: Some(3),
265 detail: "first message is \"tools/list\", expected \"initialize\"".to_owned(),
266 }];
267 let mut excluded = row("TRAN-001", "MUST NOT", Outcome::Excluded);
268 excluded.exclusion = Some("enforced at capture time".to_owned());
269 let mut not_applicable = row("TOOL-001", "MUST", Outcome::NotApplicable);
270 not_applicable.capability = Some("server.tools".to_owned());
271 Report {
272 revision_mismatch: None,
273 revision: "2025-11-25".to_owned(),
274 totals: Totals {
275 pass: 1,
276 fail: 1,
277 warn: 0,
278 excluded: 1,
279 unsupported: 0,
280 not_applicable: 1,
281 not_observed: 1,
282 },
283 requirements: vec![
284 row("BASE-001", "MUST", Outcome::Pass),
285 failed,
286 excluded,
287 not_applicable,
288 row("PAGE-002", "MUST", Outcome::NotObserved),
289 ],
290 }
291 }
292
293 #[test]
294 fn verdict_priority_is_unsupported_fail_warn_pass() {
295 let mut report = sample();
296 assert_eq!(report.verdict(), Verdict::Fail);
297 report.totals.unsupported = 1;
298 assert_eq!(report.verdict(), Verdict::Unsupported);
299 report.totals.unsupported = 0;
300 report.totals.fail = 0;
301 report.totals.warn = 2;
302 assert_eq!(report.verdict(), Verdict::PassWithWarnings);
303 report.totals.warn = 0;
304 assert_eq!(report.verdict(), Verdict::Pass);
305 }
306
307 #[test]
308 fn human_rendering_shows_findings_and_totals() {
309 let text = sample().render_human();
310 assert!(text.contains("FAIL LIFE-001 (MUST)"), "{text}");
311 assert!(text.contains("seq 3:"), "{text}");
312 assert!(
313 text.contains("excluded: enforced at capture time"),
314 "{text}"
315 );
316 assert!(text.contains("N/A TOOL-001 (MUST)"), "{text}");
317 assert!(
318 text.contains(
319 "not applicable: capability server.tools was not declared in this session"
320 ),
321 "{text}"
322 );
323 // A not-observed row says so in words, like every other non-judged
324 // outcome: "NOBS" alone tells an operator nothing about *why*. Pinned
325 // as the two lines *together*, and counted: asserting only that the
326 // sentence appears somewhere passes just as well when it is attached
327 // to every row except the one it describes.
328 assert!(
329 text.contains(
330 " NOBS PAGE-002 (MUST)\n not observed: the session carried none of \
331 the traffic this clause binds to\n"
332 ),
333 "{text}"
334 );
335 assert_eq!(
336 text.matches("not observed:").count(),
337 1,
338 "exactly the not-observed row carries the reason: {text}"
339 );
340 // The whole line, anchored at both ends: a `contains` of a prefix would
341 // pass while a new outcome went unnamed and the counts stopped summing
342 // to the registry's size.
343 assert!(
344 text.contains(
345 "\ntotals: 1 pass, 1 fail, 0 warn, 1 excluded, 0 unsupported, \
346 1 not applicable, 1 not observed\n"
347 ),
348 "{text}"
349 );
350 assert!(text.contains("verdict: fail"), "{text}");
351 }
352
353 #[test]
354 fn json_omits_empty_collections() {
355 let report = sample();
356 let json = serde_json::to_string(&report).unwrap();
357 assert!(json.contains("\"revision\":\"2025-11-25\""), "{json}");
358 // Passing rows carry no findings/exclusion/missing_checks keys.
359 assert!(!json.contains("\"missing_checks\""), "{json}");
360 }
361
362 /// The counts a rendered summary line actually carries, read back out of
363 /// the text a reader sees rather than off the struct the renderer was
364 /// handed — the two disagreeing is the whole failure this guards.
365 fn counts_in(line: &str) -> Vec<u32> {
366 // The first number in each comma-separated part is its count; what
367 // surrounds it (`totals: ` here, a revision there) carries none.
368 line.split(", ")
369 .filter_map(|part| part.split_whitespace().find_map(|word| word.parse().ok()))
370 .collect()
371 }
372
373 #[test]
374 fn a_summary_line_accounts_for_every_requirement() {
375 let report = sample();
376 let text = report.render_human();
377 let line = text
378 .lines()
379 .find(|line| line.starts_with("totals: "))
380 .unwrap();
381 let counts = counts_in(line);
382 assert_eq!(
383 counts.len(),
384 Totals::default().labelled().len(),
385 "every outcome is named: {line}"
386 );
387 // The invariant the line's own comment claims, asserted rather than
388 // left to a reader's arithmetic: what the renderer prints must add up
389 // to the rows it printed. The multi-revision line made exactly this
390 // claim in prose and silently broke it.
391 assert_eq!(
392 counts.iter().sum::<u32>() as usize,
393 report.requirements.len(),
394 "{line}"
395 );
396 }
397
398 #[test]
399 fn every_outcome_has_a_label_and_they_are_distinct() {
400 let labels: Vec<&str> = Totals::default()
401 .labelled()
402 .iter()
403 .map(|&(label, _)| label)
404 .collect();
405 let mut sorted = labels.clone();
406 sorted.sort_unstable();
407 sorted.dedup();
408 assert_eq!(sorted.len(), labels.len(), "duplicate label in {labels:?}");
409 // Each count sits with its own label: a swapped pair would keep the sum
410 // and the label set intact, so the mapping is pinned too.
411 let totals = Totals {
412 pass: 1,
413 fail: 2,
414 warn: 3,
415 excluded: 4,
416 unsupported: 5,
417 not_applicable: 6,
418 not_observed: 7,
419 };
420 assert_eq!(
421 totals.to_string(),
422 "1 pass, 2 fail, 3 warn, 4 excluded, 5 unsupported, 6 not applicable, 7 not observed"
423 );
424 }
425
426 #[test]
427 fn totals_predicates_pin_their_thresholds() {
428 let mut report = sample();
429 report.totals = Totals::default();
430 assert!(!report.has_errors());
431 assert!(!report.has_warnings());
432 assert!(!report.has_unsupported());
433 report.totals.fail = 1;
434 assert!(report.has_errors());
435 report.totals.warn = 1;
436 assert!(report.has_warnings());
437 report.totals.unsupported = 1;
438 assert!(report.has_unsupported());
439 }
440}