1use serde_json::Value;
8use speccoder_core::{RunReport, TaskStatus};
9
10pub use speccoder_core::evidence::Row;
11
12#[must_use]
14pub fn banner(state: &str) -> (&'static str, &'static str) {
15 match state {
16 "verified" => ("verified", "VERIFIED"),
17 "regression_only" => (
18 "regression_only",
19 "REGRESSION ONLY — nothing task-specific was shown",
20 ),
21 "attested" => ("attested", "ATTESTED — a person's word, not evidence"),
22 "ticked_without_evidence" => (
23 "ticked_without_evidence",
24 "TICKED WITHOUT SPECCODER EVIDENCE",
25 ),
26 "evidence_without_tick" => (
27 "evidence_without_tick",
28 "COMMITTED — its local tick is missing",
29 ),
30 "skipped" => ("skipped", "SKIPPED"),
31 "ready" => ("ready", "READY"),
32 _ => ("blocked", "BLOCKED"),
33 }
34}
35
36#[must_use]
38pub fn statements(repo: &std::path::Path, commit: &str) -> Vec<Value> {
39 let Some(note) = crate::attest::notes::read(repo, commit) else {
40 return Vec::new();
41 };
42 crate::attest::dsse::decode(¬e)
43 .0
44 .iter()
45 .filter_map(crate::attest::dsse::Envelope::statement)
46 .collect()
47}
48
49fn phase<'a>(statements: &'a [Value], name: &str) -> Option<&'a Value> {
50 statements.iter().find_map(|s| {
51 let p = &s["predicate"];
52 (p["configuration"][0]["name"] == format!("speccoder:phase/{name}")).then_some(p)
53 })
54}
55
56fn names(v: &Value) -> usize {
57 v.as_array().map_or(0, Vec::len)
58}
59
60#[must_use]
63pub fn rows(task: &TaskStatus, statements: &[Value], run: Option<&RunReport>) -> Vec<Row> {
64 let mut out = Vec::new();
65 let row = |label: &str, mark: &str, text: String| Row {
66 label: label.to_owned(),
67 mark: mark.to_owned(),
68 text,
69 items: Vec::new(),
70 };
71 let tv = statements
72 .iter()
73 .find(|s| s["predicateType"] == "https://speccoder.dev/task-verification/v0")
74 .map(|s| &s["predicate"]);
75 let why = tv
77 .and_then(|tv| {
78 serde_json::from_value::<speccoder_core::regression::RegressionReason>(
79 tv["regression"].clone(),
80 )
81 .ok()
82 })
83 .map(|r| speccoder_core::regression::wording(&r))
84 .or_else(|| task.regression.as_ref().map(|r| r.text.clone()))
85 .filter(|_| task.state == "regression_only");
86 match (phase(statements, "red"), phase(statements, "green")) {
88 (Some(red), Some(green)) => out.push(row(
89 "Acceptance",
90 "verified",
91 format!(
92 "red → green: {} failing before, {} passing after (spec-derived, frozen)",
93 names(&red["failedTests"]),
94 names(&green["passedTests"])
95 ),
96 )),
97 (None, Some(green)) => out.push(row(
98 "Acceptance",
99 "regression_only",
100 why.clone().map_or_else(
101 || {
102 format!(
103 "none red first — the checks alone ({} passing)",
104 names(&green["passedTests"])
105 )
106 },
107 |w| format!("none red first — {w}"),
108 ),
109 )),
110 _ => out.push(match why.clone() {
111 Some(w) => row(
112 "Acceptance",
113 "regression_only",
114 format!("none red first — {w}"),
115 ),
116 None => row("Acceptance", "ready", "not run".to_owned()),
117 }),
118 }
119 let collection = tv
121 .and_then(|tv| {
122 serde_json::from_value::<speccoder_core::collection::Collection>(
123 tv["collection"].clone(),
124 )
125 .ok()
126 })
127 .or_else(|| run.and_then(|r| r.collection.clone()));
128 let recorded = tv.is_some_and(|tv| tv["revision"].as_u64().is_some_and(|r| r >= 3));
129 if let Some(r) = collection_row(collection.as_ref(), recorded || run.is_some()) {
130 out.push(r);
131 }
132 out.push(match phase(statements, "held_out") {
133 Some(h) if h["result"] == "PASSED" => row(
134 "Held-out",
135 "verified",
136 format!(
137 "{} passed (judge only, never shown to the implementer)",
138 names(&h["passedTests"])
139 ),
140 ),
141 Some(h) => row(
142 "Held-out",
143 "failed",
144 format!("{} failed", names(&h["failedTests"])),
145 ),
146 None => row("Held-out", "ready", "not run".to_owned()),
147 });
148 out.push(match phase(statements, "baseline") {
149 Some(b) => {
150 let warned = names(&b["warnedTests"]);
151 row(
152 "Checks",
153 if warned == 0 {
154 "verified"
155 } else {
156 "regression_only"
157 },
158 if warned == 0 {
159 "baseline green, nothing regressed".to_owned()
160 } else {
161 format!(
162 "{warned} failing before the attempt (not blamed on it), nothing regressed"
163 )
164 },
165 )
166 }
167 None => row("Checks", "ready", "no record".to_owned()),
168 });
169 if let Some(tv) = tv {
170 let findings = names(&tv["integrity"]["findings"]);
171 out.push(row(
172 "Diff audit",
173 if findings == 0 { "verified" } else { "failed" },
174 if findings == 0 {
175 "no tests weakened, no skips, no config changes".to_owned()
176 } else {
177 format!("{findings} finding(s)")
178 },
179 ));
180 let monitor = tv["monitor"]["state"]
181 .as_str()
182 .or_else(|| tv["monitor"].as_str())
183 .unwrap_or("off");
184 out.push(row(
185 "Gaming monitor",
186 match monitor {
187 "clean" => "verified",
188 "suspected" => "needs_approval",
189 _ => "ready",
190 },
191 monitor.to_owned(),
192 ));
193 let mutation = &tv["mutation"];
194 out.push(if mutation.is_null() {
195 row("Mutation", "ready", "not run".to_owned())
196 } else {
197 let caught = mutation["caught"].as_u64().unwrap_or(0);
198 let missed = mutation["missed"].as_u64().unwrap_or(0);
199 row(
200 "Mutation",
201 if missed == 0 {
202 "verified"
203 } else {
204 "regression_only"
205 },
206 format!("{caught} caught, {missed} survived"),
207 )
208 });
209 let decisions: Vec<String> = tv["decisions"]
210 .as_array()
211 .into_iter()
212 .flatten()
213 .map(|d| {
214 format!(
215 "{} {} — {}{}",
216 d["verb"].as_str().unwrap_or("decided"),
217 d["grant"]
218 .as_str()
219 .or_else(|| d["tool"].as_str())
220 .unwrap_or_default(),
221 d["by"].as_str().unwrap_or("?"),
222 d["via"]
223 .as_str()
224 .map(|v| format!(" via {v}"))
225 .unwrap_or_default()
226 )
227 })
228 .collect();
229 out.push(row(
230 "Approvals",
231 if decisions.is_empty() {
232 "ready"
233 } else {
234 "verified"
235 },
236 if decisions.is_empty() {
237 "none needed".to_owned()
238 } else {
239 decisions.join("; ")
240 },
241 ));
242 let contained: Vec<String> = tv["contained"]
244 .as_array()
245 .into_iter()
246 .flatten()
247 .map(|c| {
248 let argv: Vec<&str> = c["argv"]
249 .as_array()
250 .into_iter()
251 .flatten()
252 .filter_map(Value::as_str)
253 .collect();
254 format!("`{}`", argv.join(" "))
255 })
256 .collect();
257 if !contained.is_empty() {
258 out.push(Row {
259 items: contained
260 .iter()
261 .map(|c| c.trim_matches('`').to_owned())
262 .collect(),
263 ..row(
264 "Ran without asking",
265 "ready",
266 format!(
267 "{} command{} the sandbox confined: {}",
268 contained.len(),
269 if contained.len() == 1 { "" } else { "s" },
270 contained.join("; ")
271 ),
272 )
273 });
274 }
275 let instructions: Vec<&str> = tv["instructions"]
276 .as_array()
277 .into_iter()
278 .flatten()
279 .filter_map(|i| i["path"].as_str())
280 .collect();
281 out.push(row(
282 "Instructions",
283 "ready",
284 if instructions.is_empty() {
285 "none".to_owned()
286 } else {
287 instructions.join(", ")
288 },
289 ));
290 if let Some(by) = tv["decider"]["by"].as_str() {
291 out.push(row(
292 "Decided by",
293 "attested",
294 format!(
295 "{by} — {}",
296 tv["decider"]["reason"].as_str().unwrap_or_default()
297 ),
298 ));
299 }
300 }
301 if let Some(st) = tv
303 .and_then(|tv| tv.get("strength"))
304 .filter(|v| !v.is_null())
305 {
306 let share = st["combined"]["share"].as_f64();
307 let rejected = st["permitted"]
308 .as_array()
309 .into_iter()
310 .flatten()
311 .any(|p| p["outcome"] == "rejected");
312 out.push(row(
313 "Strength",
314 match share {
315 _ if rejected => "needs_approval",
316 Some(x) if x >= 0.999 => "verified",
317 Some(_) => "regression_only",
318 None => "ready",
319 },
320 task.strength.clone().unwrap_or_else(|| {
321 share.map_or_else(|| "not measured".to_owned(), |x| format!("{x:.2}"))
322 }),
323 ));
324 } else if let Some(st) = &task.strength {
325 out.push(row("Strength", "ready", st.clone()));
326 }
327 out.push(match task.attestation.as_deref() {
328 Some(a) if a.starts_with("signed") => row("Attestation", "verified", a.to_owned()),
329 Some(a) => row("Attestation", "regression_only", a.to_owned()),
330 None => row(
331 "Attestation",
332 "ready",
333 "none (not ticked by speccoder)".to_owned(),
334 ),
335 });
336 if let Some(a) = &task.last_attempt {
337 out.push(row(
338 "Last attempt",
339 a.outcome.as_str(),
340 format!(
341 "{} — {} ({})",
342 a.outcome.replace('_', " "),
343 a.reason,
344 a.reference
345 ),
346 ));
347 }
348 if let Some(r) = run {
349 for e in &r.earlier {
350 out.push(row(
351 "Earlier attempt",
352 "failed",
353 format!("{} — run {}", e.attempt_status.replace('_', " "), e.run_id),
354 ));
355 }
356 for note in r
358 .acceptance
359 .as_ref()
360 .and_then(|a| a["notes"].as_array())
361 .into_iter()
362 .flatten()
363 .filter_map(|n| n.as_str())
364 .filter(|n| !n.trim().is_empty())
365 {
366 out.push(row(
367 "Test author",
368 "ready",
369 format!("the test author's note (a model's words): \"{note}\""),
370 ));
371 }
372 if let Some(claim) = r.model_report.as_ref().and_then(|m| m["summary"].as_str()) {
373 out.push(row(
374 "Model says",
375 "ready",
376 format!("\"{claim}\" (a claim, not evidence)"),
377 ));
378 }
379 }
380 out
381}
382
383#[must_use]
387pub fn collection_row(
388 c: Option<&speccoder_core::collection::Collection>,
389 recorded: bool,
390) -> Option<Row> {
391 use speccoder_core::collection::FileState;
392 let row = |mark: &str, text: String| Row {
393 label: "Collection".to_owned(),
394 mark: mark.to_owned(),
395 text,
396 items: Vec::new(),
397 };
398 let Some(c) = c.filter(|c| !c.files.is_empty()) else {
399 return recorded.then(|| row("ready", "no test files changed".to_owned()));
400 };
401 let never: Vec<&speccoder_core::collection::FileCollection> = c.not_collected().collect();
402 let unknown = c.unknown().count();
403 let ran: Vec<_> = c
404 .files
405 .iter()
406 .filter(|f| f.state == FileState::Collected)
407 .collect();
408 let mut parts = Vec::new();
409 if !ran.is_empty() {
410 let tests: usize = ran.iter().map(|f| f.tests).sum();
411 parts.push(format!(
412 "{} test file{} collected ({tests} test{})",
413 ran.len(),
414 if ran.len() == 1 { "" } else { "s" },
415 if tests == 1 { "" } else { "s" }
416 ));
417 }
418 if let Some(first) = never.first() {
419 parts.push(format!(
420 "{} never ran: `{}`{}",
421 never.len(),
422 first.path,
423 first
424 .rule
425 .as_ref()
426 .map(|r| format!(" — {}", r.text))
427 .unwrap_or_default()
428 ));
429 }
430 if unknown > 0 {
431 parts.push(format!("{unknown} could not be told"));
432 }
433 let mark = if !never.is_empty() {
434 "failed"
435 } else if unknown > 0 {
436 "needs_approval"
437 } else {
438 "verified"
439 };
440 Some(row(mark, parts.join(" · ")))
441}
442
443pub fn account_of(
451 path: &std::path::Path,
452 run: &str,
453) -> Result<Vec<crate::substrate::AccountTurn>, String> {
454 let root = crate::format::at(path)
455 .detect(path)
456 .ok_or_else(|| "not a spec project".to_owned())?;
457 let state = crate::worktree::state_dir().ok_or_else(|| "no state directory".to_owned())?;
458 let plane = crate::worktree::project_state_dir(&state, &root);
459 crate::pipeline::tokio_runtime()?
460 .block_on(crate::substrate::account(&plane, run))
461 .map_err(|e| e.0)
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use serde_json::json;
468
469 fn task(state: &str) -> TaskStatus {
470 TaskStatus {
471 lane: speccoder_core::lane::Lane::default(),
472 id: "T002".to_owned(),
473 title: "t".to_owned(),
474 phase: "P".to_owned(),
475 state: state.to_owned(),
476 drift: None,
477 blocked_by: Vec::new(),
478 commit: Some("abc".to_owned()),
479 run_id: None,
480 last_attempt: None,
481 judged: None,
482 set_aside: None,
483 bug: None,
484 regression: None,
485 note: None,
486 attestation: Some("signed ssh SHA256:x".to_owned()),
487 strength: None,
488 }
489 }
490
491 fn phase_stmt(name: &str, result: &str, passed: &[&str], failed: &[&str]) -> Value {
492 json!({ "predicateType": "https://in-toto.io/attestation/test-result/v0.1", "predicate": {
493 "result": result, "configuration": [{ "name": format!("speccoder:phase/{name}") }],
494 "passedTests": passed, "failedTests": failed, "warnedTests": [] } })
495 }
496
497 #[test]
498 fn rows_come_from_the_statements() {
499 let statements = vec![
500 phase_stmt("baseline", "PASSED", &["a"], &[]),
501 phase_stmt("red", "FAILED", &[], &["accept::login"]),
502 phase_stmt("green", "PASSED", &["accept::login", "a"], &[]),
503 json!({ "predicateType": "https://speccoder.dev/task-verification/v0", "predicate": {
504 "integrity": { "findings": [] }, "monitor": { "state": "clean" },
505 "mutation": { "caught": 26, "missed": 3 },
506 "decisions": [{ "verb": "approve", "grant": "run `cargo add x`", "by": "Ada", "via": "dashboard" }],
507 "contained": [{ "argv": ["python3", "-c", "print(1)"] }],
508 "instructions": [{ "path": "AGENTS.md" }] } }),
509 ];
510 let rows = rows(&task("verified"), &statements, None);
511 let text = |label: &str| {
512 rows.iter()
513 .find(|r| r.label == label)
514 .map(|r| r.text.clone())
515 .unwrap_or_default()
516 };
517 assert!(
518 text("Acceptance").contains("1 failing before, 2 passing after"),
519 "{rows:?}"
520 );
521 assert_eq!(text("Held-out"), "not run");
522 assert_eq!(text("Mutation"), "26 caught, 3 survived");
523 assert!(text("Approvals").contains("Ada via dashboard"));
524 assert_eq!(
525 text("Ran without asking"),
526 "1 command the sandbox confined: `python3 -c print(1)`"
527 );
528 assert_eq!(text("Instructions"), "AGENTS.md");
529 assert!(text("Attestation").starts_with("signed"));
530 }
531
532 #[test]
533 fn a_regression_only_tick_says_why_and_its_collection_is_a_row() {
534 let statements = vec![
535 phase_stmt("green", "PASSED", &["a"], &[]),
536 json!({ "predicateType": "https://speccoder.dev/task-verification/v0", "predicate": {
537 "revision": 3,
538 "regression": { "reason": "already_passing" },
539 "collection": { "files": [
540 { "path": "tests/greet.rs", "state": "collected", "decided_by": "junit", "tests": 2, "test_ids": ["greet::greet::a", "greet::greet::b"] },
541 { "path": "tests/unit/x.rs", "state": "not_collected", "decided_by": "static", "tests": 0, "test_ids": [],
542 "rule": { "toolchain": "rust", "text": "Cargo builds `tests/*.rs` and `tests/<dir>/main.rs` (and the modules they declare)", "source": "Cargo.toml (default)" } }
543 ] } } }),
544 ];
545 let rows = rows(&task("regression_only"), &statements, None);
546 let row = |label: &str| rows.iter().find(|r| r.label == label).cloned().unwrap();
547 assert_eq!(
548 row("Acceptance").text,
549 "none red first — already passing before the attempt"
550 );
551 let c = row("Collection");
552 assert_eq!(c.mark, "failed");
553 assert_eq!(
554 c.text,
555 "1 test file collected (2 tests) · 1 never ran: `tests/unit/x.rs` — Cargo builds `tests/*.rs` and `tests/<dir>/main.rs` (and the modules they declare)"
556 );
557 assert_eq!(
559 collection_row(None, true).map(|r| r.text),
560 Some("no test files changed".to_owned())
561 );
562 assert_eq!(collection_row(None, false), None);
563 let mut legacy = task("regression_only");
565 legacy.regression = Some(speccoder_core::regression::Regression::of(
566 speccoder_core::regression::RegressionReason::Unrecorded,
567 ));
568 let rows = super::rows(&legacy, &[phase_stmt("green", "PASSED", &["a"], &[])], None);
569 assert!(
570 rows[0]
571 .text
572 .ends_with("why was not recorded (ticked by an earlier speccoder)")
573 );
574 }
575
576 #[test]
577 fn the_test_author_s_note_is_a_model_s_words_in_the_detail() {
578 let mut run = crate::pipeline::outcome::bare_report("001-x", "T002", "/w", "completed");
579 run.acceptance = Some(json!({ "notes": ["the spec has no example for an empty name"] }));
580 let rows = rows(&task("regression_only"), &[], Some(&run));
581 let note = rows
582 .iter()
583 .find(|r| r.label == "Test author")
584 .unwrap_or_else(|| panic!("{rows:?}"));
585 assert_eq!(
586 note.text,
587 "the test author's note (a model's words): \"the spec has no example for an empty name\""
588 );
589 }
590
591 #[test]
592 fn banners_never_blur() {
593 assert_eq!(banner("verified").1, "VERIFIED");
594 assert!(
595 banner("regression_only")
596 .1
597 .contains("nothing task-specific")
598 );
599 assert!(banner("attested").1.contains("not evidence"));
600 assert_ne!(banner("attested").0, banner("verified").0);
601 }
602}