use std::path::Path;
use serde::Serialize;
use crate::lint::{FixCandidate, LintDiagnostic, LintFile, LintReport, LintRuleId, check_sources};
use crate::manifest::{Claim, Manifest, Node, NodeFields, is_canonical_id};
use crate::parse::parse_sources;
use crate::report::{Diagnostic, ParseReport};
const MAX_ITERS: usize = 1000;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct AppliedFix {
pub rule: LintRuleId,
pub file: LintFile,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SkippedFix {
pub rule: LintRuleId,
pub file: LintFile,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FixOutcome {
pub applied: Vec<AppliedFix>,
pub skipped: Vec<SkippedFix>,
pub remaining: LintReport,
pub changed_files: Vec<LintFile>,
pub errors: Vec<(LintFile, String)>,
}
impl FixOutcome {
pub fn is_noop(&self) -> bool {
self.applied.is_empty() && self.changed_files.is_empty()
}
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
}
pub fn fix_dir(dir: &Path) -> FixOutcome {
let tree_path = dir.join("trace/exploration_tree.yaml");
let claims_path = dir.join("logic/claims.md");
let orig_tree = std::fs::read_to_string(&tree_path).unwrap_or_default();
let orig_claims = std::fs::read_to_string(&claims_path).ok();
let mut applier = Applier::new(orig_tree.clone(), orig_claims.clone());
applier.run();
let mut changed_files = Vec::new();
let mut errors = Vec::new();
if applier.tree != orig_tree {
match std::fs::write(&tree_path, &applier.tree) {
Ok(()) => changed_files.push(LintFile::Tree),
Err(e) => errors.push((LintFile::Tree, e.to_string())),
}
}
if let Some(new_claims) = &applier.claims
&& orig_claims.as_deref() != Some(new_claims.as_str())
{
match std::fs::write(&claims_path, new_claims) {
Ok(()) => changed_files.push(LintFile::Claims),
Err(e) => errors.push((LintFile::Claims, e.to_string())),
}
}
let remaining = check_sources(&applier.tree, applier.claims.as_deref());
let skipped = remaining
.diagnostics()
.iter()
.filter(|d| d.fixable)
.map(|d| SkippedFix {
rule: d.rule,
file: d.file,
reason: applier.reason_for(d),
})
.collect();
FixOutcome {
applied: applier.applied,
skipped,
remaining,
changed_files,
errors,
}
}
type ParseResult = Result<(Manifest, ParseReport), ParseReport>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AliasField {
WhyFailed,
Rationale,
}
struct Applier {
tree: String,
claims: Option<String>,
applied: Vec<AppliedFix>,
failed: Vec<(LintRuleId, LintFile, usize, String)>,
}
impl Applier {
fn new(tree: String, claims: Option<String>) -> Self {
Self {
tree,
claims,
applied: Vec::new(),
failed: Vec::new(),
}
}
fn run(&mut self) {
for _ in 0..MAX_ITERS {
let report = check_sources(&self.tree, self.claims.as_deref());
let Some(diag) = report
.diagnostics()
.iter()
.find(|d| d.fixable && d.fix.is_some() && !self.is_failed(d))
.cloned()
else {
break;
};
if self.step(&diag) {
self.failed.clear();
}
}
}
fn step(&mut self, diag: &LintDiagnostic) -> bool {
let base = parse_sources(&self.tree, self.claims.as_deref());
let Some((new_tree, new_claims)) = self.render_candidate(diag) else {
self.fail(
diag,
"fix candidate could not be rendered onto the source text",
);
return false;
};
let cand = parse_sources(&new_tree, new_claims.as_deref());
let accept = match diag.rule {
LintRuleId::RootDialect => guard_ara001(&base, &cand),
LintRuleId::DeadEndReasonAlias => guard_alias(&base, &cand, AliasField::WhyFailed),
LintRuleId::DecisionRationaleAlias => guard_alias(&base, &cand, AliasField::Rationale),
LintRuleId::ClaimHeaderStyle => {
self.guard_ara004(diag, &base, &cand, new_claims.as_deref(), &new_tree)
}
};
if !accept {
self.fail(diag, guard_reason(diag.rule));
return false;
}
let recheck = check_sources(&new_tree, new_claims.as_deref());
let line = diag_line(diag);
if recheck
.diagnostics()
.iter()
.any(|d| d.rule == diag.rule && diag_line(d) == line)
{
self.fail(diag, "fix did not eliminate the drift (non-idempotent)");
return false;
}
self.tree = new_tree;
self.claims = new_claims;
self.applied.push(AppliedFix {
rule: diag.rule,
file: diag.file,
description: applied_desc(diag.rule),
});
true
}
fn render_candidate(&self, diag: &LintDiagnostic) -> Option<(String, Option<String>)> {
let fix = diag.fix.as_ref()?;
match diag.file {
LintFile::Tree => Some((apply_fix_to_text(&self.tree, fix)?, self.claims.clone())),
LintFile::Claims => {
let claims = self.claims.as_deref()?;
Some((self.tree.clone(), Some(apply_fix_to_text(claims, fix)?)))
}
}
}
fn guard_ara004(
&self,
diag: &LintDiagnostic,
base: &ParseResult,
cand: &ParseResult,
new_claims: Option<&str>,
new_tree: &str,
) -> bool {
let Ok((mc, _)) = cand else {
return false;
};
if !errors_subset(cand, base) {
return false;
}
let Some(base_claims) = claims_only(self.claims.as_deref()) else {
return false;
};
let Some((rec_id, rec_title)) = header_at(new_claims, diag_line(diag)) else {
return false;
};
if base_claims.iter().any(|c| c.id.as_str() == rec_id) {
return false;
}
let Some(rc) = mc.claims.iter().find(|c| c.id.as_str() == rec_id) else {
return false;
};
if rc.title != rec_title {
return false;
}
let mc_minus: Vec<Claim> = mc
.claims
.iter()
.filter(|c| c.id.as_str() != rec_id)
.cloned()
.collect();
if mc_minus != base_claims {
return false;
}
let Ok((tb, _)) = parse_sources(new_tree, None) else {
return false;
};
mc.nodes == tb.nodes && mc.links == tb.links
}
fn is_failed(&self, diag: &LintDiagnostic) -> bool {
let key = (diag.rule, diag.file, diag_line(diag));
self.failed.iter().any(|(r, f, l, _)| (*r, *f, *l) == key)
}
fn fail(&mut self, diag: &LintDiagnostic, reason: impl Into<String>) {
if !self.is_failed(diag) {
self.failed
.push((diag.rule, diag.file, diag_line(diag), reason.into()));
}
}
fn reason_for(&self, diag: &LintDiagnostic) -> String {
let key = (diag.rule, diag.file, diag_line(diag));
self.failed
.iter()
.find(|(r, f, l, _)| (*r, *f, *l) == key)
.map(|(_, _, _, reason)| reason.clone())
.unwrap_or_else(|| guard_reason(diag.rule))
}
}
fn guard_ara001(base: &ParseResult, cand: &ParseResult) -> bool {
match (base, cand) {
(Ok((mb, _)), Ok((mc, _))) => mc == mb,
_ => false,
}
}
fn guard_alias(base: &ParseResult, cand: &ParseResult, field: AliasField) -> bool {
let (Ok((mb, _)), Ok((mc, _))) = (base, cand) else {
return false;
};
if mc.nodes.len() != mb.nodes.len() {
return false;
}
if mb.nodes.iter().zip(&mc.nodes).any(|(a, b)| a.id != b.id) {
return false;
}
let diffs: Vec<usize> = (0..mb.nodes.len())
.filter(|&i| mb.nodes[i] != mc.nodes[i])
.collect();
if diffs.len() != 1 {
return false;
}
let i = diffs[0];
if field_is_some(&mb.nodes[i], field) || !field_is_some(&mc.nodes[i], field) {
return false;
}
let mut mc2 = (*mc).clone();
clear_field(&mut mc2.nodes[i], field);
mc2 == *mb
}
fn field_is_some(node: &Node, field: AliasField) -> bool {
match (field, &node.fields) {
(AliasField::WhyFailed, NodeFields::DeadEnd { why_failed, .. }) => why_failed.is_some(),
(AliasField::Rationale, NodeFields::Decision { rationale, .. }) => rationale.is_some(),
_ => false,
}
}
fn clear_field(node: &mut Node, field: AliasField) {
match (field, &mut node.fields) {
(AliasField::WhyFailed, NodeFields::DeadEnd { why_failed, .. }) => *why_failed = None,
(AliasField::Rationale, NodeFields::Decision { rationale, .. }) => *rationale = None,
_ => {}
}
}
fn errors_subset(cand: &ParseResult, base: &ParseResult) -> bool {
let be = errors_of(base);
errors_of(cand).iter().all(|e| be.contains(e))
}
fn errors_of(result: &ParseResult) -> &[Diagnostic] {
match result {
Ok((_, report)) => report.errors(),
Err(report) => report.errors(),
}
}
fn claims_only(claims: Option<&str>) -> Option<Vec<Claim>> {
match parse_sources("tree: []\n", claims) {
Ok((m, _)) => Some(m.claims),
Err(_) => None,
}
}
fn apply_fix_to_text(text: &str, fix: &FixCandidate) -> Option<String> {
match fix {
FixCandidate::ReplaceInLine {
line,
start_col,
end_col,
replacement,
} => apply_replace_in_line(text, *line, *start_col, *end_col, replacement),
FixCandidate::RewriteRootToTree {
root_line,
root_indent,
block_end_line,
} => apply_root_to_tree(text, *root_line, *root_indent, *block_end_line),
}
}
fn apply_replace_in_line(
text: &str,
line: usize,
start: usize,
end: usize,
repl: &str,
) -> Option<String> {
let mut segs: Vec<String> = text.split('\n').map(str::to_string).collect();
let seg = segs.get_mut(line)?;
if start > end || end > seg.len() || !seg.is_char_boundary(start) || !seg.is_char_boundary(end)
{
return None;
}
seg.replace_range(start..end, repl);
Some(segs.join("\n"))
}
fn apply_root_to_tree(
text: &str,
root_line: usize,
root_indent: usize,
block_end_line: usize,
) -> Option<String> {
let mut segs: Vec<String> = text.split('\n').map(str::to_string).collect();
if root_line >= segs.len() || block_end_line > segs.len() || block_end_line <= root_line {
return None;
}
{
let seg = &mut segs[root_line];
let end = root_indent + "root".len();
if end > seg.len() || !seg.is_char_boundary(root_indent) || &seg[root_indent..end] != "root"
{
return None;
}
seg.replace_range(root_indent..end, "tree");
}
let mut first_seen = false;
for seg in segs.iter_mut().take(block_end_line).skip(root_line + 1) {
if seg.trim().is_empty() {
continue;
}
if first_seen {
seg.insert_str(0, " ");
} else {
first_seen = true;
let ws = leading_spaces(seg);
seg.insert_str(ws, "- ");
}
}
Some(segs.join("\n"))
}
fn leading_spaces(s: &str) -> usize {
s.len() - s.trim_start_matches(' ').len()
}
fn header_at(claims: Option<&str>, line: usize) -> Option<(String, String)> {
let l = claims?.split('\n').nth(line)?;
let rest = l.trim_start().strip_prefix("## ")?;
let (raw_id, raw_title) = rest.split_once(':')?;
let id = raw_id.trim();
if !is_canonical_id(id, 'C') {
return None;
}
let title = raw_title.trim();
if title.is_empty() {
return None;
}
Some((id.to_string(), title.to_string()))
}
fn diag_line(diag: &LintDiagnostic) -> usize {
match &diag.fix {
Some(FixCandidate::ReplaceInLine { line, .. }) => *line,
Some(FixCandidate::RewriteRootToTree { root_line, .. }) => *root_line,
None => usize::MAX,
}
}
fn applied_desc(rule: LintRuleId) -> String {
match rule {
LintRuleId::RootDialect => {
"rewrote top-level `root:` single node into a one-element `tree:` list".to_string()
}
LintRuleId::DeadEndReasonAlias => {
"renamed `reason:` to `why_failed:` on a dead_end node".to_string()
}
LintRuleId::DecisionRationaleAlias => {
"renamed `justification:` to `rationale:` on a decision node".to_string()
}
LintRuleId::ClaimHeaderStyle => "rewrote dash claim-header separator to `: `".to_string(),
}
}
fn guard_reason(rule: LintRuleId) -> String {
match rule {
LintRuleId::RootDialect => {
"root→tree rewrite would change the parsed manifest; left unchanged".to_string()
}
LintRuleId::DeadEndReasonAlias | LintRuleId::DecisionRationaleAlias => {
"alias rename would change more than the recovered field; left unchanged".to_string()
}
LintRuleId::ClaimHeaderStyle => {
"claim-header rewrite would change more than the recovered claim; left unchanged"
.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::NodeId;
fn artifact(tree_yaml: &str, claims_md: Option<&str>) -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("trace")).unwrap();
std::fs::write(dir.path().join("trace/exploration_tree.yaml"), tree_yaml).unwrap();
if let Some(claims) = claims_md {
std::fs::create_dir_all(dir.path().join("logic")).unwrap();
std::fs::write(dir.path().join("logic/claims.md"), claims).unwrap();
}
dir
}
fn read_tree(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("trace/exploration_tree.yaml")).unwrap()
}
fn read_claims(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("logic/claims.md")).unwrap()
}
#[test]
fn ara001_root_rewritten_to_tree_preserving_manifest() {
let yaml = "\
root:
id: N01
type: question
title: q
children:
- id: N02
type: experiment
result: 28.4 BLEU
";
let before = parse_sources(yaml, None).expect("root parses").0;
let dir = artifact(yaml, None);
let outcome = fix_dir(dir.path());
assert_eq!(outcome.applied.len(), 1);
assert_eq!(outcome.applied[0].rule, LintRuleId::RootDialect);
assert_eq!(outcome.changed_files, vec![LintFile::Tree]);
assert!(outcome.remaining.is_empty());
let after_text = read_tree(&dir);
assert!(after_text.starts_with("tree:\n"), "got: {after_text}");
let after = parse_sources(&after_text, None)
.expect("rewritten parses")
.0;
assert_eq!(before.nodes, after.nodes);
assert_eq!(before.links, after.links);
assert_eq!(before, after);
}
#[test]
fn ara001_expected_reindented_text() {
let yaml = "root:\n id: RQ\n type: question\n children:\n - id: N02\n";
let dir = artifact(yaml, None);
fix_dir(dir.path());
assert_eq!(
read_tree(&dir),
"tree:\n - id: RQ\n type: question\n children:\n - id: N02\n"
);
}
#[test]
fn ara001_guard_discards_when_manifest_would_differ() {
let base = parse_sources("tree:\n - id: N01\n type: question\n", None);
let different = parse_sources("tree:\n - id: N99\n type: question\n", None);
let same = parse_sources("tree:\n - id: N01\n type: question\n", None);
assert!(!guard_ara001(&base, &different));
assert!(guard_ara001(&base, &same));
}
#[test]
fn ara002_reason_recovered_as_why_failed() {
let yaml = "\
tree:
- id: N01
type: dead_end
reason: it diverged
";
let dir = artifact(yaml, None);
let outcome = fix_dir(dir.path());
assert_eq!(outcome.applied.len(), 1);
assert_eq!(outcome.applied[0].rule, LintRuleId::DeadEndReasonAlias);
assert!(read_tree(&dir).contains("why_failed: it diverged"));
let (m, _) = parse_sources(&read_tree(&dir), None).expect("ok");
match &m.nodes[0].fields {
NodeFields::DeadEnd { why_failed, .. } => {
assert_eq!(why_failed.as_deref(), Some("it diverged"));
}
other => panic!("expected DeadEnd fields, got {other:?}"),
}
}
#[test]
fn ara003_justification_recovered_as_rationale() {
let yaml = "\
tree:
- id: N01
type: decision
justification: cheaper to train
";
let dir = artifact(yaml, None);
let outcome = fix_dir(dir.path());
assert_eq!(outcome.applied.len(), 1);
assert_eq!(outcome.applied[0].rule, LintRuleId::DecisionRationaleAlias);
let (m, _) = parse_sources(&read_tree(&dir), None).expect("ok");
match &m.nodes[0].fields {
NodeFields::Decision { rationale, .. } => {
assert_eq!(rationale.as_deref(), Some("cheaper to train"));
}
other => panic!("expected Decision fields, got {other:?}"),
}
}
#[test]
fn alias_guard_discards_multi_node_change() {
let base = parse_sources(
"tree:\n - id: N01\n type: dead_end\n - id: N02\n type: dead_end\n",
None,
);
let cand = parse_sources(
"tree:\n - id: N01\n type: dead_end\n why_failed: a\n - id: N02\n type: dead_end\n why_failed: b\n",
None,
);
assert!(!guard_alias(&base, &cand, AliasField::WhyFailed));
let base1 = parse_sources("tree:\n - id: N01\n type: dead_end\n", None);
let cand1 = parse_sources(
"tree:\n - id: N01\n type: dead_end\n why_failed: a\n",
None,
);
assert!(guard_alias(&base1, &cand1, AliasField::WhyFailed));
}
#[test]
fn ara004_dash_header_recovers_claim() {
let yaml = "tree:\n - id: N01\n type: question\n";
let claims = "## C01 — Attention is all you need\n- **Statement**: yes\n";
let dir = artifact(yaml, Some(claims));
let before = parse_sources(yaml, Some(claims)).expect("ok").0;
assert!(before.claims.is_empty(), "dash header must not parse today");
let outcome = fix_dir(dir.path());
assert_eq!(outcome.applied.len(), 1);
assert_eq!(outcome.applied[0].rule, LintRuleId::ClaimHeaderStyle);
assert_eq!(outcome.changed_files, vec![LintFile::Claims]);
let after_claims = read_claims(&dir);
assert!(after_claims.starts_with("## C01: Attention is all you need\n"));
let (m, _) = parse_sources(&read_tree(&dir), Some(&after_claims)).expect("ok");
assert_eq!(m.claims.len(), 1);
assert_eq!(m.claims[0].id, crate::manifest::ClaimId::new("C01"));
assert_eq!(m.claims[0].title, "Attention is all you need");
}
#[test]
fn ara004_recovers_referenced_claim_and_resolves_dangling_error() {
let yaml = "\
tree:
- id: N01
type: experiment
evidence: [C01]
";
let claims = "## C01 - Faster training\n- **Statement**: yes\n";
let dir = artifact(yaml, Some(claims));
assert!(
parse_sources(yaml, Some(claims)).is_err(),
"dangling C01 must error before the fix"
);
let outcome = fix_dir(dir.path());
assert_eq!(outcome.applied.len(), 1);
assert_eq!(outcome.applied[0].rule, LintRuleId::ClaimHeaderStyle);
let (m, report) =
parse_sources(&read_tree(&dir), Some(&read_claims(&dir))).expect("ok now");
assert!(report.is_ok());
assert_eq!(m.claims.len(), 1);
assert_eq!(m.bindings.len(), 1);
assert_eq!(m.bindings[0].claim, crate::manifest::ClaimId::new("C01"));
}
#[test]
fn fix_dir_is_idempotent() {
let yaml = "\
root:
id: N01
type: question
children:
- id: N02
type: dead_end
reason: diverged
- id: N03
type: decision
justification: cheaper
";
let claims = "## C01 — A claim\n- **Statement**: yes\n";
let dir = artifact(yaml, Some(claims));
let first = fix_dir(dir.path());
assert!(!first.applied.is_empty());
let tree_after_first = read_tree(&dir);
let claims_after_first = read_claims(&dir);
let second = fix_dir(dir.path());
assert!(
second.applied.is_empty(),
"second run must apply nothing, got: {:?}",
second.applied
);
assert!(second.changed_files.is_empty());
assert_eq!(
read_tree(&dir),
tree_after_first,
"tree must be byte-identical"
);
assert_eq!(
read_claims(&dir),
claims_after_first,
"claims must be byte-identical"
);
}
#[test]
fn discarded_fix_leaves_file_unchanged_and_parseable() {
let yaml = "\
tree:
- id: N01
type: dead_end
reason: x
- id: N01
type: insight
";
let dir = artifact(yaml, None);
let outcome = fix_dir(dir.path());
assert!(outcome.applied.is_empty());
assert!(outcome.changed_files.is_empty());
assert!(
outcome
.skipped
.iter()
.any(|s| s.rule == LintRuleId::DeadEndReasonAlias)
);
assert_eq!(read_tree(&dir), yaml, "file must be untouched");
assert_eq!(read_tree(&dir).lines().count(), yaml.lines().count());
}
#[test]
fn happy_path_reports_no_write_errors() {
let yaml = "root:\n id: N01\n type: question\n";
let dir = artifact(yaml, None);
let outcome = fix_dir(dir.path());
assert!(!outcome.applied.is_empty());
assert!(
outcome.errors.is_empty(),
"clean write must record no errors"
);
assert!(!outcome.has_errors());
}
#[cfg(unix)]
#[test]
fn write_failure_is_surfaced_in_errors() {
use std::os::unix::fs::PermissionsExt;
let yaml = "root:\n id: N01\n type: question\n";
let dir = artifact(yaml, None);
let tree_path = dir.path().join("trace/exploration_tree.yaml");
let mut perms = std::fs::metadata(&tree_path).unwrap().permissions();
perms.set_mode(0o444);
std::fs::set_permissions(&tree_path, perms).unwrap();
if std::fs::OpenOptions::new()
.write(true)
.open(&tree_path)
.is_ok()
{
eprintln!("skipping: write not denied (likely running as root)");
return;
}
let outcome = fix_dir(dir.path());
assert!(outcome.has_errors());
assert!(
outcome.errors.iter().any(|(f, _)| *f == LintFile::Tree),
"tree write failure must be surfaced, got: {:?}",
outcome.errors
);
assert!(!outcome.changed_files.contains(&LintFile::Tree));
assert_eq!(read_tree(&dir), yaml, "on-disk file must be untouched");
let mut perms = std::fs::metadata(&tree_path).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&tree_path, perms).unwrap();
}
#[test]
fn clean_artifact_is_a_noop() {
let yaml = "tree:\n - id: N01\n type: question\n";
let dir = artifact(yaml, None);
let outcome = fix_dir(dir.path());
assert!(outcome.is_noop());
assert!(outcome.applied.is_empty());
assert!(outcome.skipped.is_empty());
assert_eq!(read_tree(&dir), yaml);
}
#[test]
fn combined_ara001_and_alias_fixes_both_apply() {
let yaml = "\
root:
id: N01
type: question
children:
- id: N02
type: dead_end
reason: diverged
";
let dir = artifact(yaml, None);
let outcome = fix_dir(dir.path());
let rules: Vec<LintRuleId> = outcome.applied.iter().map(|a| a.rule).collect();
assert!(rules.contains(&LintRuleId::RootDialect));
assert!(rules.contains(&LintRuleId::DeadEndReasonAlias));
assert!(outcome.remaining.is_empty());
let (m, _) = parse_sources(&read_tree(&dir), None).expect("ok");
assert_eq!(m.nodes[0].id, NodeId::new("N01"));
match &m.nodes[1].fields {
NodeFields::DeadEnd { why_failed, .. } => {
assert_eq!(why_failed.as_deref(), Some("diverged"));
}
other => panic!("expected DeadEnd, got {other:?}"),
}
}
}