Skip to main content

speccoder_core/
verify_branch.rs

1//! A branch's ticks and what their evidence comes to: the payload of
2//! `speccoder-verify check` and `speccoder attest verify --branch`
3//! (`branch_verify`), the rule that turns per-tick outcomes into the
4//! branch's conclusion, and the badge.
5//!
6//! The rules are here, pure, so every interface (the binary, the check
7//! run, the badge) concludes the same from the same ticks.
8
9use serde::Serialize;
10
11use crate::{ObjectVerification, RequirementDrift, SignerView, TraceRow, VerifyOutcome};
12
13/// `not_evaluated`: earlier-task regressions have no signed record
14/// this verifier could read yet.
15pub const EARLIER_TASK_REGRESSIONS: &str = "earlier_task_regressions";
16/// `not_evaluated`: a trailer-only bug task (`Spec-Task: <unit>/bug:<slug>`)
17/// is in the checked range, and this version cannot check it.
18pub const TRAILER_ONLY_TASKS: &str = "trailer_only_tasks";
19
20/// Where an annotation lands: a file and a 1-based line, at the head.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
22pub struct Anchor {
23    /// Repository-relative path.
24    pub path: String,
25    /// 1-based line.
26    pub line: u32,
27}
28
29/// What a checked task is.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
31#[serde(rename_all = "snake_case")]
32pub enum TickKind {
33    /// A `[X]` line in a task list.
34    Tick,
35    /// A closed task of a format home without a task list: its verdict
36    /// commit is the claim (a bug's `bug:<slug>`).
37    TrailerOnly,
38}
39
40/// A ticked task at the checked head.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
42pub struct Tick {
43    /// The unit, as the format adapter names it.
44    pub feature: String,
45    /// Task ID (`T012`, `1.3`, `bug:<slug>`).
46    pub task: String,
47    /// The task's title.
48    pub title: String,
49    /// A tick or a trailer-only task.
50    pub kind: TickKind,
51    /// The task line (a tick) or the home's assessment (trailer-only).
52    pub anchor: Anchor,
53    /// sha256 of the task line with its box read as open: what the
54    /// predicate's `task.line_hash` must equal. `None` for trailer-only.
55    pub line_sha256: Option<String>,
56    /// For trailer-only: the claim's digest at the head (the bug's triad).
57    pub claim_sha256: Option<String>,
58}
59
60/// Why a tick's evidence does not verify.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
62#[serde(rename_all = "snake_case")]
63pub enum Unverifiable {
64    /// No commit carries the task's verdict (ticked by hand).
65    TickedWithoutEvidence,
66    /// The task commit has no attestation note.
67    Missing,
68    /// A line of the note does not parse, or the predicate is not v0.
69    Malformed,
70    /// A signature does not verify.
71    BadSignature,
72    /// A statement disagrees with the commit, its trailers, the task line
73    /// or the bundle.
74    Mismatch,
75    /// Signed by a key the verifier does not trust.
76    UntrustedSigner,
77    /// Not signed (unverifiable unless the caller accepts unsigned ticks).
78    Unsigned,
79    /// The commit the evidence names is not reachable from the head (a
80    /// squash or rebase merge).
81    CommitUnreachable,
82    /// The signature's tool (`ssh-keygen`, `gpg`, `cosign`) cannot run.
83    ToolMissing,
84}
85
86impl Unverifiable {
87    /// The reason an `attest verify` outcome is, `None` for `ok`.
88    #[must_use]
89    pub const fn of(outcome: VerifyOutcome) -> Option<Self> {
90        match outcome {
91            VerifyOutcome::Ok => None,
92            VerifyOutcome::Unsigned => Some(Self::Unsigned),
93            VerifyOutcome::Missing => Some(Self::Missing),
94            VerifyOutcome::BadSignature => Some(Self::BadSignature),
95            VerifyOutcome::UntrustedSigner => Some(Self::UntrustedSigner),
96            VerifyOutcome::Mismatch => Some(Self::Mismatch),
97            VerifyOutcome::Malformed => Some(Self::Malformed),
98            VerifyOutcome::ToolMissing => Some(Self::ToolMissing),
99        }
100    }
101
102    /// The word reports use.
103    #[must_use]
104    pub const fn word(self) -> &'static str {
105        match self {
106            Self::TickedWithoutEvidence => "ticked_without_evidence",
107            Self::Missing => "missing",
108            Self::Malformed => "malformed",
109            Self::BadSignature => "bad_signature",
110            Self::Mismatch => "mismatch",
111            Self::UntrustedSigner => "untrusted_signer",
112            Self::Unsigned => "unsigned",
113            Self::CommitUnreachable => "commit_unreachable",
114            Self::ToolMissing => "tool_missing",
115        }
116    }
117}
118
119/// A tick's outcome.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
121#[serde(rename_all = "snake_case")]
122pub enum TickOutcome {
123    /// The evidence verifies; `verdict` says what it shows.
124    Evidenced,
125    /// The evidence verifies, but a requirement the task cites changed
126    /// since the tick: it is for the earlier wording.
127    Stale,
128    /// The evidence does not verify; `reason` says why.
129    Unverifiable,
130    /// Reserved: a later signed checkpoint marks this earlier task
131    /// regressed. No producer yet; it fails like `unverifiable`.
132    Regressed,
133}
134
135/// One tick's report.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
137pub struct TickReport {
138    /// The tick.
139    pub tick: Tick,
140    /// The task commit checked (the latest reachable with the task's
141    /// verdict trailers), when one exists.
142    pub commit: Option<String>,
143    /// The outcome.
144    pub outcome: TickOutcome,
145    /// `verified`, `regression_only` or `attested`, as the evidence claims.
146    pub verdict: Option<String>,
147    /// For `unverifiable`.
148    pub reason: Option<Unverifiable>,
149    /// More on the reason, in one line.
150    pub detail: Option<String>,
151    /// Who signed.
152    pub signer: Option<SignerView>,
153    /// Who accepted an `attested` tick.
154    pub decider: Option<String>,
155    /// For `stale`: the cited requirements that changed since the tick.
156    pub stale: Vec<RequirementDrift>,
157    /// For `stale`: the first changed requirement's line at the head.
158    pub stale_anchor: Option<Anchor>,
159    /// Predicate fields or revisions this verifier does not know (kept,
160    /// never deciding).
161    pub newer_fields: Vec<String>,
162    /// `attest verify`'s object for the commit, unchanged.
163    pub verification: Option<ObjectVerification>,
164}
165
166impl TickReport {
167    /// Whether the tick fails the branch.
168    #[must_use]
169    pub fn fails(&self, require_verified: bool) -> bool {
170        match self.outcome {
171            TickOutcome::Evidenced => {
172                require_verified && self.verdict.as_deref() != Some("verified")
173            }
174            TickOutcome::Stale | TickOutcome::Unverifiable | TickOutcome::Regressed => true,
175        }
176    }
177}
178
179/// A task skipped by a person's decision: no tick, not counted.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
181pub struct SkippedTask {
182    /// The unit.
183    pub feature: String,
184    /// Task ID.
185    pub task: String,
186}
187
188/// Which units a check covers.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
190#[serde(rename_all = "snake_case")]
191pub enum Mode {
192    /// Every unit whose task list or intent differs between base and head.
193    Touched,
194    /// Every unit (the badge, the default branch).
195    All,
196}
197
198/// The branch's conclusion.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
200#[serde(rename_all = "snake_case")]
201pub enum Conclusion {
202    /// Every tick carries verifying evidence.
203    Success,
204    /// A tick is unverifiable, stale or regressed (or not verified while
205    /// required), or a claim in range cannot be checked.
206    Failure,
207    /// Nothing to check: never success.
208    Neutral,
209}
210
211impl Conclusion {
212    /// The word reports and check runs use.
213    #[must_use]
214    pub const fn word(self) -> &'static str {
215        match self {
216            Self::Success => "success",
217            Self::Failure => "failure",
218            Self::Neutral => "neutral",
219        }
220    }
221
222    /// The verifier's exit status: 0, 1, or 78 (neutral).
223    #[must_use]
224    pub const fn exit_code(self) -> u8 {
225        match self {
226            Self::Success => 0,
227            Self::Failure => 1,
228            Self::Neutral => 78,
229        }
230    }
231
232    /// The rule (R7): any failing tick, or a claim this version cannot
233    /// check, is failure; nothing to check is neutral; else success.
234    #[must_use]
235    pub fn of(ticks: &[TickReport], require_verified: bool, not_evaluated: &[String]) -> Self {
236        if ticks.iter().any(|t| t.fails(require_verified))
237            || not_evaluated.iter().any(|n| n == TRAILER_ONLY_TASKS)
238        {
239            Self::Failure
240        } else if ticks.is_empty() {
241            Self::Neutral
242        } else {
243            Self::Success
244        }
245    }
246}
247
248/// Counts by outcome, in the verdict bar's order.
249#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
250pub struct BranchTotals {
251    /// Evidenced `verified`.
252    pub verified: usize,
253    /// Evidenced `regression_only`.
254    pub regression_only: usize,
255    /// Evidenced `attested`.
256    pub attested: usize,
257    /// Stale.
258    pub stale: usize,
259    /// Unverifiable.
260    pub unverifiable: usize,
261    /// Regressed (reserved).
262    pub regressed: usize,
263    /// Skipped by a person (not ticks).
264    pub skipped: usize,
265}
266
267impl BranchTotals {
268    /// The counts of `ticks` (and `skipped` skips).
269    #[must_use]
270    pub fn of(ticks: &[TickReport], skipped: usize) -> Self {
271        let mut t = Self {
272            skipped,
273            ..Self::default()
274        };
275        for r in ticks {
276            match (r.outcome, r.verdict.as_deref()) {
277                (TickOutcome::Evidenced, Some("verified")) => t.verified += 1,
278                (TickOutcome::Evidenced, Some("attested")) => t.attested += 1,
279                (TickOutcome::Evidenced, _) => t.regression_only += 1,
280                (TickOutcome::Stale, _) => t.stale += 1,
281                (TickOutcome::Unverifiable, _) => t.unverifiable += 1,
282                (TickOutcome::Regressed, _) => t.regressed += 1,
283            }
284        }
285        t
286    }
287}
288
289/// Result of `speccoder-verify check` / `speccoder attest verify --branch`.
290#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
291pub struct BranchReport {
292    /// The commit checked.
293    pub head: String,
294    /// The merge base with the given base, when one was given.
295    pub base: Option<String>,
296    /// Which units were covered.
297    pub mode: Mode,
298    /// The units checked, in order.
299    pub features: Vec<String>,
300    /// Every tick (and trailer-only task), in document order per unit.
301    pub ticks: Vec<TickReport>,
302    /// Tasks skipped by a person's decision (informational).
303    pub skipped: Vec<SkippedTask>,
304    /// Counts by outcome.
305    pub totals: BranchTotals,
306    /// The conclusion.
307    pub conclusion: Conclusion,
308    /// The trust used: the allowed-signers file and the revision it was
309    /// read at, the Sigstore identity and issuer.
310    pub trust: serde_json::Value,
311    /// Unsigned evidence was accepted (`--require-signed=false`).
312    pub accepted_unsigned: bool,
313    /// `regression_only` and `attested` fail too.
314    pub require_verified: bool,
315    /// Requirement → task → test → verdict, from the signed `trace[]`,
316    /// with a row (no task) for each requirement no tick cites.
317    pub trace: Vec<TraceRow>,
318    /// Evidenced (not stale) over all ticks; `None` without ticks.
319    pub share: Option<f64>,
320    /// Bug homes touched without a verdict commit (informational).
321    pub open_no_claim: Vec<String>,
322    /// Rules this verifier version does not evaluate yet.
323    pub not_evaluated: Vec<String>,
324    /// Predicate fields or revisions seen but unknown here (tolerated).
325    pub newer_fields: Vec<String>,
326}
327
328impl BranchReport {
329    /// The check run's title: `failure: 7 verified, 1 regression only, …`;
330    /// a neutral outcome says there was nothing to check.
331    #[must_use]
332    pub fn title(&self) -> String {
333        if self.conclusion == Conclusion::Neutral {
334            return "neutral: no ticks checked".to_owned();
335        }
336        let t = &self.totals;
337        let mut title = format!(
338            "{}: {} verified, {} regression only, {} attested, {} stale, {} unverifiable",
339            self.conclusion.word(),
340            t.verified,
341            t.regression_only,
342            t.attested,
343            t.stale,
344            t.unverifiable
345        );
346        if t.regressed > 0 {
347            title.push_str(&format!(", {} regressed", t.regressed));
348        }
349        title
350    }
351}
352
353/// Evidenced (not stale) over all ticks; `None` with none.
354#[must_use]
355pub fn share(ticks: &[TickReport]) -> Option<f64> {
356    if ticks.is_empty() {
357        return None;
358    }
359    let evidenced = ticks
360        .iter()
361        .filter(|t| t.outcome == TickOutcome::Evidenced)
362        .count();
363    #[allow(clippy::cast_precision_loss)] // counts of ticks
364    Some(evidenced as f64 / ticks.len() as f64)
365}
366
367/// The badge: a shields.io endpoint document, and the same as an SVG.
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
369pub struct Badge {
370    /// The endpoint schema's version (1).
371    #[serde(rename = "schemaVersion")]
372    pub schema_version: u32,
373    /// `evidence`.
374    pub label: String,
375    /// `80%`, or `none`.
376    pub message: String,
377    /// `brightgreen`, `yellow`, `red`, `lightgrey`.
378    pub color: String,
379}
380
381impl Badge {
382    /// The badge of a report: ≥ 95 % green, ≥ 75 % yellow, else red; never
383    /// green with an unverifiable tick; `none` without ticks.
384    #[must_use]
385    pub fn of(report: &BranchReport) -> Self {
386        let (message, color) = match report.share {
387            None => ("none".to_owned(), "lightgrey"),
388            Some(s) => {
389                let percent = (s * 100.0).floor();
390                let color = if percent >= 95.0 && report.totals.unverifiable == 0 {
391                    "brightgreen"
392                } else if percent >= 75.0 {
393                    "yellow"
394                } else {
395                    "red"
396                };
397                (format!("{percent:.0}%"), color)
398            }
399        };
400        Self {
401            schema_version: 1,
402            label: "evidence".to_owned(),
403            message,
404            color: color.to_owned(),
405        }
406    }
407
408    /// The badge as a flat SVG, its text the same as the endpoint's.
409    #[must_use]
410    pub fn svg(&self) -> String {
411        let fill = match self.color.as_str() {
412            "brightgreen" => "#4c1",
413            "yellow" => "#dfb317",
414            "red" => "#e05d44",
415            _ => "#9f9f9f",
416        };
417        // Roughly 7 px per character of 11 px Verdana, plus padding.
418        let width = |s: &str| u32::try_from(s.chars().count()).unwrap_or(8) * 7 + 10;
419        let (lw, mw) = (width(&self.label), width(&self.message));
420        let total = lw + mw;
421        format!(
422            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total}\" height=\"20\" role=\"img\" aria-label=\"{label}: {message}\">\
423<title>{label}: {message}</title>\
424<rect width=\"{lw}\" height=\"20\" fill=\"#555\"/>\
425<rect x=\"{lw}\" width=\"{mw}\" height=\"20\" fill=\"{fill}\"/>\
426<g fill=\"#fff\" text-anchor=\"middle\" font-family=\"Verdana,Geneva,DejaVu Sans,sans-serif\" font-size=\"11\">\
427<text x=\"{lx}\" y=\"14\">{label}</text><text x=\"{mx}\" y=\"14\">{message}</text></g></svg>\n",
428            label = self.label,
429            message = self.message,
430            lx = lw / 2,
431            mx = lw + mw / 2,
432        )
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    fn tick(task: &str, outcome: TickOutcome, verdict: Option<&str>) -> TickReport {
441        TickReport {
442            tick: Tick {
443                feature: "001-x".to_owned(),
444                task: task.to_owned(),
445                title: String::new(),
446                kind: TickKind::Tick,
447                anchor: Anchor {
448                    path: "specs/001-x/tasks.md".to_owned(),
449                    line: 1,
450                },
451                line_sha256: None,
452                claim_sha256: None,
453            },
454            commit: None,
455            outcome,
456            verdict: verdict.map(str::to_owned),
457            reason: (outcome == TickOutcome::Unverifiable)
458                .then_some(Unverifiable::TickedWithoutEvidence),
459            detail: None,
460            signer: None,
461            decider: None,
462            stale: Vec::new(),
463            stale_anchor: None,
464            newer_fields: Vec::new(),
465            verification: None,
466        }
467    }
468
469    fn report(ticks: Vec<TickReport>) -> BranchReport {
470        let conclusion = Conclusion::of(&ticks, false, &[]);
471        BranchReport {
472            head: "h".to_owned(),
473            base: None,
474            mode: Mode::All,
475            features: vec!["001-x".to_owned()],
476            totals: BranchTotals::of(&ticks, 0),
477            share: share(&ticks),
478            ticks,
479            skipped: Vec::new(),
480            conclusion,
481            trust: serde_json::Value::Null,
482            accepted_unsigned: false,
483            require_verified: false,
484            trace: Vec::new(),
485            open_no_claim: Vec::new(),
486            not_evaluated: Vec::new(),
487            newer_fields: Vec::new(),
488        }
489    }
490
491    #[test]
492    fn the_conclusion_is_failure_on_any_failing_tick_and_never_success_without_ticks() {
493        use TickOutcome::*;
494        let ok = tick("T1", Evidenced, Some("verified"));
495        let ro = tick("T2", Evidenced, Some("regression_only"));
496        assert_eq!(Conclusion::of(&[], false, &[]), Conclusion::Neutral);
497        assert_eq!(
498            Conclusion::of(&[ok.clone(), ro.clone()], false, &[]),
499            Conclusion::Success
500        );
501        assert_eq!(
502            Conclusion::of(&[ok.clone(), ro], true, &[]),
503            Conclusion::Failure
504        );
505        for bad in [Stale, Unverifiable, Regressed] {
506            assert_eq!(
507                Conclusion::of(&[ok.clone(), tick("T3", bad, None)], false, &[]),
508                Conclusion::Failure,
509                "{bad:?}"
510            );
511        }
512        // A claim this version cannot check is not proof.
513        assert_eq!(
514            Conclusion::of(&[], false, &[TRAILER_ONLY_TASKS.to_owned()]),
515            Conclusion::Failure
516        );
517        assert_eq!(
518            Conclusion::of(&[ok], false, &[EARLIER_TASK_REGRESSIONS.to_owned()]),
519            Conclusion::Success
520        );
521    }
522
523    #[test]
524    fn the_title_counts_in_the_bars_order() {
525        use TickOutcome::*;
526        let r = report(vec![
527            tick("T1", Evidenced, Some("verified")),
528            tick("T2", Evidenced, Some("attested")),
529            tick("T3", Unverifiable, None),
530        ]);
531        assert_eq!(
532            r.title(),
533            "failure: 1 verified, 0 regression only, 1 attested, 0 stale, 1 unverifiable"
534        );
535        assert_eq!(report(Vec::new()).title(), "neutral: no ticks checked");
536    }
537
538    #[test]
539    fn the_badge_reads_eight_of_ten_as_eighty_percent_and_never_green_with_an_unverifiable_tick() {
540        use TickOutcome::*;
541        let mut ticks: Vec<TickReport> = (0..8)
542            .map(|i| tick(&format!("T{i}"), Evidenced, Some("verified")))
543            .collect();
544        ticks.push(tick("T8", Stale, Some("verified")));
545        ticks.push(tick("T9", Unverifiable, None));
546        let b = Badge::of(&report(ticks));
547        assert_eq!((b.message.as_str(), b.color.as_str()), ("80%", "yellow"));
548        assert!(b.svg().contains(">80%<"));
549        let mut all: Vec<TickReport> = (0..40)
550            .map(|i| tick(&format!("T{i}"), Evidenced, Some("verified")))
551            .collect();
552        all.push(tick("T99", Unverifiable, None));
553        let b = Badge::of(&report(all));
554        assert_eq!((b.message.as_str(), b.color.as_str()), ("97%", "yellow"));
555        assert_eq!(Badge::of(&report(Vec::new())).message, "none");
556    }
557}