1use serde::Serialize;
10use serde_json::{Value, json};
11use speccoder_core::verify_branch::{
12 Anchor, BranchReport, Conclusion, EARLIER_TASK_REGRESSIONS, TRAILER_ONLY_TASKS, TickOutcome,
13 TickReport, Unverifiable,
14};
15use speccoder_core::workflow::{ANNOTATIONS_PER_KIND, data, property};
16
17use crate::evidence::banner;
18
19pub const NAME: &str = "speccoder / evidence";
21
22pub const TEXT_BUDGET: usize = 65_535;
24
25pub const ANNOTATIONS_PER_REQUEST: usize = 50;
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct Annotation {
31 pub path: String,
33 pub start_line: u32,
35 pub end_line: u32,
37 pub annotation_level: &'static str,
39 pub title: String,
41 pub message: String,
43}
44
45fn at(anchor: &Anchor, level: &'static str, title: String, message: String) -> Annotation {
46 Annotation {
47 path: anchor.path.clone(),
48 start_line: anchor.line.max(1),
49 end_line: anchor.line.max(1),
50 annotation_level: level,
51 title,
52 message,
53 }
54}
55
56fn fix(r: &TickReport, reason: Unverifiable) -> String {
58 let task = &r.tick.task;
59 match reason {
60 Unverifiable::TickedWithoutEvidence => format!(
61 "No commit carries this task's evidence. Re-run the task with speccoder, or `speccoder task accept {task}` (attested)."
62 ),
63 Unverifiable::Missing => "The task commit has no attestation note: push them (`git push origin refs/notes/speccoder`).".to_owned(),
64 Unverifiable::CommitUnreachable => "The commit with the evidence is not on this branch: a squash or rebase merge drops it; merge with a merge commit.".to_owned(),
65 Unverifiable::UntrustedSigner => "Signed by a key the maintainer's trust does not list: add the signer to the base branch's allowed signers, or re-sign.".to_owned(),
66 Unverifiable::Unsigned => "The evidence is not signed: `speccoder attest sign`, then push the notes.".to_owned(),
67 Unverifiable::ToolMissing => "The signature's tool is not installed on this runner; install it and re-run.".to_owned(),
68 Unverifiable::BadSignature | Unverifiable::Mismatch | Unverifiable::Malformed => {
69 "The evidence does not hold for this commit (edited or tampered). Re-run the task with speccoder.".to_owned()
70 }
71 }
72}
73
74#[must_use]
79pub fn annotations(report: &BranchReport) -> Vec<Annotation> {
80 let mut out = Vec::new();
81 for r in &report.ticks {
82 let task = &r.tick.task;
83 let detail = r
84 .detail
85 .as_deref()
86 .map(|d| format!(" ({d})"))
87 .unwrap_or_default();
88 match (r.outcome, r.verdict.as_deref()) {
89 (TickOutcome::Unverifiable, _) => {
90 let reason = r.reason.unwrap_or(Unverifiable::Malformed);
91 out.push(at(
92 &r.tick.anchor,
93 "failure",
94 format!("{task} unverifiable: {}", reason.word()),
95 format!("{}{detail}", fix(r, reason)),
96 ));
97 }
98 (TickOutcome::Regressed, _) => out.push(at(
99 &r.tick.anchor,
100 "failure",
101 format!("{task} regressed"),
102 "A later task's signed checkpoint marks this task regressed.".to_owned(),
103 )),
104 (TickOutcome::Stale, _) => out.push(at(
105 r.stale_anchor.as_ref().unwrap_or(&r.tick.anchor),
106 "warning",
107 format!(
108 "{task} stale: {} changed",
109 r.stale
110 .iter()
111 .map(|d| d.id.as_str())
112 .collect::<Vec<_>>()
113 .join(", ")
114 ),
115 format!(
116 "{task}'s evidence is for the earlier wording of this requirement. Re-prove it (speccoder converge) or revert the edit."
117 ),
118 )),
119 (TickOutcome::Evidenced, Some("regression_only")) => out.push(at(
120 &r.tick.anchor,
121 "warning",
122 format!("{task} regression only"),
123 banner("regression_only").1.to_owned(),
124 )),
125 (TickOutcome::Evidenced, Some("attested")) => out.push(at(
126 &r.tick.anchor,
127 "notice",
128 format!(
129 "{task} attested by {}",
130 r.decider.as_deref().unwrap_or("a person")
131 ),
132 banner("attested").1.to_owned(),
133 )),
134 (TickOutcome::Evidenced, _) => {}
135 }
136 }
137 out
138}
139
140fn sentence(report: &BranchReport) -> String {
142 let failing = report
143 .ticks
144 .iter()
145 .filter(|t| t.fails(report.require_verified))
146 .count();
147 match report.conclusion {
148 Conclusion::Success => "every tick carries evidence that verifies.".to_owned(),
149 Conclusion::Neutral => "neutral: no ticks checked — nothing here is proof.".to_owned(),
150 Conclusion::Failure if failing == 0 => {
151 "a claim in range cannot be checked by this verifier version.".to_owned()
152 }
153 Conclusion::Failure => format!("{failing} tick(s) without verifying evidence."),
154 }
155}
156
157#[must_use]
159pub fn bar(report: &BranchReport) -> String {
160 let t = &report.totals;
161 let mut bar = format!(
162 "**verified {} · regression only {} · attested {} · stale {} · unverifiable {}",
163 t.verified, t.regression_only, t.attested, t.stale, t.unverifiable
164 );
165 if t.regressed > 0 {
166 bar.push_str(&format!(" · regressed {}", t.regressed));
167 }
168 format!("{bar}** — {}", sentence(report))
169}
170
171fn trust_line(report: &BranchReport) -> String {
172 let ssh = &report.trust["ssh_allowed_signers"];
173 let ssh = match (
174 ssh["rev"].as_str(),
175 ssh["path"].as_str(),
176 ssh["file"].as_str(),
177 ) {
178 (Some(rev), Some(path), _) => format!("allowed signers `{path}` at `{rev}`"),
179 (_, _, Some(file)) => format!("allowed signers `{file}`"),
180 _ => "no SSH allowed signers".to_owned(),
181 };
182 let sigstore = report.trust["sigstore"]["identity"]
183 .as_str()
184 .map(|i| {
185 format!(
186 ", Sigstore identity `{i}` ({})",
187 report.trust["sigstore"]["issuer"].as_str().unwrap_or("?")
188 )
189 })
190 .unwrap_or_default();
191 format!("Trust: {ssh}{sigstore}; the verifier's, never the branch's.")
192}
193
194#[must_use]
197pub fn summary(report: &BranchReport, omitted_annotations: usize) -> String {
198 let mut out = format!("{}\n\n{}\n", bar(report), trust_line(report));
199 if report.accepted_unsigned {
200 out.push_str(
201 "\n> **Unsigned ticks were accepted** (`require-signed: false`): their statements could have been written by anyone who can push.\n",
202 );
203 }
204 if report.require_verified {
205 out.push_str("\nEvery tick must be `verified` (`require-verified: true`).\n");
206 }
207 let mut notes = Vec::new();
208 for n in &report.not_evaluated {
209 notes.push(match n.as_str() {
210 EARLIER_TASK_REGRESSIONS => {
211 "regressions of earlier tasks are not checked by this verifier version".to_owned()
212 }
213 TRAILER_ONLY_TASKS => "a trailer-only bug task (`Spec-Task: <unit>/bug:<slug>`) is in range; this verifier version cannot check it, so the branch fails".to_owned(),
214 other => format!("{other}: not checked by this verifier version"),
215 });
216 }
217 if !report.newer_fields.is_empty() {
218 notes.push(format!(
219 "fields from a newer predicate revision not shown: {}",
220 report.newer_fields.join(", ")
221 ));
222 }
223 if !report.open_no_claim.is_empty() {
224 notes.push(format!(
225 "open, no claim: {}",
226 report.open_no_claim.join(", ")
227 ));
228 }
229 if !report.skipped.is_empty() {
230 notes.push(format!(
231 "{} task(s) skipped by a person (not ticks)",
232 report.skipped.len()
233 ));
234 }
235 if omitted_annotations > 0 {
236 notes.push(format!(
237 "{omitted_annotations} annotation(s) not shown — every tick is in the table"
238 ));
239 }
240 for n in notes {
241 out.push_str(&format!("\n- {n}"));
242 }
243 if !out.ends_with('\n') {
244 out.push('\n');
245 }
246 cut(&out, TEXT_BUDGET, "the summary")
247}
248
249fn cell(s: &str) -> String {
251 s.replace('|', "\\|").replace('\n', " ")
252}
253
254fn verdict_words(r: &TickReport) -> String {
255 match r.outcome {
256 TickOutcome::Evidenced => banner(r.verdict.as_deref().unwrap_or_default())
257 .1
258 .to_owned(),
259 TickOutcome::Stale => "STALE — evidence for an earlier wording".to_owned(),
260 TickOutcome::Unverifiable => format!(
261 "UNVERIFIABLE — {}",
262 r.reason.map_or("no evidence", Unverifiable::word)
263 ),
264 TickOutcome::Regressed => "REGRESSED".to_owned(),
265 }
266}
267
268fn tables(report: &BranchReport) -> String {
270 let mut out = String::from(
271 "### Ticks\n\n| Unit | Task | Outcome | Commit | Signer |\n|---|---|---|---|---|\n",
272 );
273 for r in &report.ticks {
274 out.push_str(&format!(
275 "| {} | {} | {}{} | {} | {} |\n",
276 cell(&r.tick.feature),
277 cell(&r.tick.task),
278 cell(&verdict_words(r)),
279 r.detail
280 .as_deref()
281 .map(|d| format!(" — {}", cell(d)))
282 .unwrap_or_default(),
283 r.commit.as_deref().map_or_else(
284 || "—".to_owned(),
285 |c| format!("`{}`", &c[..c.len().min(12)])
286 ),
287 r.signer.as_ref().map_or_else(
288 || "—".to_owned(),
289 |s| cell(s.principal.as_deref().unwrap_or(&s.kind))
290 ),
291 ));
292 }
293 if report.ticks.is_empty() {
294 out.push_str("| — | — | no ticks | — | — |\n");
295 }
296 if !report.trace.is_empty() {
297 out.push_str("\n### Requirement trace\n\n| Requirement | Scenario | Test | Task | Verdict |\n|---|---|---|---|---|\n");
298 for t in &report.trace {
299 out.push_str(&format!(
300 "| {} | {} | {} | {} | {} |\n",
301 cell(&t.requirement),
302 cell(t.scenario.as_deref().unwrap_or("—")),
303 cell(t.test.as_deref().unwrap_or("—")),
304 cell(t.task.as_deref().unwrap_or("not cited")),
305 cell(&match (&t.verdict, t.changed) {
306 (Some(v), true) => format!("{v} (changed since)"),
307 (Some(v), false) => v.clone(),
308 (None, _) => "—".to_owned(),
309 }),
310 ));
311 }
312 }
313 out
314}
315
316#[must_use]
320pub fn text(report: &BranchReport, tours: &[String]) -> (String, usize) {
321 let tables = cut(&tables(report), TEXT_BUDGET - 200, "the tables");
322 let mut out = tables;
323 let mut shown = 0;
324 let budget = TEXT_BUDGET - 200;
325 if !tours.is_empty() {
326 out.push_str("\n### Evidence tours\n\n");
327 }
328 for tour in tours {
329 if out.chars().count() + tour.chars().count() + 1 > budget {
330 break;
331 }
332 out.push_str(tour);
333 out.push('\n');
334 shown += 1;
335 }
336 let omitted = tours.len() - shown;
337 if omitted > 0 {
338 out.push_str(&format!(
339 "\n{omitted} tour(s) not shown — run `speccoder-verify tour --feature <unit> --task <id>`\n"
340 ));
341 }
342 (out, omitted)
343}
344
345fn cut(text: &str, budget: usize, what: &str) -> String {
347 if text.chars().count() <= budget {
348 return text.to_owned();
349 }
350 let mut out = String::new();
351 let mut lines = text.lines();
352 let mut dropped = 0;
353 for line in lines.by_ref() {
354 if out.chars().count() + line.chars().count() + 120 > budget {
355 dropped += 1;
356 break;
357 }
358 out.push_str(line);
359 out.push('\n');
360 }
361 dropped += lines.count();
362 out.push_str(&format!(
363 "\n… {dropped} line(s) of {what} not shown (GitHub's size cap); every tick is in the outcome.\n"
364 ));
365 out
366}
367
368#[must_use]
372pub fn published(conclusion: Conclusion, allow_neutral: bool) -> &'static str {
373 match conclusion {
374 Conclusion::Neutral if allow_neutral => "neutral",
375 Conclusion::Neutral | Conclusion::Failure => "failure",
376 Conclusion::Success => "success",
377 }
378}
379
380#[must_use]
383pub fn request(report: &BranchReport, tours: &[String], name: &str, allow_neutral: bool) -> Value {
384 let all = annotations(report);
385 let summary = summary(report, 0);
386 let (text, _) = text(report, tours);
387 let title = report.title();
388 let mut batches = all.chunks(ANNOTATIONS_PER_REQUEST);
389 let first: Vec<&Annotation> = batches
390 .next()
391 .map(|b| b.iter().collect())
392 .unwrap_or_default();
393 let updates: Vec<Value> = batches
394 .map(|b| {
395 json!({
396 "output": { "title": title, "summary": summary, "annotations": b }
397 })
398 })
399 .collect();
400 json!({
401 "create": {
402 "name": name,
403 "head_sha": report.head,
404 "status": "completed",
405 "conclusion": published(report.conclusion, allow_neutral),
406 "output": {
407 "title": title,
408 "summary": summary,
409 "text": text,
410 "annotations": first,
411 }
412 },
413 "updates": updates,
414 })
415}
416
417#[must_use]
420pub fn workflow_commands(report: &BranchReport) -> Vec<String> {
421 let mut by_kind: [(&str, Vec<String>); 3] = [
422 ("error", Vec::new()),
423 ("warning", Vec::new()),
424 ("notice", Vec::new()),
425 ];
426 for a in annotations(report) {
427 let kind = match a.annotation_level {
428 "failure" => 0,
429 "warning" => 1,
430 _ => 2,
431 };
432 by_kind[kind].1.push(format!(
433 "::{} file={},line={},title={}::{}",
434 by_kind[kind].0,
435 property(&a.path),
436 a.start_line,
437 property(&a.title),
438 data(&a.message)
439 ));
440 }
441 let hidden: usize = by_kind
442 .iter()
443 .map(|(_, v)| v.len().saturating_sub(ANNOTATIONS_PER_KIND))
444 .sum();
445 let mut out: Vec<String> = by_kind
446 .into_iter()
447 .flat_map(|(_, v)| v.into_iter().take(ANNOTATIONS_PER_KIND))
448 .collect();
449 if hidden > 0 {
450 out.push(format!(
451 "::notice title={}::{}",
452 property(NAME),
453 data(&format!(
454 "{hidden} more annotation(s) not shown — every tick is in the job summary"
455 ))
456 ));
457 }
458 out
459}
460
461#[must_use]
464pub fn markdown(report: &BranchReport, tours: &[String]) -> String {
465 let (text, _) = text(report, tours);
466 format!(
467 "## {NAME}: {}\n\n{}\n{text}",
468 report.title(),
469 summary(report, 0)
470 )
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use speccoder_core::RequirementDrift;
477 use speccoder_core::verify_branch::{BranchTotals, Mode, Tick, TickKind, share};
478
479 fn tick(task: &str, line: u32, outcome: TickOutcome, verdict: Option<&str>) -> TickReport {
480 TickReport {
481 tick: Tick {
482 feature: "001-x".to_owned(),
483 task: task.to_owned(),
484 title: format!("{task} title"),
485 kind: TickKind::Tick,
486 anchor: Anchor {
487 path: "specs/001-x/tasks.md".to_owned(),
488 line,
489 },
490 line_sha256: None,
491 claim_sha256: None,
492 },
493 commit: Some("0123456789abcdef".to_owned()),
494 outcome,
495 verdict: verdict.map(str::to_owned),
496 reason: (outcome == TickOutcome::Unverifiable)
497 .then_some(Unverifiable::TickedWithoutEvidence),
498 detail: None,
499 signer: None,
500 decider: (verdict == Some("attested")).then(|| "Ada".to_owned()),
501 stale: if outcome == TickOutcome::Stale {
502 vec![RequirementDrift {
503 id: "FR-002".to_owned(),
504 then: Some("old".to_owned()),
505 now: Some("new".to_owned()),
506 }]
507 } else {
508 Vec::new()
509 },
510 stale_anchor: (outcome == TickOutcome::Stale).then(|| Anchor {
511 path: "specs/001-x/spec.md".to_owned(),
512 line: 9,
513 }),
514 newer_fields: Vec::new(),
515 verification: None,
516 }
517 }
518
519 fn report(ticks: Vec<TickReport>) -> BranchReport {
520 let conclusion = Conclusion::of(&ticks, false, &[]);
521 BranchReport {
522 head: "h".to_owned(),
523 base: None,
524 mode: Mode::All,
525 features: vec!["001-x".to_owned()],
526 totals: BranchTotals::of(&ticks, 0),
527 share: share(&ticks),
528 ticks,
529 skipped: Vec::new(),
530 conclusion,
531 trust: json!({ "ssh_allowed_signers": { "rev": "base", "path": ".github/speccoder/allowed_signers" } }),
532 accepted_unsigned: false,
533 require_verified: false,
534 trace: Vec::new(),
535 open_no_claim: Vec::new(),
536 not_evaluated: vec![EARLIER_TASK_REGRESSIONS.to_owned()],
537 newer_fields: vec!["provenance".to_owned()],
538 }
539 }
540
541 fn mixed() -> BranchReport {
542 use TickOutcome::*;
543 let mut r = report(vec![
544 tick("T1", 3, Evidenced, Some("verified")),
545 tick("T2", 4, Evidenced, Some("regression_only")),
546 tick("T3", 5, Evidenced, Some("attested")),
547 tick("T4", 6, Stale, Some("verified")),
548 tick("T5", 7, Unverifiable, None),
549 tick("T6", 8, Regressed, Some("verified")),
550 ]);
551 r.ticks[2].decider = Some("Ada".to_owned());
552 r
553 }
554
555 #[test]
556 fn one_annotation_per_tick_that_is_not_plainly_verified_at_its_level_and_line() {
557 let r = mixed();
558 let a = annotations(&r);
559 let got: Vec<(&str, &str, u32)> = a
560 .iter()
561 .map(|a| (a.annotation_level, a.path.as_str(), a.start_line))
562 .collect();
563 assert_eq!(
564 got,
565 [
566 ("warning", "specs/001-x/tasks.md", 4),
567 ("notice", "specs/001-x/tasks.md", 5),
568 ("warning", "specs/001-x/spec.md", 9),
569 ("failure", "specs/001-x/tasks.md", 7),
570 ("failure", "specs/001-x/tasks.md", 8),
571 ]
572 );
573 assert_eq!(a[1].title, "T3 attested by Ada");
574 assert_eq!(a[2].title, "T4 stale: FR-002 changed");
575 assert_eq!(a[3].title, "T5 unverifiable: ticked_without_evidence");
576 }
577
578 #[test]
579 fn the_summary_leads_with_the_bar_and_names_what_is_not_evaluated() {
580 let r = mixed();
581 let s = summary(&r, 0);
582 assert!(
583 s.starts_with("**verified 1 · regression only 1 · attested 1 · stale 1 · unverifiable 1 · regressed 1** — 3 tick(s) without verifying evidence."),
584 "{s}"
585 );
586 assert!(
587 s.contains("regressions of earlier tasks are not checked by this verifier version")
588 );
589 assert!(s.contains("fields from a newer predicate revision not shown: provenance"));
590 assert!(s.contains("`.github/speccoder/allowed_signers` at `base`"));
591 assert_eq!(
592 r.title(),
593 "failure: 1 verified, 1 regression only, 1 attested, 1 stale, 1 unverifiable, 1 regressed"
594 );
595 }
596
597 #[test]
598 fn sixty_annotations_go_fifty_in_the_create_and_ten_in_an_update() {
599 let ticks: Vec<TickReport> = (1..=60)
600 .map(|i| tick(&format!("T{i}"), i, TickOutcome::Unverifiable, None))
601 .collect();
602 let r = report(ticks);
603 let req = request(&r, &[], NAME, false);
604 assert_eq!(
605 req["create"]["output"]["annotations"]
606 .as_array()
607 .unwrap()
608 .len(),
609 50
610 );
611 assert_eq!(req["updates"].as_array().unwrap().len(), 1);
612 assert_eq!(
613 req["updates"][0]["output"]["annotations"]
614 .as_array()
615 .unwrap()
616 .len(),
617 10
618 );
619 assert_eq!(req["create"]["head_sha"], "h");
620 assert_eq!(req["create"]["conclusion"], "failure");
621 let lines = workflow_commands(&r);
623 assert_eq!(lines.len(), 11);
624 assert!(
625 lines[10].contains("50 more annotation(s) not shown"),
626 "{}",
627 lines[10]
628 );
629 assert!(lines[0].starts_with("::error file=specs/001-x/tasks.md,line=1,title=T1 unverifiable%3A ticked_without_evidence::"));
630 }
631
632 #[test]
633 fn neutral_is_published_as_failure_unless_allowed() {
634 let r = report(Vec::new());
635 assert_eq!(r.conclusion, Conclusion::Neutral);
636 assert_eq!(
637 request(&r, &[], NAME, false)["create"]["conclusion"],
638 "failure"
639 );
640 assert_eq!(
641 request(&r, &[], NAME, true)["create"]["conclusion"],
642 "neutral"
643 );
644 assert_eq!(
645 request(&r, &[], NAME, false)["create"]["output"]["title"],
646 "neutral: no ticks checked"
647 );
648 }
649
650 #[test]
651 fn summary_and_text_stay_within_the_cap_and_count_what_was_cut() {
652 let ticks: Vec<TickReport> = (1..=400)
653 .map(|i| tick(&format!("T{i}"), i, TickOutcome::Unverifiable, None))
654 .collect();
655 let r = report(ticks);
656 let tours: Vec<String> = (0..400)
657 .map(|i| format!("<details>{}</details>\n", "x".repeat(400 + i)))
658 .collect();
659 let (t, omitted) = text(&r, &tours);
660 assert!(t.chars().count() <= TEXT_BUDGET);
661 assert!(omitted > 0);
662 assert!(t.contains(&format!("{omitted} tour(s) not shown")));
663 let s = summary(&r, 350);
664 assert!(s.chars().count() <= TEXT_BUDGET);
665 assert!(s.contains("350 annotation(s) not shown"));
666 let long = "line\n".repeat(20_000);
667 let c = cut(&long, 1000, "x");
668 assert!(c.chars().count() <= 1000, "{}", c.chars().count());
669 assert!(c.contains("line(s) of x not shown"));
670 }
671}