Skip to main content

speccoder_runtime/
tour.rs

1//! The evidence tour of one task, built from the signed statements on its
2//! commit and the commit itself — nothing else, so it never says more than
3//! `attest verify` can check. Six steps in a fixed order; a step without
4//! evidence says so in the verdict's own words instead of being left out.
5
6use std::path::Path;
7
8use serde_json::{Value, json};
9use speccoder_core::predicate::TaskVerificationV0;
10use speccoder_core::tour::{EvidenceTour, Source, StepKind, TourStep};
11use speccoder_core::verify_branch::{TickOutcome, TickReport, Unverifiable};
12use speccoder_core::{CommandData, CommandResult, CommandStatus};
13
14use crate::attest::statement;
15use crate::evidence::banner;
16use crate::git::Git;
17
18/// The most of a requirement passage a tour quotes.
19const PASSAGE_MAX: usize = 1500;
20
21fn step(kind: StepKind, source: Source, body: String, facts: Value) -> TourStep {
22    TourStep {
23        kind,
24        source,
25        title: kind.title().to_owned(),
26        body_markdown: body,
27        facts,
28    }
29}
30
31fn phase<'a>(statements: &'a [Value], name: &str) -> Option<&'a Value> {
32    statements.iter().find_map(|s| {
33        let p = &s["predicate"];
34        (s["predicateType"] == statement::TEST_RESULT
35            && p["configuration"][0]["name"] == format!("speccoder:phase/{name}"))
36        .then_some(p)
37    })
38}
39
40fn names(v: &Value) -> Vec<String> {
41    v.as_array()
42        .into_iter()
43        .flatten()
44        .filter_map(|x| x.as_str().map(str::to_owned))
45        .collect()
46}
47
48fn short(commit: &str) -> &str {
49    &commit[..commit.len().min(7)]
50}
51
52/// Why a step has nothing, in the verdict's words.
53fn nothing(verdict: Option<&str>, what: &str) -> String {
54    match verdict {
55        Some("attested") => format!("{what} — {}", banner("attested").1),
56        Some("regression_only") => format!("{what} — {}", banner("regression_only").1),
57        _ => what.to_owned(),
58    }
59}
60
61/// `> ` before every line, at most [`PASSAGE_MAX`] characters.
62fn quote(text: &str) -> String {
63    let mut out = String::new();
64    for line in text.trim().lines() {
65        if out.chars().count() > PASSAGE_MAX {
66            out.push_str("> …\n");
67            break;
68        }
69        out.push_str(&format!("> {line}\n"));
70    }
71    out
72}
73
74/// Step 1: the requirement as the commit's tree has it, through the
75/// format adapter — the task's story and the requirements its title
76/// cites; `signed` when the story hashes to the signed one.
77fn requirement(
78    repo: &Path,
79    prefix: &str,
80    feature: &str,
81    task: &str,
82    title: &str,
83    commit: &str,
84    tv: Option<&TaskVerificationV0>,
85) -> TourStep {
86    let read = crate::attest::tree::intent_tops(repo, commit)
87        .and_then(|tops| crate::attest::tree::materialize(repo, commit, Some(&tops)))
88        .ok()
89        .and_then(|dir| {
90            let at = dir.path().join(prefix);
91            let format = crate::format::at(&at);
92            let snap = format.inspect(&at, Some(feature))?;
93            let story = format
94                .bundle(&snap, task)
95                .ok()
96                .map(|b| b.story)
97                .unwrap_or_default();
98            let unit = snap.feature.as_ref().map(|f| f.feature.dir.clone())?;
99            let cited: Vec<(String, String)> = format
100                .requirements_file()
101                .and_then(|f| {
102                    std::fs::read_to_string(Path::new(&snap.root).join(&unit).join(f)).ok()
103                })
104                .map(|text| {
105                    format
106                        .requirements_in(&text)
107                        .into_iter()
108                        .filter(|(id, _)| crate::attest::cites(title, id))
109                        .collect()
110                })
111                .unwrap_or_default();
112            Some((story, cited))
113        });
114    let Some((story, cited)) = read.filter(|(s, c)| !s.trim().is_empty() || !c.is_empty()) else {
115        return step(
116            StepKind::Requirement,
117            Source::NotRecorded,
118            format!("no requirement passage found at `{}`", short(commit)),
119            Value::Null,
120        );
121    };
122    let hash = crate::integrity::sha256_hex(story.as_bytes());
123    let section = tv.and_then(|tv| tv.spec.as_ref());
124    let signed = !story.trim().is_empty() && section.is_some_and(|s| s.section_hash == hash);
125    let ids: Vec<&str> = cited.iter().map(|(id, _)| id.as_str()).collect();
126    let name = match (section.and_then(|s| s.section.clone()), ids.is_empty()) {
127        (Some(s), true) => s,
128        (Some(s), false) => format!("{s}: {}", ids.join(", ")),
129        (None, false) => ids.join(", "),
130        (None, true) => "the task's requirement".to_owned(),
131    };
132    let mut passage = String::new();
133    for (_, line) in &cited {
134        passage.push_str(line.trim());
135        passage.push('\n');
136    }
137    if !story.trim().is_empty() {
138        if !passage.is_empty() {
139            passage.push('\n');
140        }
141        passage.push_str(&story);
142    }
143    step(
144        StepKind::Requirement,
145        if signed {
146            Source::Signed
147        } else {
148            Source::Committed
149        },
150        format!("{name} (at `{}`)\n{}", short(commit), quote(&passage)),
151        json!({ "section": section.and_then(|s| s.section.clone()), "cited": ids, "story_sha256": hash, "commit": commit }),
152    )
153}
154
155/// Step 2: the acceptance tests the signed trace names, and where they
156/// were decided (the predicate's `acceptance.source`, else not recorded).
157fn frozen_tests(tv: Option<&TaskVerificationV0>, verdict: Option<&str>) -> TourStep {
158    let tests: Vec<String> = tv
159        .and_then(|tv| tv.trace.as_ref())
160        .and_then(Value::as_array)
161        .into_iter()
162        .flatten()
163        .filter_map(|r| r["test"].as_str().map(str::to_owned))
164        .collect::<std::collections::BTreeSet<_>>()
165        .into_iter()
166        .collect();
167    let source = tv
168        .and_then(|tv| tv.acceptance.as_ref())
169        .and_then(|a| a["source"].as_str())
170        .map_or_else(
171            || "where decided: not recorded".to_owned(),
172            |s| format!("decided: {s}"),
173        );
174    if tests.is_empty() {
175        return step(
176            StepKind::FrozenTests,
177            Source::NotRecorded,
178            nothing(verdict, "no task-specific test"),
179            json!({ "tests": [], "acceptance": tv.and_then(|tv| tv.acceptance.clone()) }),
180        );
181    }
182    step(
183        StepKind::FrozenTests,
184        Source::Signed,
185        format!(
186            "{} ({source})",
187            tests
188                .iter()
189                .map(|t| format!("`{t}`"))
190                .collect::<Vec<_>>()
191                .join(", ")
192        ),
193        json!({ "tests": tests, "acceptance": tv.and_then(|tv| tv.acceptance.clone()) }),
194    )
195}
196
197/// Step 3: the tests failing first, how, and the signed excerpt.
198fn red(statements: &[Value], tv: Option<&TaskVerificationV0>, verdict: Option<&str>) -> TourStep {
199    let Some(red) = phase(statements, "red") else {
200        return step(
201            StepKind::Red,
202            Source::NotRecorded,
203            nothing(verdict, "no red phase"),
204            Value::Null,
205        );
206    };
207    let failed = names(&red["failedTests"]);
208    let excerpt = tv.and_then(|tv| tv.red.as_ref());
209    let kind = excerpt.map_or("kind not recorded", |r| match r.kind {
210        speccoder_core::predicate::RedKind::Assertion => "assertion",
211        speccoder_core::predicate::RedKind::MissingSymbol => "missing symbol",
212    });
213    let block = match excerpt {
214        Some(r) if r.withheld => {
215            "output excerpt withheld (it was not clean: a local path or a credential)".to_owned()
216        }
217        Some(r) => format!(
218            "```text\n{}\n```",
219            r.excerpt
220                .as_deref()
221                .unwrap_or_default()
222                .replace("```", "'''")
223        ),
224        None => "no output excerpt recorded".to_owned(),
225    };
226    step(
227        StepKind::Red,
228        Source::Signed,
229        format!(
230            "failed: {} ({kind})\n{block}",
231            if failed.is_empty() {
232                "—".to_owned()
233            } else {
234                failed
235                    .iter()
236                    .map(|t| format!("`{t}`"))
237                    .collect::<Vec<_>>()
238                    .join(", ")
239            }
240        ),
241        json!({ "failed": failed, "red": excerpt }),
242    )
243}
244
245/// Step 4: the commit's diff, by its stat (`committed`: git objects).
246fn diff(repo: &Path, commit: &str) -> TourStep {
247    let stat = Git::at(repo)
248        .run(&["show", "--stat=100", "--format=", commit])
249        .map(|b| String::from_utf8_lossy(&b).trim().to_owned())
250        .unwrap_or_default();
251    let numstat = Git::at(repo)
252        .run(&["show", "--numstat", "--format=", commit])
253        .map(|b| String::from_utf8_lossy(&b).into_owned())
254        .unwrap_or_default();
255    let (mut files, mut added, mut removed) = (0usize, 0u64, 0u64);
256    for line in numstat.lines() {
257        let mut f = line.split('\t');
258        if let (Some(a), Some(r), Some(_)) = (f.next(), f.next(), f.next()) {
259            files += 1;
260            added += a.parse::<u64>().unwrap_or(0);
261            removed += r.parse::<u64>().unwrap_or(0);
262        }
263    }
264    let lines: Vec<&str> = stat.lines().collect();
265    let shown = lines.len().min(30);
266    let mut block = lines[..shown].join("\n");
267    if lines.len() > shown {
268        block.push_str(&format!("\n… {} more", lines.len() - shown));
269    }
270    step(
271        StepKind::Diff,
272        Source::Committed,
273        format!(
274            "`{}`: {files} file(s), +{added} −{removed}\n```text\n{block}\n```",
275            short(commit)
276        ),
277        json!({ "commit": commit, "files": files, "added": added, "removed": removed }),
278    )
279}
280
281/// Step 5: what passed after — acceptance, held-out, the checks against
282/// their baseline — and signed dependencies, shown, never failing.
283fn green(statements: &[Value], tv: Option<&TaskVerificationV0>, verdict: Option<&str>) -> TourStep {
284    let Some(green) = phase(statements, "green") else {
285        return step(
286            StepKind::Green,
287            Source::NotRecorded,
288            nothing(verdict, "no green phase"),
289            Value::Null,
290        );
291    };
292    let passed = names(&green["passedTests"]).len();
293    let failed = names(&green["failedTests"]).len();
294    let held_out = phase(statements, "held_out").map_or_else(
295        || "held-out: not run".to_owned(),
296        |h| {
297            if h["result"] == "PASSED" {
298                "held-out passed".to_owned()
299            } else {
300                "held-out failed".to_owned()
301            }
302        },
303    );
304    let warned = phase(statements, "baseline").map(|b| names(&b["warnedTests"]).len());
305    let checks = match warned {
306        Some(0) => "checks green, no regression (baseline warnings: 0)".to_owned(),
307        Some(n) => format!("checks green, no regression (baseline warnings: {n})"),
308        None => "no baseline recorded".to_owned(),
309    };
310    let mut body = format!("{passed} passed, {failed} failed; {held_out}; {checks}");
311    let deps = tv.and_then(|tv| tv.dependencies.clone());
312    if let Some(d) = deps.as_ref().and_then(Value::as_array) {
313        body.push_str(&format!(
314            "\ndependencies added (signed): {}",
315            d.iter()
316                .map(|x| format!(
317                    "`{}@{}`",
318                    x["name"].as_str().unwrap_or("?"),
319                    x["version"].as_str().unwrap_or("?")
320                ))
321                .collect::<Vec<_>>()
322                .join(", ")
323        ));
324    }
325    step(
326        StepKind::Green,
327        Source::Signed,
328        body,
329        json!({ "passed": passed, "failed": failed, "held_out": phase(statements, "held_out").map(|h| &h["result"]), "baseline_warnings": warned, "dependencies": deps }),
330    )
331}
332
333/// Step 6: the acceptance set's strength and mutation results.
334fn strength(tv: Option<&TaskVerificationV0>, verdict: Option<&str>) -> TourStep {
335    let st = tv
336        .and_then(|tv| tv.strength.as_ref())
337        .filter(|v| !v.is_null());
338    let mutation = tv
339        .and_then(|tv| tv.mutation.as_ref())
340        .filter(|v| !v.is_null());
341    if st.is_none() && mutation.is_none() {
342        return step(
343            StepKind::Strength,
344            Source::NotRecorded,
345            nothing(verdict, "not measured"),
346            Value::Null,
347        );
348    }
349    let mut parts = Vec::new();
350    if let Some(st) = st {
351        let share = |v: &Value| v["share"].as_f64().map(|x| format!("{x:.2}"));
352        if let Some(v) = share(&st["visible"]) {
353            parts.push(format!("visible {v}"));
354        }
355        if let Some(c) = share(&st["combined"]) {
356            parts.push(format!("combined {c}"));
357        }
358        let near = st["near_misses"].as_array().map_or(0, Vec::len);
359        parts.push(format!("{near} near miss(es) tried"));
360        let rejected = st["permitted"]
361            .as_array()
362            .into_iter()
363            .flatten()
364            .filter(|p| p["outcome"] == "rejected")
365            .count();
366        if rejected > 0 {
367            parts.push(format!(
368                "{rejected} permitted alternative(s) rejected — over-strict"
369            ));
370        }
371    }
372    if let Some(m) = mutation {
373        parts.push(format!(
374            "{} of {} mutants caught",
375            m["caught"].as_u64().unwrap_or(0),
376            m["caught"].as_u64().unwrap_or(0) + m["missed"].as_u64().unwrap_or(0)
377        ));
378    }
379    step(
380        StepKind::Strength,
381        Source::Signed,
382        parts.join(", "),
383        json!({ "strength": st, "mutation": mutation }),
384    )
385}
386
387/// The tour of one checked tick. `prefix` is the project's path in the
388/// repository.
389#[must_use]
390pub fn of(repo: &Path, prefix: &str, r: &TickReport) -> EvidenceTour {
391    let mut tour = EvidenceTour {
392        feature: r.tick.feature.clone(),
393        task: r.tick.task.clone(),
394        title: r.tick.title.clone(),
395        verdict: r.verdict.clone(),
396        commit: r.commit.clone(),
397        steps: Vec::new(),
398        unverifiable: None,
399    };
400    let Some(commit) = r.commit.as_deref() else {
401        tour.steps.push(step(
402            StepKind::Requirement,
403            Source::NotRecorded,
404            "no commit carries this task's evidence".to_owned(),
405            Value::Null,
406        ));
407        tour.unverifiable = Some(
408            r.detail
409                .clone()
410                .unwrap_or_else(|| Unverifiable::TickedWithoutEvidence.word().to_owned()),
411        );
412        return tour;
413    };
414    let statements = crate::evidence::statements(repo, commit);
415    // Whether the note's envelopes carry signatures at all, trusted or not.
416    let signed = crate::attest::notes::read(repo, commit).is_some_and(|note| {
417        crate::attest::dsse::decode(&note)
418            .0
419            .iter()
420            .any(|e| !e.signatures.is_empty())
421    });
422    let tv: Option<TaskVerificationV0> = statements
423        .iter()
424        .find(|s| s["predicateType"] == statement::TASK_VERIFICATION)
425        .and_then(|s| serde_json::from_value(s["predicate"].clone()).ok());
426    let tv = tv.as_ref();
427    tour.steps.push(requirement(
428        repo,
429        prefix,
430        &r.tick.feature,
431        &r.tick.task,
432        &r.tick.title,
433        commit,
434        tv,
435    ));
436    if r.outcome == TickOutcome::Unverifiable {
437        tour.unverifiable = Some(match (&r.reason, &r.detail) {
438            (Some(reason), Some(d)) => format!("{} — {d}", reason.word()),
439            (Some(reason), None) => reason.word().to_owned(),
440            (None, _) => "the evidence does not verify".to_owned(),
441        });
442        return tour;
443    }
444    let verdict = r.verdict.as_deref();
445    tour.steps.push(frozen_tests(tv, verdict));
446    tour.steps.push(red(&statements, tv, verdict));
447    tour.steps.push(diff(repo, commit));
448    tour.steps.push(green(&statements, tv, verdict));
449    tour.steps.push(strength(tv, verdict));
450    // Reserved fields another spec owns: shown as the signed facts they
451    // are, never interpreted here.
452    if let Some(tv) = tv {
453        for name in tv.reserved_present() {
454            let value = match name {
455                "bug" => tv.bug.as_ref(),
456                "context" => tv.context.as_ref(),
457                _ => None,
458            };
459            if let (Some(v), Some(last)) = (value, tour.steps.last_mut()) {
460                last.body_markdown.push_str(&format!(
461                    "\n{} `{name}` (shown as recorded): `{}`",
462                    if signed { "signed" } else { "recorded" },
463                    v.to_string().chars().take(300).collect::<String>()
464                ));
465            }
466        }
467    }
468    // A statement nobody signed says it, but is not signed: said so.
469    if !signed {
470        for s in &mut tour.steps {
471            if s.source == Source::Signed {
472                s.source = Source::Recorded;
473            }
474        }
475    }
476    tour
477}
478
479/// The words a tour's summary line ends with.
480#[must_use]
481pub fn words(r: &TickReport) -> String {
482    match r.outcome {
483        TickOutcome::Evidenced => banner(r.verdict.as_deref().unwrap_or_default())
484            .1
485            .to_owned(),
486        TickOutcome::Stale => "STALE — evidence for an earlier wording".to_owned(),
487        TickOutcome::Unverifiable => "UNVERIFIABLE".to_owned(),
488        TickOutcome::Regressed => "REGRESSED".to_owned(),
489    }
490}
491
492/// Every tick's tour, as collapsed Markdown, in the report's order.
493#[must_use]
494pub fn markdown_all(checked: &crate::attest::branch::Checked) -> Vec<String> {
495    checked
496        .report
497        .ticks
498        .iter()
499        .map(|r| of(&checked.repo, &checked.prefix, r).markdown(&words(r)))
500        .collect()
501}
502
503/// `speccoder attest tour` and `/api/tour`: the tour of `feature`'s
504/// `task` at `head`, with the check's trust when given (the outcome then
505/// decides how far the tour goes), else from the statements as recorded.
506#[must_use]
507pub fn command(
508    start: &Path,
509    feature: &str,
510    task: &str,
511    opts: &crate::attest::branch::Options,
512    command: &str,
513) -> CommandResult {
514    let checked = match crate::attest::branch::locate(start, opts, feature, task) {
515        Ok(c) => c,
516        Err(r) => return CommandResult::new(command, r.status, &r.message),
517    };
518    let Some(r) = checked.report.ticks.first() else {
519        return CommandResult::new(
520            command,
521            CommandStatus::NotFound,
522            &format!("{feature} has no ticked task {task} at {}", opts.head),
523        );
524    };
525    let tour = of(&checked.repo, &checked.prefix, r);
526    CommandResult::new(command, CommandStatus::Ok, &format!("{task}: {}", words(r)))
527        .with_data(CommandData::EvidenceTour(Box::new(tour)))
528}