1use std::{
4 collections::BTreeMap,
5 fmt, fs,
6 io::{self, Write},
7 path::{Path, PathBuf},
8 process::ExitCode,
9};
10
11use anyhow::Result;
12use serde::{Deserialize, Deserializer, Serialize, de};
13use thiserror::Error;
14
15use crate::{cli, config::MemorySkillCandidateKind, time::unix_now};
16
17pub const MACHINE_LEDGER_FILE: &str = "ledger.jsonl";
18pub const MARKDOWN_LEDGER_FILE: &str = "ledger.md";
19
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
21pub struct LedgerEntry {
22 pub commit_sha: String,
23 pub verdict: Verdict,
24 pub disposition: Disposition,
25 pub claim: String,
26 pub evidence: Vec<String>,
27 pub reviewer: ReviewerConfig,
28 #[serde(default)]
29 pub summary: String,
30 pub findings: Vec<String>,
31 #[serde(default)]
32 pub structured_findings: Vec<StructuredFinding>,
33 #[serde(default)]
34 pub next_steps: Vec<String>,
35 #[serde(default)]
36 pub memory_skill_classification: MemorySkillClassification,
37 #[serde(default)]
38 pub raw_reviewer_output: String,
39 pub resolution: Option<Resolution>,
40 pub created_at_unix: u64,
41 pub updated_at_unix: u64,
42}
43
44impl LedgerEntry {
45 pub fn new(
46 commit_sha: impl Into<String>,
47 verdict: Verdict,
48 claim: impl Into<String>,
49 evidence: Vec<String>,
50 reviewer: ReviewerConfig,
51 findings: Vec<String>,
52 ) -> Self {
53 Self::new_at(
54 commit_sha,
55 verdict,
56 claim,
57 evidence,
58 reviewer,
59 findings,
60 unix_now(),
61 )
62 }
63
64 pub fn new_at(
65 commit_sha: impl Into<String>,
66 verdict: Verdict,
67 claim: impl Into<String>,
68 evidence: Vec<String>,
69 reviewer: ReviewerConfig,
70 findings: Vec<String>,
71 timestamp: u64,
72 ) -> Self {
73 let disposition = match verdict {
74 Verdict::Pass => Disposition::Resolved,
75 Verdict::Reject => Disposition::Open,
76 };
77
78 Self {
79 commit_sha: commit_sha.into(),
80 verdict,
81 disposition,
82 claim: claim.into(),
83 evidence,
84 reviewer,
85 summary: String::new(),
86 findings,
87 structured_findings: Vec::new(),
88 next_steps: Vec::new(),
89 memory_skill_classification: MemorySkillClassification::default(),
90 raw_reviewer_output: String::new(),
91 resolution: None,
92 created_at_unix: timestamp,
93 updated_at_unix: timestamp,
94 }
95 }
96
97 pub fn with_structured_review(
98 mut self,
99 summary: impl Into<String>,
100 structured_findings: Vec<StructuredFinding>,
101 next_steps: Vec<String>,
102 raw_reviewer_output: impl Into<String>,
103 ) -> Self {
104 self.summary = summary.into();
105 self.structured_findings = structured_findings;
106 self.next_steps = next_steps;
107 self.raw_reviewer_output = raw_reviewer_output.into();
108 self
109 }
110
111 pub fn with_memory_skill_classification(
112 mut self,
113 classification: MemorySkillClassification,
114 ) -> Self {
115 self.memory_skill_classification = classification;
116 self
117 }
118
119 pub fn is_unresolved_rejection(&self) -> bool {
120 self.verdict == Verdict::Reject && self.disposition == Disposition::Open
121 }
122}
123
124#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
125#[serde(rename_all = "UPPERCASE")]
126pub enum Verdict {
127 Pass,
128 Reject,
129}
130
131impl fmt::Display for Verdict {
132 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133 match self {
134 Self::Pass => formatter.write_str("PASS"),
135 Self::Reject => formatter.write_str("REJECT"),
136 }
137 }
138}
139
140#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
141#[serde(rename_all = "kebab-case")]
142pub enum FindingSeverity {
143 Critical,
144 High,
145 Medium,
146 Low,
147}
148
149impl fmt::Display for FindingSeverity {
150 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151 match self {
152 Self::Critical => formatter.write_str("critical"),
153 Self::High => formatter.write_str("high"),
154 Self::Medium => formatter.write_str("medium"),
155 Self::Low => formatter.write_str("low"),
156 }
157 }
158}
159
160#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
161pub struct StructuredFinding {
162 pub severity: FindingSeverity,
163 pub title: String,
164 pub body: String,
165 pub file: String,
166 pub line_start: u32,
167 pub line_end: u32,
168 #[serde(deserialize_with = "deserialize_confidence")]
169 pub confidence: u8,
170 pub recommendation: String,
171}
172
173impl StructuredFinding {
174 pub fn display_line(&self) -> String {
175 format!(
176 "{} [{}:{}-{}] {}: {} Recommendation: {} Confidence: {}%",
177 self.severity,
178 self.file,
179 self.line_start,
180 self.line_end,
181 self.title,
182 self.body,
183 self.recommendation,
184 self.confidence
185 )
186 }
187}
188
189#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
190pub struct MemorySkillClassification {
191 pub kind: MemorySkillClassificationKind,
192 pub learning_source: String,
193 pub reasoning: String,
194}
195
196impl Default for MemorySkillClassification {
197 fn default() -> Self {
198 Self {
199 kind: MemorySkillClassificationKind::None,
200 learning_source: String::new(),
201 reasoning: String::new(),
202 }
203 }
204}
205
206impl MemorySkillClassification {
207 pub fn candidate_kind(&self) -> Option<MemorySkillCandidateKind> {
208 match self.kind {
209 MemorySkillClassificationKind::None => None,
210 MemorySkillClassificationKind::HowToSkill => Some(MemorySkillCandidateKind::HowToSkill),
211 MemorySkillClassificationKind::AntiPatternSkill => {
212 Some(MemorySkillCandidateKind::AntiPatternSkill)
213 }
214 MemorySkillClassificationKind::RemediationSkill => {
215 Some(MemorySkillCandidateKind::RemediationSkill)
216 }
217 }
218 }
219
220 pub fn validate_for_verdict(&self, verdict: Verdict) -> Result<(), String> {
221 if self.reasoning.trim().is_empty() {
222 return Err("memory_skill.reasoning must not be empty".to_owned());
223 }
224 if self.kind != MemorySkillClassificationKind::None
225 && self.learning_source.trim().is_empty()
226 {
227 return Err(
228 "memory_skill.learning_source must not be empty when kind is not none".to_owned(),
229 );
230 }
231 match (verdict, self.kind) {
232 (Verdict::Pass, MemorySkillClassificationKind::AntiPatternSkill)
233 | (Verdict::Pass, MemorySkillClassificationKind::RemediationSkill) => Err(
234 "PASS verdict cannot propose anti_pattern_skill or remediation_skill".to_owned(),
235 ),
236 (Verdict::Reject, MemorySkillClassificationKind::HowToSkill) => {
237 Err("REJECT verdict cannot propose how_to_skill".to_owned())
238 }
239 _ => Ok(()),
240 }
241 }
242}
243
244#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
245#[serde(rename_all = "snake_case")]
246pub enum MemorySkillClassificationKind {
247 None,
248 HowToSkill,
249 AntiPatternSkill,
250 RemediationSkill,
251}
252
253impl std::fmt::Display for MemorySkillClassificationKind {
254 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
255 let value = match self {
256 Self::None => "none",
257 Self::HowToSkill => "how_to_skill",
258 Self::AntiPatternSkill => "anti_pattern_skill",
259 Self::RemediationSkill => "remediation_skill",
260 };
261 formatter.write_str(value)
262 }
263}
264
265fn deserialize_confidence<'de, D>(deserializer: D) -> Result<u8, D::Error>
274where
275 D: Deserializer<'de>,
276{
277 struct ConfidenceVisitor;
278
279 impl<'de> de::Visitor<'de> for ConfidenceVisitor {
280 type Value = u8;
281
282 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
283 formatter.write_str("an integer percent 0..=100 or a normalized float 0.0..=1.0")
284 }
285
286 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
287 where
288 E: de::Error,
289 {
290 Ok(clamp_percent(value))
291 }
292
293 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
294 where
295 E: de::Error,
296 {
297 Ok(u64::try_from(value).map_or(0, clamp_percent))
299 }
300
301 fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
302 where
303 E: de::Error,
304 {
305 Ok(confidence_from_f64(value))
306 }
307
308 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
309 where
310 E: de::Error,
311 {
312 Ok(value.trim().parse::<f64>().map_or(0, confidence_from_f64))
316 }
317 }
318
319 deserializer.deserialize_any(ConfidenceVisitor)
320}
321
322fn clamp_percent(value: u64) -> u8 {
324 value.min(100) as u8
325}
326
327fn confidence_from_f64(value: f64) -> u8 {
333 if value.is_nan() {
334 return 0;
335 }
336
337 let percent = if (0.0..=1.0).contains(&value) {
338 value * 100.0
339 } else {
340 value
341 };
342
343 if percent <= 0.0 {
344 0
345 } else if percent >= 100.0 {
346 100
347 } else {
348 percent.round() as u8
349 }
350}
351
352#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
353#[serde(rename_all = "kebab-case")]
354pub enum Disposition {
355 Open,
356 Resolved,
357 Waived,
358}
359
360impl fmt::Display for Disposition {
361 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
362 match self {
363 Self::Open => formatter.write_str("open"),
364 Self::Resolved => formatter.write_str("resolved"),
365 Self::Waived => formatter.write_str("waived"),
366 }
367 }
368}
369
370#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
371pub struct ReviewerConfig {
372 pub harness: String,
373 pub model: String,
374 pub allow_same_model: bool,
375}
376
377impl ReviewerConfig {
378 pub fn new(
379 harness: impl Into<String>,
380 model: impl Into<String>,
381 allow_same_model: bool,
382 ) -> Self {
383 Self {
384 harness: harness.into(),
385 model: model.into(),
386 allow_same_model,
387 }
388 }
389}
390
391#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
392pub struct Resolution {
393 pub kind: ResolutionKind,
394 pub reason: String,
395 pub resolved_at_unix: u64,
396}
397
398#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
399#[serde(rename_all = "kebab-case")]
400pub enum ResolutionKind {
401 Resolved,
402 Waived,
403}
404
405#[derive(Clone, Debug, Default, Eq, PartialEq)]
406pub struct LedgerStats {
407 pub total: usize,
408 pub pass: usize,
409 pub reject: usize,
410 pub unresolved: usize,
411 pub resolved: usize,
412 pub waived: usize,
413}
414
415#[derive(Clone, Debug)]
416pub struct LedgerStore {
417 root: PathBuf,
418}
419
420impl LedgerStore {
421 pub fn new(root: impl Into<PathBuf>) -> Self {
422 Self { root: root.into() }
423 }
424
425 pub fn machine_path(&self) -> PathBuf {
426 self.root.join(MACHINE_LEDGER_FILE)
427 }
428
429 pub fn markdown_path(&self) -> PathBuf {
430 self.root.join(MARKDOWN_LEDGER_FILE)
431 }
432
433 pub fn append_entry(&self, entry: &LedgerEntry) -> Result<(), LedgerError> {
434 fs::create_dir_all(&self.root)?;
435 let mut file = fs::OpenOptions::new()
436 .create(true)
437 .append(true)
438 .open(self.machine_path())?;
439 serde_json::to_writer(&mut file, entry)?;
440 writeln!(file)?;
441 self.render_markdown_mirror()?;
442 Ok(())
443 }
444
445 pub fn read_history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
446 let path = self.machine_path();
447 let contents = match fs::read_to_string(&path) {
448 Ok(contents) => contents,
449 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
450 Err(error) => return Err(error.into()),
451 };
452
453 contents
454 .lines()
455 .filter(|line| !line.trim().is_empty())
456 .map(|line| serde_json::from_str(line).map_err(LedgerError::from))
457 .collect()
458 }
459
460 pub fn latest_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
461 let mut by_sha = BTreeMap::new();
462 for entry in self.read_history()? {
463 by_sha.insert(entry.commit_sha.clone(), entry);
464 }
465 Ok(by_sha.into_values().collect())
466 }
467
468 pub fn show(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
469 self.latest_entries()?
470 .into_iter()
471 .find(|entry| entry.commit_sha == sha)
472 .ok_or_else(|| LedgerError::NotFound {
473 sha: sha.to_owned(),
474 })
475 }
476
477 pub fn unresolved_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
478 Ok(self
479 .latest_entries()?
480 .into_iter()
481 .filter(LedgerEntry::is_unresolved_rejection)
482 .collect())
483 }
484
485 pub fn resolve(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
486 self.transition_rejection(
487 sha,
488 Disposition::Resolved,
489 ResolutionKind::Resolved,
490 "resolved",
491 )
492 }
493
494 pub fn waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
495 if reason.trim().is_empty() {
496 return Err(LedgerError::EmptyWaiverReason);
497 }
498
499 self.transition_rejection(sha, Disposition::Waived, ResolutionKind::Waived, reason)
500 }
501
502 pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
503 let mut stats = LedgerStats::default();
504 for entry in self.latest_entries()? {
505 stats.total += 1;
506 match entry.verdict {
507 Verdict::Pass => stats.pass += 1,
508 Verdict::Reject => stats.reject += 1,
509 }
510 match entry.disposition {
511 Disposition::Open => {
512 if entry.verdict == Verdict::Reject {
513 stats.unresolved += 1;
514 }
515 }
516 Disposition::Resolved => stats.resolved += 1,
517 Disposition::Waived => stats.waived += 1,
518 }
519 }
520 Ok(stats)
521 }
522
523 pub fn render_markdown_mirror(&self) -> Result<(), LedgerError> {
524 fs::create_dir_all(&self.root)?;
525 let markdown = render_markdown(&self.latest_entries()?, &self.stats()?);
526 fs::write(self.markdown_path(), markdown)?;
527 Ok(())
528 }
529
530 fn transition_rejection(
531 &self,
532 sha: &str,
533 disposition: Disposition,
534 kind: ResolutionKind,
535 reason: &str,
536 ) -> Result<LedgerEntry, LedgerError> {
537 let mut entry = self.show(sha)?;
538 if !entry.is_unresolved_rejection() {
539 return Err(LedgerError::NoOpenRejection {
540 sha: sha.to_owned(),
541 });
542 }
543
544 let timestamp = unix_now();
545 entry.disposition = disposition;
546 entry.resolution = Some(Resolution {
547 kind,
548 reason: reason.trim().to_owned(),
549 resolved_at_unix: timestamp,
550 });
551 entry.updated_at_unix = timestamp;
552 self.append_entry(&entry)?;
553 Ok(entry)
554 }
555}
556
557#[derive(Debug, Error)]
558pub enum LedgerError {
559 #[error("ledger IO failed: {0}")]
560 Io(#[from] io::Error),
561 #[error("ledger JSON failed: {0}")]
562 Json(#[from] serde_json::Error),
563 #[error("ledger entry not found for commit {sha}")]
564 NotFound { sha: String },
565 #[error("commit {sha} has no open rejection")]
566 NoOpenRejection { sha: String },
567 #[error("waive requires a non-empty reason")]
568 EmptyWaiverReason,
569}
570
571pub fn run(args: cli::LedgerArgs, state_dir: &Path) -> Result<ExitCode> {
572 let store = LedgerStore::new(state_dir);
573 match args.command {
574 cli::LedgerCommand::List => {
575 print_unresolved(&store.unresolved_rejections()?);
576 }
577 cli::LedgerCommand::Show { sha } => {
578 let entry = store.show(&sha)?;
579 print_entry(&entry);
580 }
581 cli::LedgerCommand::Resolve { sha } => {
582 let entry = store.resolve(&sha)?;
583 println!("resolved {}", entry.commit_sha);
584 }
585 cli::LedgerCommand::Waive { sha, reason } => {
586 let entry = store.waive(&sha, &reason)?;
587 println!("waived {}", entry.commit_sha);
588 }
589 cli::LedgerCommand::Stats => print_stats(&store.stats()?),
590 }
591
592 Ok(ExitCode::SUCCESS)
593}
594
595fn render_markdown(entries: &[LedgerEntry], stats: &LedgerStats) -> String {
596 let mut output = String::new();
597 output.push_str("# Truth Mirror Ledger\n\n");
598 output.push_str("## Summary\n\n");
599 output.push_str(&format!("- Total: {}\n", stats.total));
600 output.push_str(&format!("- PASS: {}\n", stats.pass));
601 output.push_str(&format!("- REJECT: {}\n", stats.reject));
602 output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
603 output.push_str(&format!("- Resolved: {}\n", stats.resolved));
604 output.push_str(&format!("- Waived: {}\n\n", stats.waived));
605 output.push_str("## Entries\n");
606
607 if entries.is_empty() {
608 output.push_str("\nNo ledger entries.\n");
609 return output;
610 }
611
612 for entry in entries {
613 output.push_str(&format!(
614 "\n### {} - {} - {}\n\n",
615 entry.commit_sha, entry.verdict, entry.disposition
616 ));
617 output.push_str(&format!("- Claim: {}\n", entry.claim));
618 output.push_str(&format!(
619 "- Evidence: {}\n",
620 if entry.evidence.is_empty() {
621 "none".to_owned()
622 } else {
623 entry.evidence.join(", ")
624 }
625 ));
626 output.push_str(&format!(
627 "- Reviewer: {}/{} (allow_same_model={})\n",
628 entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
629 ));
630 if !entry.summary.trim().is_empty() {
631 output.push_str(&format!("- Summary: {}\n", entry.summary));
632 }
633
634 if entry.findings.is_empty() {
635 output.push_str("- Findings: none\n");
636 } else if !entry.structured_findings.is_empty() {
637 output.push_str("- Findings:\n");
638 for finding in &entry.structured_findings {
639 output.push_str(&format!(" - {}\n", finding.display_line()));
640 }
641 } else {
642 output.push_str("- Findings:\n");
643 for finding in &entry.findings {
644 output.push_str(&format!(" - {}\n", finding));
645 }
646 }
647
648 if !entry.next_steps.is_empty() {
649 output.push_str("- Next steps:\n");
650 for step in &entry.next_steps {
651 output.push_str(&format!(" - {}\n", step));
652 }
653 }
654
655 if !entry
656 .memory_skill_classification
657 .reasoning
658 .trim()
659 .is_empty()
660 {
661 let classification = &entry.memory_skill_classification;
662 output.push_str(&format!("- Memory skill: {}", classification.kind));
663 if !classification.learning_source.trim().is_empty() {
664 output.push_str(&format!(" - {}", classification.learning_source));
665 }
666 output.push_str(&format!(" - {}\n", classification.reasoning));
667 }
668
669 if !entry.raw_reviewer_output.trim().is_empty() {
670 output.push_str("- Raw reviewer output:\n\n```json\n");
671 output.push_str(entry.raw_reviewer_output.trim());
672 output.push_str("\n```\n");
673 }
674
675 if let Some(resolution) = &entry.resolution {
676 output.push_str(&format!(
677 "- Resolution: {:?} - {}\n",
678 resolution.kind, resolution.reason
679 ));
680 }
681 }
682
683 output
684}
685
686fn print_unresolved(entries: &[LedgerEntry]) {
687 if entries.is_empty() {
688 println!("No unresolved rejected commits.");
689 return;
690 }
691
692 for entry in entries {
693 println!(
694 "{} {} {} {}",
695 entry.commit_sha, entry.verdict, entry.disposition, entry.claim
696 );
697 }
698}
699
700fn print_entry(entry: &LedgerEntry) {
701 println!("commit: {}", entry.commit_sha);
702 println!("verdict: {}", entry.verdict);
703 println!("disposition: {}", entry.disposition);
704 println!("claim: {}", entry.claim);
705 println!("evidence: {}", entry.evidence.join(", "));
706 println!(
707 "reviewer: {}/{} allow_same_model={}",
708 entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
709 );
710 if !entry.summary.trim().is_empty() {
711 println!("summary: {}", entry.summary);
712 }
713 if entry.findings.is_empty() {
714 println!("findings: none");
715 } else if !entry.structured_findings.is_empty() {
716 println!("findings:");
717 for finding in &entry.structured_findings {
718 println!("- {}", finding.display_line());
719 }
720 } else {
721 println!("findings:");
722 for finding in &entry.findings {
723 println!("- {finding}");
724 }
725 }
726 if !entry.next_steps.is_empty() {
727 println!("next_steps:");
728 for step in &entry.next_steps {
729 println!("- {step}");
730 }
731 }
732 if !entry
733 .memory_skill_classification
734 .reasoning
735 .trim()
736 .is_empty()
737 {
738 let classification = &entry.memory_skill_classification;
739 println!("memory_skill_kind: {}", classification.kind);
740 if !classification.learning_source.trim().is_empty() {
741 println!(
742 "memory_skill_learning_source: {}",
743 classification.learning_source
744 );
745 }
746 println!("memory_skill_reasoning: {}", classification.reasoning);
747 }
748 if !entry.raw_reviewer_output.trim().is_empty() {
749 println!("raw_reviewer_output:");
750 println!("{}", entry.raw_reviewer_output.trim());
751 }
752}
753
754fn print_stats(stats: &LedgerStats) {
755 println!("total={}", stats.total);
756 println!("pass={}", stats.pass);
757 println!("reject={}", stats.reject);
758 println!("unresolved={}", stats.unresolved);
759 println!("resolved={}", stats.resolved);
760 println!("waived={}", stats.waived);
761}
762
763#[cfg(test)]
764mod tests {
765 use std::fs;
766
767 use proptest::prelude::*;
768
769 use super::{
770 Disposition, FindingSeverity, LedgerEntry, LedgerStore, MemorySkillClassification,
771 MemorySkillClassificationKind, ResolutionKind, ReviewerConfig, StructuredFinding, Verdict,
772 };
773
774 fn reviewer() -> ReviewerConfig {
775 ReviewerConfig::new("claude", "claude-opus-4-1", false)
776 }
777
778 fn rejected_entry(sha: &str) -> LedgerEntry {
779 LedgerEntry::new_at(
780 sha,
781 Verdict::Reject,
782 "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
783 vec!["tests:cargo-test".to_owned()],
784 reviewer(),
785 vec!["claim was unsupported".to_owned()],
786 100,
787 )
788 }
789
790 fn structured_finding() -> StructuredFinding {
791 StructuredFinding {
792 severity: FindingSeverity::High,
793 title: "missing proof".to_owned(),
794 body: "The evidence pointer does not prove the claim.".to_owned(),
795 file: "src/lib.rs".to_owned(),
796 line_start: 7,
797 line_end: 9,
798 confidence: 95,
799 recommendation: "Add executable evidence before claiming completion.".to_owned(),
800 }
801 }
802
803 #[test]
804 fn append_and_read_latest_entries() {
805 let temp = tempfile::tempdir().unwrap();
806 let store = LedgerStore::new(temp.path());
807 store.append_entry(&rejected_entry("abc123")).unwrap();
808
809 let entries = store.latest_entries().unwrap();
810
811 assert_eq!(entries.len(), 1);
812 assert_eq!(entries[0].commit_sha, "abc123");
813 assert!(entries[0].is_unresolved_rejection());
814 }
815
816 #[test]
817 fn markdown_mirror_renders_summary_and_findings() {
818 let temp = tempfile::tempdir().unwrap();
819 let store = LedgerStore::new(temp.path());
820 store.append_entry(&rejected_entry("abc123")).unwrap();
821
822 let markdown = fs::read_to_string(store.markdown_path()).unwrap();
823
824 assert!(markdown.contains("# Truth Mirror Ledger"));
825 assert!(markdown.contains("Unresolved rejections: 1"));
826 assert!(markdown.contains("claim was unsupported"));
827 }
828
829 #[test]
830 fn markdown_mirror_renders_structured_review_provenance() {
831 let temp = tempfile::tempdir().unwrap();
832 let store = LedgerStore::new(temp.path());
833 let entry = rejected_entry("abc123")
834 .with_structured_review(
835 "The claim is not proven.",
836 vec![structured_finding()],
837 vec!["Run cargo test.".to_owned()],
838 r#"{"verdict":"REJECT"}"#,
839 )
840 .with_memory_skill_classification(MemorySkillClassification {
841 kind: MemorySkillClassificationKind::AntiPatternSkill,
842 learning_source: "missing proof".to_owned(),
843 reasoning: "The reviewer proposed an anti-pattern candidate.".to_owned(),
844 });
845 store.append_entry(&entry).unwrap();
846
847 let markdown = fs::read_to_string(store.markdown_path()).unwrap();
848
849 assert!(markdown.contains("The claim is not proven."));
850 assert!(markdown.contains("high [src/lib.rs:7-9] missing proof"));
851 assert!(markdown.contains("Run cargo test."));
852 assert!(markdown.contains("Memory skill: anti_pattern_skill - missing proof"));
853 assert!(markdown.contains(r#"{"verdict":"REJECT"}"#));
854 }
855
856 #[test]
857 fn old_ledger_entry_without_memory_skill_classification_reads_as_none() {
858 let entry: LedgerEntry = serde_json::from_value(serde_json::json!({
859 "commit_sha": "abc123",
860 "verdict": "REJECT",
861 "disposition": "open",
862 "claim": "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
863 "evidence": ["tests:cargo-test"],
864 "reviewer": {
865 "harness": "claude",
866 "model": "claude-opus-4-1",
867 "allow_same_model": false
868 },
869 "summary": "The claim is not proven.",
870 "findings": ["missing proof"],
871 "structured_findings": [],
872 "next_steps": [],
873 "raw_reviewer_output": "{}",
874 "resolution": null,
875 "created_at_unix": 100,
876 "updated_at_unix": 100
877 }))
878 .unwrap();
879
880 assert_eq!(
881 entry.memory_skill_classification.kind,
882 MemorySkillClassificationKind::None
883 );
884 }
885
886 #[test]
887 fn resolve_clears_unresolved_rejection() {
888 let temp = tempfile::tempdir().unwrap();
889 let store = LedgerStore::new(temp.path());
890 store.append_entry(&rejected_entry("abc123")).unwrap();
891
892 let resolved = store.resolve("abc123").unwrap();
893
894 assert_eq!(resolved.disposition, Disposition::Resolved);
895 assert_eq!(
896 resolved.resolution.as_ref().unwrap().kind,
897 ResolutionKind::Resolved
898 );
899 assert!(store.unresolved_rejections().unwrap().is_empty());
900 assert_eq!(store.read_history().unwrap().len(), 2);
901 }
902
903 #[test]
904 fn waive_records_reason_and_clears_unresolved_rejection() {
905 let temp = tempfile::tempdir().unwrap();
906 let store = LedgerStore::new(temp.path());
907 store.append_entry(&rejected_entry("abc123")).unwrap();
908
909 let waived = store.waive("abc123", "Ramiro approved exception").unwrap();
910
911 assert_eq!(waived.disposition, Disposition::Waived);
912 assert_eq!(
913 waived.resolution.as_ref().unwrap().reason,
914 "Ramiro approved exception"
915 );
916 assert!(store.unresolved_rejections().unwrap().is_empty());
917 }
918
919 #[test]
920 fn stats_counts_latest_dispositions() {
921 let temp = tempfile::tempdir().unwrap();
922 let store = LedgerStore::new(temp.path());
923 store.append_entry(&rejected_entry("abc123")).unwrap();
924 store
925 .append_entry(&LedgerEntry::new_at(
926 "def456",
927 Verdict::Pass,
928 "CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
929 vec!["tests:cargo-test".to_owned()],
930 reviewer(),
931 Vec::new(),
932 100,
933 ))
934 .unwrap();
935 store.waive("abc123", "accepted risk").unwrap();
936
937 let stats = store.stats().unwrap();
938
939 assert_eq!(stats.total, 2);
940 assert_eq!(stats.pass, 1);
941 assert_eq!(stats.reject, 1);
942 assert_eq!(stats.unresolved, 0);
943 assert_eq!(stats.waived, 1);
944 }
945
946 fn confidence_of(confidence: serde_json::Value) -> u8 {
949 let value = serde_json::json!({
950 "severity": "high",
951 "title": "missing proof",
952 "body": "The cited evidence does not prove the claim.",
953 "file": "src/lib.rs",
954 "line_start": 1,
955 "line_end": 1,
956 "confidence": confidence,
957 "recommendation": "Provide executable evidence.",
958 });
959 serde_json::from_value::<StructuredFinding>(value)
960 .expect("a finding must never fail to deserialize over confidence formatting")
961 .confidence
962 }
963
964 #[test]
965 fn confidence_accepts_codex_normalized_floats() {
966 assert_eq!(confidence_of(serde_json::json!(0.95)), 95);
968 assert_eq!(confidence_of(serde_json::json!(0.9)), 90);
969 assert_eq!(confidence_of(serde_json::json!(0.86)), 86);
970 }
971
972 #[test]
973 fn confidence_accepts_integer_percent() {
974 assert_eq!(confidence_of(serde_json::json!(86)), 86);
975 assert_eq!(confidence_of(serde_json::json!(0)), 0);
976 assert_eq!(confidence_of(serde_json::json!(100)), 100);
977 }
978
979 #[test]
980 fn confidence_normalizes_or_clamps_but_never_drops_the_verdict() {
981 assert_eq!(confidence_of(serde_json::json!(95.5)), 96); assert_eq!(confidence_of(serde_json::json!(140)), 100); assert_eq!(confidence_of(serde_json::json!(-3)), 0); assert_eq!(confidence_of(serde_json::json!(-0.2)), 0); assert_eq!(confidence_of(serde_json::json!("0.86")), 86); assert_eq!(confidence_of(serde_json::json!("nonsense")), 0); }
988
989 proptest! {
990 #[test]
991 fn confidence_deserialization_is_total_over_all_finite_floats(value in -1000.0f64..1000.0) {
992 let confidence = confidence_of(serde_json::json!(value));
994 prop_assert!(confidence <= 100);
995 }
996
997 #[test]
998 fn append_read_preserves_unresolved_rejection_semantics(sha in "[a-f0-9]{7,40}") {
999 let temp = tempfile::tempdir().unwrap();
1000 let store = LedgerStore::new(temp.path());
1001 store.append_entry(&rejected_entry(&sha)).unwrap();
1002
1003 let entries = store.latest_entries().unwrap();
1004 prop_assert_eq!(entries.len(), 1);
1005 prop_assert_eq!(entries[0].commit_sha.as_str(), sha.as_str());
1006 prop_assert!(entries[0].is_unresolved_rejection());
1007 }
1008 }
1009}