1use 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
18const 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
52fn 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
61fn 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
74fn 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
155fn 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
197fn 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
245fn 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
281fn 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
333fn 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#[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 let tv: Option<TaskVerificationV0> = statements
416 .iter()
417 .find(|s| s["predicateType"] == statement::TASK_VERIFICATION)
418 .and_then(|s| serde_json::from_value(s["predicate"].clone()).ok());
419 let tv = tv.as_ref();
420 tour.steps.push(requirement(
421 repo,
422 prefix,
423 &r.tick.feature,
424 &r.tick.task,
425 &r.tick.title,
426 commit,
427 tv,
428 ));
429 if r.outcome == TickOutcome::Unverifiable {
430 tour.unverifiable = Some(match (&r.reason, &r.detail) {
431 (Some(reason), Some(d)) => format!("{} — {d}", reason.word()),
432 (Some(reason), None) => reason.word().to_owned(),
433 (None, _) => "the evidence does not verify".to_owned(),
434 });
435 return tour;
436 }
437 let verdict = r.verdict.as_deref();
438 tour.steps.push(frozen_tests(tv, verdict));
439 tour.steps.push(red(&statements, tv, verdict));
440 tour.steps.push(diff(repo, commit));
441 tour.steps.push(green(&statements, tv, verdict));
442 tour.steps.push(strength(tv, verdict));
443 if let Some(tv) = tv {
446 for name in tv.reserved_present() {
447 let value = match name {
448 "bug" => tv.bug.as_ref(),
449 "context" => tv.context.as_ref(),
450 _ => None,
451 };
452 if let (Some(v), Some(last)) = (value, tour.steps.last_mut()) {
453 last.body_markdown.push_str(&format!(
454 "\nsigned `{name}` (shown as recorded): `{}`",
455 v.to_string().chars().take(300).collect::<String>()
456 ));
457 }
458 }
459 }
460 tour
461}
462
463#[must_use]
465pub fn words(r: &TickReport) -> String {
466 match r.outcome {
467 TickOutcome::Evidenced => banner(r.verdict.as_deref().unwrap_or_default())
468 .1
469 .to_owned(),
470 TickOutcome::Stale => "STALE — evidence for an earlier wording".to_owned(),
471 TickOutcome::Unverifiable => "UNVERIFIABLE".to_owned(),
472 TickOutcome::Regressed => "REGRESSED".to_owned(),
473 }
474}
475
476#[must_use]
478pub fn markdown_all(checked: &crate::attest::branch::Checked) -> Vec<String> {
479 checked
480 .report
481 .ticks
482 .iter()
483 .map(|r| of(&checked.repo, &checked.prefix, r).markdown(&words(r)))
484 .collect()
485}
486
487#[must_use]
491pub fn command(
492 start: &Path,
493 feature: &str,
494 task: &str,
495 opts: &crate::attest::branch::Options,
496 command: &str,
497) -> CommandResult {
498 let checked = match crate::attest::branch::locate(start, opts, feature, task) {
499 Ok(c) => c,
500 Err(r) => return CommandResult::new(command, r.status, &r.message),
501 };
502 let Some(r) = checked.report.ticks.first() else {
503 return CommandResult::new(
504 command,
505 CommandStatus::NotFound,
506 &format!("{feature} has no ticked task {task} at {}", opts.head),
507 );
508 };
509 let tour = of(&checked.repo, &checked.prefix, r);
510 CommandResult::new(command, CommandStatus::Ok, &format!("{task}: {}", words(r)))
511 .with_data(CommandData::EvidenceTour(Box::new(tour)))
512}