1use crate::canonical::compute_id;
2use crate::store::Store;
3use crate::tick::{Check, Ground, Liveness, Tick};
4use crate::verify::verify;
5use serde_json::{json, Value};
6use std::path::Path;
7use std::process::ExitCode;
8
9pub fn correct(repo: &Path, a: crate::correct::CorrectArgs) -> ExitCode {
11 match crate::correct::run(repo, a) {
12 Ok(t) => {
13 println!("corrected {} ({} ground(s))", t.id, t.grounds.len());
14 ExitCode::SUCCESS
15 }
16 Err(e) => {
17 eprintln!("error: {e}");
18 ExitCode::FAILURE
19 }
20 }
21}
22
23fn decision_identity(t: &Tick) -> String {
30 let mut v = crate::canonical::hashed_value(t);
31 if let serde_json::Value::Object(m) = &mut v {
32 m.remove("parent_id");
33 }
34 v.to_string()
35}
36
37fn current_decisions(mut ticks: Vec<(String, Tick)>) -> Vec<(String, Tick)> {
41 ticks.sort_by(|a, b| b.1.held_since.cmp(&a.1.held_since).then(b.0.cmp(&a.0)));
43 let mut seen = std::collections::HashSet::new();
44 ticks
45 .into_iter()
46 .filter(|(_, t)| seen.insert(decision_identity(t)))
47 .collect()
48}
49
50fn render_source_ref(v: &serde_json::Value) -> String {
55 v.as_str()
56 .map(String::from)
57 .unwrap_or_else(|| v.to_string())
58}
59
60fn triggered_since(
64 repo: &std::path::Path,
65 ground: &crate::tick::Ground,
66 receipts: &[crate::receipt::Receipt],
67) -> bool {
68 use crate::tick::Check;
69 let triggered_by = match &ground.check {
70 Some(Check::Test { liveness, .. }) => &liveness.triggered_by,
71 _ => return false,
72 };
73 let latest = receipts.iter().max_by(|a, b| a.ran_at.cmp(&b.ran_at));
74 match latest {
75 Some(r) => crate::liveness::changed_since(repo, &r.commit, triggered_by).unwrap_or(false),
76 None => false,
77 }
78}
79
80pub fn init(repo: &Path) -> ExitCode {
81 let store = Store::at(repo);
82 match store.init() {
83 Ok(true) => {
84 println!("created .evolving/ (content-addressed chain + results cache)");
85 ExitCode::SUCCESS
86 }
87 Ok(false) => {
88 println!(".evolving/ already exists (no-op)");
89 ExitCode::SUCCESS
90 }
91 Err(e) => {
92 eprintln!("error: could not create .evolving/: {e}");
93 ExitCode::FAILURE
94 }
95 }
96}
97pub fn show(repo: &Path, id: &str) -> ExitCode {
98 let store = Store::at(repo);
99 let path = store.ticks_dir().join(id);
100 if !path.is_file() {
101 eprintln!("error: no tick with id {id}");
102 return ExitCode::FAILURE;
103 }
104 match std::fs::read_to_string(&path) {
105 Ok(text) => {
106 println!("{text}");
108 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
110 if let Some(a) = v.get("authority").and_then(|x| x.as_str()) {
111 println!("authority: {a}");
112 }
113 if let Some(j) = v.get("jurisdiction").and_then(|x| x.as_str()) {
114 println!("jurisdiction: {j}");
115 }
116 if let Some(r) = v.get("source_ref") {
117 println!("source_ref: {}", render_source_ref(r));
118 }
119 }
120 ExitCode::SUCCESS
121 }
122 Err(e) => {
123 eprintln!("error: reading {id}: {e}");
124 ExitCode::FAILURE
125 }
126 }
127}
128pub fn decide(repo: &Path, decision: Option<&str>, args: &[String]) -> ExitCode {
129 let (decision, args): (Option<&str>, Vec<String>) = match decision {
134 Some(d) if d.starts_with('-') => {
135 let mut v = vec![d.to_string()];
136 v.extend_from_slice(args);
137 (None, v)
138 }
139 other => (other, args.to_vec()),
140 };
141 match crate::capture::run(repo, decision, &args) {
142 Ok(t) => {
143 crate::events::append(&Store::at(repo), "decide", Some(&t), None, None);
144 println!("recorded {} ({} ground(s))", t.id, t.grounds.len());
145 ExitCode::SUCCESS
146 }
147 Err(e) => {
148 eprintln!("error: {e}");
149 ExitCode::FAILURE
150 }
151 }
152}
153
154pub fn guard(repo: &Path, a: crate::guard::GuardArgs) -> ExitCode {
155 match crate::guard::run(repo, a) {
156 Ok(t) => {
157 crate::events::append(&Store::at(repo), "guard", Some(&t), None, None);
158 println!("bound; wrote child {}", t.id);
159 ExitCode::SUCCESS
160 }
161 Err(e) => {
162 eprintln!("error: {e}");
163 ExitCode::FAILURE
164 }
165 }
166}
167
168pub fn verify_cmd(repo: &Path, self_test: bool) -> ExitCode {
169 if self_test {
170 return self_test_golden();
171 }
172 let store = Store::at(repo);
173 for w in crate::verify::unknown_key_warnings(&store).unwrap_or_default() {
176 eprintln!("{w}");
177 }
178 for w in crate::verify::imported_op_warnings(&store).unwrap_or_default() {
181 eprintln!("{w}");
182 }
183 match verify(&store) {
184 Ok(v) if v.is_empty() => {
185 println!("✓ chain intact: every id == hash(payload), lineage forward-only");
186 println!("✓ every tick validates against the closed schema (R1) and check shape (R2)");
187 ExitCode::SUCCESS
188 }
189 Ok(v) => {
190 for line in &v {
191 println!("✗ {line}");
192 }
193 eprintln!("{} violation(s)", v.len());
194 ExitCode::FAILURE
195 }
196 Err(e) => {
197 eprintln!("error: reading store: {e}");
198 ExitCode::FAILURE
199 }
200 }
201}
202
203fn latest_ran_at(receipts: &[crate::receipt::Receipt]) -> Option<String> {
206 receipts.iter().map(|r| r.ran_at.clone()).max()
207}
208
209fn verdict_rank(v: &crate::verdict::Verdict) -> u8 {
216 use crate::verdict::Verdict;
217 match v {
218 Verdict::Red | Verdict::GrayRed => 6,
219 Verdict::SilentlyUnbound => 5,
221 Verdict::Stale { .. } => 4,
222 Verdict::NotRun { .. } => 3,
223 Verdict::Unproven => 2,
224 Verdict::Memo => 1,
225 Verdict::Green | Verdict::Exempt | Verdict::NotApplicable => 0,
226 }
227}
228
229fn roll_up_check(verdicts: &[&crate::verdict::Verdict]) -> Option<(String, Option<String>)> {
236 use crate::verdict::Verdict;
237 let mut worst: Option<(u8, &Verdict)> = None;
238 let mut stale: Option<&Verdict> = None;
239 for &v in verdicts {
240 let rank = verdict_rank(v);
241 if worst.map_or(true, |(r, _)| rank > r) {
242 worst = Some((rank, v));
243 }
244 if stale.is_none() && matches!(v, Verdict::Stale { .. }) {
245 stale = Some(v);
246 }
247 }
248 worst.map(|(rank, v)| {
249 let masked = if rank > 4 {
250 stale.map(|s| s.event_label())
251 } else {
252 None
253 };
254 (v.event_label(), masked)
255 })
256}
257
258fn live_ctx(
262 store: &Store,
263 staleness_days: u64,
264 live_origin_sha: Option<String>,
265 attest: Option<Vec<String>>,
266) -> crate::verdict::Ctx {
267 crate::verdict::Ctx {
268 live_origin_sha,
269 selected: crate::selected::read(store).unwrap_or(None),
270 now_unix: time::OffsetDateTime::now_utc().unix_timestamp(),
271 staleness_secs: staleness_days as i64 * 86_400,
272 attest,
273 }
274}
275
276pub fn check(
277 repo: &Path,
278 exit_on_red: bool,
279 run: bool,
280 platform: &str,
281 offline: bool,
282 attest: Vec<String>,
283) -> ExitCode {
284 use crate::verdict::{verdict_for, Verdict};
285 let store = Store::at(repo);
286 if !store.exists() {
287 eprintln!("error: no .evolving/ store here — run `ev init` first");
288 return ExitCode::FAILURE;
289 }
290 let files = match store.read_all() {
291 Ok(f) => f,
292 Err(e) => {
293 eprintln!("error: reading store: {e}");
294 return ExitCode::FAILURE;
295 }
296 };
297 let config = crate::config::read(&store);
298
299 if run {
302 for (_filename, raw) in &files {
303 let t = match crate::tick::from_value(raw) {
304 Ok(t) => t,
305 Err(_) => continue,
306 };
307 if t.status != "live" {
308 continue;
309 }
310 for g in &t.grounds {
311 if let Some(Check::Test {
312 reference,
313 counter_test,
314 liveness,
315 ..
316 }) = &g.check
317 {
318 if liveness.platforms.iter().any(|p| p == platform) {
319 match crate::runner::run_check(
321 repo,
322 reference,
323 platform,
324 config.green_exit_code,
325 ) {
326 Ok(mut rc) => {
327 if let Some(counter_test) = counter_test {
331 if let Ok(ct) = crate::runner::run_check(
332 repo,
333 counter_test,
334 platform,
335 config.green_exit_code,
336 ) {
337 rc.falsifiable = Some(rc.result != ct.result);
338 }
339 }
340 if let Err(e) = crate::receipt::append(&store, &rc) {
341 eprintln!(
342 "warning: could not write receipt for {reference:?}: {e}"
343 );
344 }
345 }
346 Err(e) => eprintln!("warning: run failed for {reference:?}: {e}"),
347 }
348 }
349 }
350 }
351 }
352 }
353
354 let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, offline);
355 let attest = if attest.is_empty() {
356 None
357 } else {
358 Some(attest)
359 };
360 let ctx = live_ctx(&store, config.staleness_days, live_origin, attest);
361 let mut rows: Vec<String> = Vec::new();
362 let mut any_not_green = false;
363 let mut total_test_bindings = 0usize;
367 let mut harvested_unproven = 0usize;
368
369 for (filename, raw) in &files {
370 let t = match crate::tick::from_value(raw) {
371 Ok(t) => t,
372 Err(_) => continue, };
374 if t.status != "live" {
375 continue;
376 }
377 let mut verdicts = Vec::with_capacity(t.grounds.len());
378 for g in &t.grounds {
379 let receipts = match &g.check {
381 Some(Check::Test { reference, .. }) => {
382 crate::receipt::read_for(&store, reference).unwrap_or_default()
383 }
384 _ => Vec::new(),
385 };
386 let ts = triggered_since(repo, g, &receipts);
388 let mut v = verdict_for(g, &receipts, &ctx, ts);
389 if matches!(t.jurisdiction.as_deref(), Some("C") | Some("D"))
394 && !matches!(v, Verdict::Green | Verdict::NotApplicable | Verdict::Exempt)
395 {
396 v = Verdict::Memo;
397 }
398 if !matches!(
399 v,
400 Verdict::Green | Verdict::NotApplicable | Verdict::Exempt | Verdict::Memo
401 ) {
402 any_not_green = true;
403 }
404 if let Some(Check::Test { counter_test, .. }) = &g.check {
406 total_test_bindings += 1;
407 let harvested = counter_test.is_none();
408 let mut detail = match &v {
409 Verdict::NotRun { missing_platforms } => {
410 format!("missing: {}", missing_platforms.join(", "))
411 }
412 Verdict::Stale { reason, .. } => reason.clone(),
413 _ => latest_ran_at(&receipts)
414 .map(|ts| format!("ran {ts}"))
415 .unwrap_or_else(|| "no receipt".into()),
416 };
417 if harvested {
421 harvested_unproven += 1;
422 detail = format!("harvested — falsifiability not proven; {detail}");
423 crate::events::append(
424 &store,
425 "harvested",
426 Some(&t),
427 Some(&v.event_label()),
428 None,
429 );
430 }
431 rows.push(format!(
432 "{}\t{filename}\t{:?}\t({detail})",
433 v.label(),
434 g.claim
435 ));
436 }
437 verdicts.push((g, v));
438 }
439 let test_verdicts: Vec<&Verdict> = verdicts
442 .iter()
443 .filter(|(g, _)| matches!(g.check, Some(Check::Test { .. })))
444 .map(|(_, v)| v)
445 .collect();
446 if let Some((label, masked_stale)) = roll_up_check(&test_verdicts) {
447 crate::events::append(
448 &store,
449 "check",
450 Some(&t),
451 Some(&label),
452 masked_stale.as_deref(),
453 );
454 }
455 let _ = crate::state::write_state(
457 &store,
458 &t.id,
459 &verdicts,
460 &config.staleness_ref,
461 ctx.live_origin_sha.as_deref(),
462 );
463 }
464
465 if rows.is_empty() {
466 println!("no test-bound grounds to check");
467 } else {
468 for r in &rows {
469 println!("{r}");
470 }
471 if harvested_unproven > 0 {
474 println!(
475 "harvested-unproven: {harvested_unproven} of {total_test_bindings} test bindings have no counter-test (run ev guard to add one)"
476 );
477 }
478 if !run {
479 println!("note: run `ev check --run` to execute each counter-test and prove its falsifiability");
483 }
484 }
485 if exit_on_red && any_not_green {
486 return ExitCode::FAILURE;
487 }
488 ExitCode::SUCCESS
489}
490
491pub struct MigrateArgs {
493 pub sources: Vec<String>,
494 pub dry_run: bool,
495 pub reconcile: bool,
496 pub against: Option<String>,
497 pub blame: Option<String>,
498 pub bind_check: Option<String>,
499 pub platforms: Vec<String>,
500 pub triggered_by: Vec<String>,
501 pub surfaces: Vec<String>,
502 pub verified_at_sha: Option<String>,
503 pub jurisdiction_map: Option<String>,
504}
505
506fn parse_jurisdiction_map(path: &str) -> Result<std::collections::HashMap<String, String>, String> {
512 let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
513 let mut map = std::collections::HashMap::new();
514 for line in text.lines() {
515 let l = line.trim();
516 if l.is_empty() || l.starts_with('#') {
517 continue;
518 }
519 let mut tokens = l.split_whitespace();
520 match (tokens.next(), tokens.next(), tokens.next()) {
521 (Some(key), Some(bucket), None) => {
522 crate::tick::validate_jurisdiction(bucket)
523 .map_err(|e| format!("jurisdiction-map line {l:?}: {e}"))?;
524 map.insert(key.to_string(), bucket.to_string());
525 }
526 _ => {
527 return Err(format!(
528 "jurisdiction-map line {l:?}: expected `<source_key> <bucket>`"
529 ))
530 }
531 }
532 }
533 Ok(map)
534}
535
536fn extract_source(spec: &str) -> Result<Vec<crate::migrate::MigrationRecord>, String> {
540 let (kind, path) = spec
541 .split_once(':')
542 .ok_or_else(|| format!("--source expects <kind>:<path>, got {spec:?}"))?;
543 let text = std::fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
544 let recs = match kind {
545 "canonical" => crate::migrate::canonical_records(&text)?,
548 "gitlog" => crate::migrate::extract_gitlog(&text),
549 "to-human" => crate::migrate::extract_to_human(&text),
550 "decisions-immutable" => crate::migrate::extract_decisions_immutable(&text),
551 "escalation" => crate::migrate::extract_escalation(&text),
552 other => {
553 return Err(format!(
554 "unknown source kind {other:?} (expected canonical | gitlog | to-human | decisions-immutable | escalation)"
555 ))
556 }
557 };
558 Ok(recs)
559}
560
561pub fn migrate(repo: &Path, a: MigrateArgs) -> ExitCode {
562 if let Some(selector) = &a.bind_check {
564 let sha = match crate::capture::resolve_sha(repo, &a.verified_at_sha) {
565 Ok(s) => s,
566 Err(e) => {
567 eprintln!("error: {e}");
568 return ExitCode::FAILURE;
569 }
570 };
571 match crate::migrate::bind_check(
572 selector.clone(),
573 sha,
574 a.platforms.clone(),
575 a.triggered_by.clone(),
576 a.surfaces.clone(),
577 ) {
578 Ok(Check::Test {
579 reference,
580 liveness,
581 ..
582 }) => {
583 println!(
584 "harvested check (falsifiability not proven; no counter-test): {reference:?} on [{}] triggered-by [{}] surface [{}]",
585 liveness.platforms.join(", "),
586 liveness.triggered_by.join(", "),
587 liveness.surfaces.join(", ")
588 );
589 return ExitCode::SUCCESS;
590 }
591 Ok(_) => unreachable!("bind_check yields a Test check"),
592 Err(e) => {
593 eprintln!("error: {e}");
594 return ExitCode::FAILURE;
595 }
596 }
597 }
598
599 if a.reconcile {
601 let against = match &a.against {
602 Some(s) => s,
603 None => {
604 eprintln!("error: --reconcile requires --against <kind>:<path>");
605 return ExitCode::FAILURE;
606 }
607 };
608 let recs = match extract_source(against) {
609 Ok(r) => r,
610 Err(e) => {
611 eprintln!("error: {e}");
612 return ExitCode::FAILURE;
613 }
614 };
615 match crate::migrate::reconcile(repo, &recs) {
616 Ok(rep) => {
617 println!(
618 "reconcile: in-both {}, source-only {} (the capture gap), store-only {}, un-keyable {}",
619 rep.in_both, rep.source_only, rep.store_only, rep.un_keyable
620 );
621 return ExitCode::SUCCESS;
622 }
623 Err(e) => {
624 eprintln!("error: {e}");
625 return ExitCode::FAILURE;
626 }
627 }
628 }
629
630 if a.sources.is_empty() {
632 eprintln!("error: ev migrate needs at least one --source <kind>:<path> (or --reconcile / --bind-check)");
633 return ExitCode::FAILURE;
634 }
635 let mut records = Vec::new();
636 for spec in &a.sources {
637 match extract_source(spec) {
638 Ok(mut r) => records.append(&mut r),
639 Err(e) => {
640 eprintln!("error: {e}");
641 return ExitCode::FAILURE;
642 }
643 }
644 }
645 let jurisdiction_map = match &a.jurisdiction_map {
647 Some(path) => match parse_jurisdiction_map(path) {
648 Ok(m) => m,
649 Err(e) => {
650 eprintln!("error: {e}");
651 return ExitCode::FAILURE;
652 }
653 },
654 None => std::collections::HashMap::new(),
655 };
656 match crate::migrate::backfill(
657 repo,
658 records,
659 a.blame.as_deref(),
660 &jurisdiction_map,
661 a.dry_run,
662 ) {
663 Ok(s) => {
664 if !a.dry_run {
665 crate::events::append(&Store::at(repo), "migrate", None, None, None);
666 }
667 println!(
668 "{}imported {}, skipped {}, re-linked {}, {} source-only gap(s){}",
669 if a.dry_run { "(dry-run) " } else { "" },
670 s.imported,
671 s.skipped,
672 s.relinked,
673 s.source_only_gaps,
674 if s.discrepancies > 0 {
675 format!(", {} discrepancy(ies) — see above", s.discrepancies)
676 } else {
677 String::new()
678 }
679 );
680 ExitCode::SUCCESS
681 }
682 Err(e) => {
683 eprintln!("error: {e}");
684 ExitCode::FAILURE
685 }
686 }
687}
688
689pub fn why(repo: &Path, selector: &str) -> ExitCode {
690 let store = Store::at(repo);
691 if !store.exists() {
692 eprintln!("error: no .evolving/ store here — run `ev init` first");
693 return ExitCode::FAILURE;
694 }
695 let files = match store.read_all() {
696 Ok(f) => f,
697 Err(e) => {
698 eprintln!("error: reading store: {e}");
699 return ExitCode::FAILURE;
700 }
701 };
702 let mut found = false;
703 for (filename, raw) in &files {
704 let t = match crate::tick::from_value(raw) {
705 Ok(t) => t,
706 Err(_) => continue,
707 };
708 if t.status != "live" {
709 continue;
710 }
711 for g in &t.grounds {
712 if let Some(Check::Test { reference, .. }) = &g.check {
713 if reference.as_str() == selector {
714 found = true;
715 println!(
716 "{filename}\t{:?}\tguards: {:?} ({})",
717 t.decision, g.claim, g.supports
718 );
719 }
720 }
721 }
722 }
723 if !found {
724 eprintln!("{selector:?} guards nothing");
725 return ExitCode::FAILURE;
726 }
727 ExitCode::SUCCESS
728}
729
730pub fn list(repo: &Path) -> ExitCode {
732 let store = Store::at(repo);
733 if !store.exists() {
734 eprintln!("error: no .evolving/ store here — run `ev init` first");
735 return ExitCode::FAILURE;
736 }
737 let files = match store.read_all() {
738 Ok(f) => f,
739 Err(e) => {
740 eprintln!("error: reading store: {e}");
741 return ExitCode::FAILURE;
742 }
743 };
744 let mut parsed: Vec<(String, Tick)> = Vec::new();
750 let mut rows: Vec<String> = Vec::new();
751 for (name, raw) in &files {
752 match crate::tick::from_value(raw) {
753 Ok(t) => parsed.push((name.clone(), t)),
754 Err(_) => rows.push(format!("{name}\t?\t\"<unparseable>\"")),
755 }
756 }
757 for (name, t) in current_decisions(parsed) {
758 let mut l = format!("{name}\t{}\t{:?}", t.status, t.decision);
759 if let Some(a) = &t.authority {
760 l.push_str(&format!("\tauthority={a}"));
761 }
762 if let Some(j) = &t.jurisdiction {
763 l.push_str(&format!("\tjurisdiction={j}"));
764 }
765 if let Some(r) = &t.source_ref {
766 l.push_str(&format!("\tsource_ref={}", render_source_ref(r)));
767 }
768 rows.push(l);
769 }
770 rows.sort();
771 if rows.is_empty() {
772 println!("no decisions yet");
773 return ExitCode::SUCCESS;
774 }
775 for line in &rows {
776 println!("{line}");
777 }
778 ExitCode::SUCCESS
779}
780
781fn load_bearing(t: &Tick) -> bool {
785 t.grounds
786 .iter()
787 .any(|g| g.supports.starts_with("rejected:"))
788}
789
790fn brief_visible(t: &Tick) -> bool {
801 t.status == "live"
802 && t.authority.as_deref() == Some("user-ruled")
803 && t.provenance.as_deref() != Some("agent-proposed")
804}
805
806fn brief_json(kept: &[(String, Tick)], total: usize, dropped_lb: usize) -> String {
811 let decisions: Vec<Value> = kept
812 .iter()
813 .map(|(_, t)| {
814 let rejected_roads: Vec<Value> = t
815 .grounds
816 .iter()
817 .filter_map(|g| {
818 g.supports
819 .strip_prefix("rejected:")
820 .map(|option| json!({ "option": option, "claim": g.claim }))
821 })
822 .collect();
823 let mut d = json!({
824 "id": t.id,
825 "decision": t.decision,
826 "load_bearing": load_bearing(t),
827 "rejected_roads": rejected_roads,
828 });
829 if let (Some(sr), Some(obj)) = (&t.source_ref, d.as_object_mut()) {
831 obj.insert(
832 "source_ref".into(),
833 Value::String(crate::tick::source_ref_key(sr)),
834 );
835 }
836 d
837 })
838 .collect();
839 let payload = json!({
840 "kind": "ev-brief",
841 "decisions": decisions,
842 "shown": kept.len(),
843 "total": total,
844 "elided": total - kept.len(),
845 "elided_load_bearing": dropped_lb,
846 });
847 format!(
852 "{}\n",
853 serde_json::to_string(&payload).expect("ev-brief payload serializes")
854 )
855}
856
857pub fn brief(repo: &Path, limit: Option<usize>, json: bool) -> ExitCode {
858 let store = Store::at(repo);
859 if !store.exists() {
860 eprintln!("error: no .evolving/ store here — run `ev init` first");
861 return ExitCode::FAILURE;
862 }
863 let files = match store.read_all() {
864 Ok(f) => f,
865 Err(e) => {
866 eprintln!("error: reading store: {e}");
867 return ExitCode::FAILURE;
868 }
869 };
870 let limit = limit.unwrap_or(crate::config::read(&store).brief_limit);
872 let all: Vec<(String, Tick)> = files
875 .iter()
876 .filter_map(|(name, raw)| crate::tick::from_value(raw).ok().map(|t| (name.clone(), t)))
877 .collect();
878 let mut kept: Vec<(String, Tick)> = current_decisions(all)
879 .into_iter()
880 .filter(|(_, t)| brief_visible(t))
881 .collect();
882 let lb = load_bearing;
883 kept.sort_by(|a, b| {
886 lb(&b.1)
887 .cmp(&lb(&a.1))
888 .then(b.1.held_since.cmp(&a.1.held_since))
889 .then(b.0.cmp(&a.0))
890 });
891 let total = kept.len();
892 let n = if limit == 0 { total } else { limit.min(total) };
894 let dropped_lb = kept[n..].iter().filter(|(_, t)| lb(t)).count();
896 kept.truncate(n);
897
898 if json {
900 print!("{}", brief_json(&kept, total, dropped_lb));
901 return ExitCode::SUCCESS;
902 }
903 if kept.is_empty() {
904 println!("no user-ruled decisions");
905 return ExitCode::SUCCESS;
906 }
907 for (_id, t) in &kept {
908 println!("{} [user-ruled]", t.decision);
909 for g in &t.grounds {
910 if let Some(option) = g.supports.strip_prefix("rejected:") {
911 println!(" rejected {option}: {}", g.claim);
912 }
913 }
914 }
915 if total > n {
916 let dropped = total - n;
917 let lb_clause = if dropped_lb > 0 {
918 format!(", {dropped_lb} with rejected roads")
919 } else {
920 String::new()
921 };
922 println!("… {dropped} more user-ruled decision(s){lb_clause} — `ev list` for all");
923 }
924 ExitCode::SUCCESS
925}
926
927pub fn log(repo: &Path) -> ExitCode {
929 let store = Store::at(repo);
930 if !store.exists() {
931 eprintln!("error: no .evolving/ store here — run `ev init` first");
932 return ExitCode::FAILURE;
933 }
934 let mut id = match store.read_head() {
935 Ok(h) => h,
936 Err(e) => {
937 eprintln!("error: reading HEAD: {e}");
938 return ExitCode::FAILURE;
939 }
940 };
941 if id.is_empty() {
942 println!("no decisions yet");
943 return ExitCode::SUCCESS;
944 }
945 let mut seen = std::collections::HashSet::new();
946 while !id.is_empty() {
947 if !seen.insert(id.clone()) {
948 break; }
950 match store.read_tick(&id) {
951 Ok(Some(t)) => {
952 println!("{}\t{}\t{:?}", t.id, t.status, t.decision);
953 id = t.parent_id;
954 }
955 Ok(None) => {
956 eprintln!("warning: {id} not found (broken lineage)");
957 break;
958 }
959 Err(e) => {
960 eprintln!("error: reading {id}: {e}");
961 return ExitCode::FAILURE;
962 }
963 }
964 }
965 ExitCode::SUCCESS
966}
967
968pub fn reopen(repo: &Path, id: &str) -> ExitCode {
969 let store = Store::at(repo);
970 let tick = match store.read_tick(id) {
971 Ok(Some(t)) => t,
972 Ok(None) => {
973 eprintln!("error: no tick with id {id}");
974 return ExitCode::FAILURE;
975 }
976 Err(e) => {
977 eprintln!("error: reading {id}: {e}");
978 return ExitCode::FAILURE;
979 }
980 };
981 let config = crate::config::read(&store);
982 let live_origin = crate::staleness::resolve(repo, &store, &config.staleness_ref, true);
983 let ctx = live_ctx(&store, config.staleness_days, live_origin, None);
984
985 crate::events::append(&store, "reopen", Some(&tick), None, None);
986 println!("decision {}: {:?}", tick.id, tick.decision);
987 if !tick.observe.is_empty() {
988 println!("observe: {:?}", tick.observe);
989 }
990 if let Some(a) = &tick.authority {
991 println!("authority: {a}");
992 }
993 if let Some(j) = &tick.jurisdiction {
994 println!("jurisdiction: {j}");
995 }
996 if let Some(r) = &tick.source_ref {
997 println!("source_ref: {}", render_source_ref(r));
998 }
999 for g in &tick.grounds {
1000 match &g.check {
1001 Some(Check::Test {
1002 reference,
1003 verified_at_sha,
1004 ..
1005 }) => {
1006 let receipts = crate::receipt::read_for(&store, reference).unwrap_or_default();
1007 let ts = triggered_since(repo, g, &receipts);
1008 let v = crate::verdict::verdict_for(g, &receipts, &ctx, ts);
1009 let now = v.label();
1010 let short = &verified_at_sha[..verified_at_sha.len().min(8)];
1011 println!(
1012 " [{}] {:?} — test {:?} frozen@{short} now: {now}",
1013 g.supports, g.claim, reference
1014 );
1015 }
1016 Some(Check::Person { reference }) => {
1017 println!(" [{}] {:?} — person {:?}", g.supports, g.claim, reference);
1018 }
1019 None => {
1020 println!(" [{}] {:?}", g.supports, g.claim);
1021 }
1022 }
1023 }
1024 ExitCode::SUCCESS
1025}
1026
1027fn self_test_golden() -> ExitCode {
1029 let genesis = Tick {
1030 id: String::new(),
1031 parent_id: "".into(),
1032 observe: "evaluating retrieval backend".into(),
1033 decision: "freeze the retrieval schema for v2".into(),
1034 grounds: vec![
1035 Ground {
1036 claim: "team still wants a frozen schema".into(),
1037 supports: "chosen".into(),
1038 check: Some(Check::Person {
1039 reference: "Q3 infra review".into(),
1040 }),
1041 },
1042 Ground {
1043 claim: "pgvector would lock our schema".into(),
1044 supports: "rejected:pgvector".into(),
1045 check: None,
1046 },
1047 ],
1048 status: "live".into(),
1049 held_since: "".into(),
1050 blame: "Wang Yu".into(),
1051 authority: None,
1052 jurisdiction: None,
1053 source_ref: None,
1054 provenance: None,
1055 };
1056 let case1 = Tick {
1057 id: String::new(),
1058 parent_id: "7b21f0a4c8de".into(),
1059 observe: "multi-pod restore-safety counter — chat-room R2289→R2290".into(),
1060 decision: "restore-safety counter DB-backed; reject Redis".into(),
1061 grounds: vec![
1062 Ground {
1063 claim: "Argus introduces no Redis; multi-pod coord via existing DB".into(),
1064 supports: "chosen".into(),
1065 check: Some(Check::Test {
1066 reference: "pytest tests/test_redis_absent.py".into(),
1067 verified_at_sha: "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
1068 counter_test: Some(
1069 "pytest tests/test_redis_absent.py::test_redis_injection_flips_red".into(),
1070 ),
1071 liveness: Liveness {
1072 platforms: vec!["linux-ci".into()],
1073 triggered_by: vec!["pyproject.toml".into()],
1074 surfaces: vec!["pyproject-deps".into()],
1075 },
1076 }),
1077 },
1078 Ground {
1079 claim: "team still wants 0-Redis posture".into(),
1080 supports: "chosen".into(),
1081 check: Some(Check::Person {
1082 reference: "Q3 infra review".into(),
1083 }),
1084 },
1085 Ground {
1086 claim: "Redis would add a new infra dependency".into(),
1087 supports: "rejected:Redis".into(),
1088 check: None,
1089 },
1090 ],
1091 status: "live".into(),
1092 held_since: "".into(),
1093 blame: "Wang Yu".into(),
1094 authority: None,
1095 jurisdiction: None,
1096 source_ref: None,
1097 provenance: None,
1098 };
1099 let mut harvested = case1.clone();
1102 if let Some(Check::Test { counter_test, .. }) = &mut harvested.grounds[0].check {
1103 *counter_test = None;
1104 }
1105 let mut ok = true;
1106 for (name, t, want) in [
1107 ("genesis", &genesis, "e2b337f53a1f"),
1108 ("case1", &case1, "638c47b0c9dd"),
1109 ("harvested", &harvested, "0cf784b51331"),
1110 ] {
1111 let got = compute_id(t);
1112 let pass = got == want;
1113 ok &= pass;
1114 println!(
1115 "{} {name}: {got} (want {want})",
1116 if pass { "✓" } else { "✗" }
1117 );
1118 }
1119 if ok {
1120 ExitCode::SUCCESS
1121 } else {
1122 ExitCode::FAILURE
1123 }
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128 use super::roll_up_check;
1129 use crate::verdict::{StaleKind, Verdict};
1130
1131 fn stale_sha() -> Verdict {
1132 Verdict::Stale {
1133 kind: StaleKind::Sha,
1134 reason: String::new(),
1135 }
1136 }
1137
1138 #[test]
1139 fn roll_up_check_should_emit_nothing_when_there_is_no_test_bound_ground() {
1140 assert_eq!(roll_up_check(&[]), None);
1142 }
1143
1144 #[test]
1145 fn roll_up_check_should_carry_the_worst_verdict_red_over_green() {
1146 let (g, r) = (Verdict::Green, Verdict::Red);
1148 assert_eq!(roll_up_check(&[&g, &r]), Some(("red".to_string(), None)));
1149 }
1150
1151 #[test]
1152 fn roll_up_check_should_let_a_gating_silently_unbound_outrank_a_co_occurring_green() {
1153 let (su, g) = (Verdict::SilentlyUnbound, Verdict::Green);
1156 assert_eq!(
1157 roll_up_check(&[&su, &g]),
1158 Some(("silently-unbound".to_string(), None))
1159 );
1160 assert_eq!(
1161 roll_up_check(&[&g, &su]),
1162 Some(("silently-unbound".to_string(), None))
1163 );
1164 }
1165
1166 #[test]
1167 fn roll_up_check_should_carry_the_stale_sub_kind_when_stale_is_the_worst() {
1168 let (s, nr) = (
1170 stale_sha(),
1171 Verdict::NotRun {
1172 missing_platforms: vec!["p".to_string()],
1173 },
1174 );
1175 assert_eq!(
1176 roll_up_check(&[&s, &nr]),
1177 Some(("stale:sha".to_string(), None))
1178 );
1179 }
1180
1181 #[test]
1182 fn roll_up_check_should_surface_a_stale_masked_behind_a_red() {
1183 let (r, s) = (Verdict::Red, stale_sha());
1186 assert_eq!(
1187 roll_up_check(&[&r, &s]),
1188 Some(("red".to_string(), Some("stale:sha".to_string())))
1189 );
1190 }
1191
1192 #[test]
1193 fn roll_up_check_should_emit_green_when_every_ground_is_green() {
1194 let (a, b) = (Verdict::Green, Verdict::Green);
1195 assert_eq!(roll_up_check(&[&a, &b]), Some(("green".to_string(), None)));
1196 }
1197}