Skip to main content

ara_core/
fix.rs

1//! Format-fix applier: turns the drift [`crate::lint`] detects into in-place
2//! rewrites of `trace/exploration_tree.yaml` / `logic/claims.md`, but applies an
3//! edit only after a per-rule **safety guard** proves it is semantically sound.
4//!
5//! # Design
6//!
7//! All edits and guard checks happen **in memory** on the file text; a file is
8//! written to disk only once its edits pass the guard, so a rejected fix can
9//! never leave a corrupted source file.
10//!
11//! The applier runs a **fixpoint loop**: detect → pick one fixable candidate →
12//! apply it to a copy of the text → re-parse and guard → commit or discard →
13//! re-detect on the (possibly edited) text and repeat. Running one candidate per
14//! iteration and re-detecting from scratch is what gives idempotence and lets
15//! later rules see the byte-offset / line shifts an earlier fix produced (e.g.
16//! an ARA001 root→tree rewrite re-indents the block, shifting the columns
17//! ARA002/ARA003 point at).
18//!
19//! # Guards
20//!
21//! Guards re-parse the base and candidate with [`parse_sources_detailed`], which
22//! retains normalized manifests even when semantic errors remain. Fatal syntax
23//! or top-level-shape outcomes are always rejected. The common invariant for
24//! value recovery is: the candidate introduces no new error occurrences, and
25//! the manifest delta is exactly the recovery targeted by the rule.
26//!
27//! - **ARA001** (structural, semantic no-op): accept only clean normalized
28//!   outcomes with an *unchanged* manifest (`mc == mb`). This protects the
29//!   re-indent and deliberately remains stricter than the recovering guards.
30//! - **ARA002 / ARA003 / ARA005–ARA007** (alias rename, value-recovering):
31//!   after diagnostic containment, accept only if one node's target field goes
32//!   `None → Some` and clearing that field reproduces the base manifest
33//!   exactly.
34//! - **ARA004** (claim-header rewrite, value-recovering): after diagnostic
35//!   containment, accept only if one header-matching claim appears, nodes and
36//!   links remain identical, and removing that claim plus its bindings
37//!   reproduces the base claim and binding sets exactly.
38//!
39//! When a guard is ambiguous for an edge case the applier prefers the **safe**
40//! choice — discard and report the drift as detected-but-not-applied.
41
42use std::path::Path;
43
44use serde::Serialize;
45
46use crate::lint::{FixCandidate, LintDiagnostic, LintFile, LintReport, LintRuleId, check_sources};
47use crate::manifest::{Node, NodeFields, is_canonical_id};
48use crate::parse::{ParseOutcome, parse_sources_detailed};
49use crate::report::Diagnostic;
50
51/// Safety backstop on the fixpoint loop. Each iteration applies or discards
52/// exactly one candidate; applies only ever *reduce* the remaining drift, so a
53/// real artifact terminates far below this bound.
54const MAX_ITERS: usize = 1000;
55
56/// A fix that was applied to a source file.
57#[derive(Debug, Clone, PartialEq, Serialize)]
58pub struct AppliedFix {
59    /// The rule whose drift was fixed.
60    pub rule: LintRuleId,
61    /// The file that was edited.
62    pub file: LintFile,
63    /// Short human-readable description of the edit.
64    pub description: String,
65}
66
67/// A fixable drift that was detected but deliberately **not** applied, because
68/// the safety guard rejected the edit (or it could not be rendered).
69#[derive(Debug, Clone, PartialEq, Serialize)]
70pub struct SkippedFix {
71    /// The rule whose drift was left in place.
72    pub rule: LintRuleId,
73    /// The file the drift lives in.
74    pub file: LintFile,
75    /// Why the fix was not applied.
76    pub reason: String,
77}
78
79/// The outcome of a [`fix_dir`] pass.
80#[derive(Debug, Clone, PartialEq, Serialize)]
81pub struct FixOutcome {
82    /// Fixes that were applied, in application order.
83    pub applied: Vec<AppliedFix>,
84    /// Fixable drift detected but discarded by a guard, with the reason.
85    pub skipped: Vec<SkippedFix>,
86    /// Format-lint report re-run on the post-fix text (what still remains).
87    ///
88    /// This reflects the **in-memory** post-fix text. When `errors` is non-empty
89    /// an intended write did not reach disk, so for those files the on-disk drift
90    /// still stands even though `remaining` shows it resolved — callers must treat
91    /// a non-empty `errors` as a failure (the CLI keys exit code 2 off it) rather
92    /// than trusting `remaining`/`applied` for the un-written files.
93    pub remaining: LintReport,
94    /// The files that were actually rewritten on disk.
95    pub changed_files: Vec<LintFile>,
96    /// I/O failures while writing fixes back: `(file, error message)`. Non-empty
97    /// ⇔ at least one intended write did not reach disk.
98    pub errors: Vec<(LintFile, String)>,
99}
100
101impl FixOutcome {
102    /// True when nothing was applied and no file changed.
103    pub fn is_noop(&self) -> bool {
104        self.applied.is_empty() && self.changed_files.is_empty()
105    }
106
107    /// True when an intended write failed to reach disk.
108    pub fn has_errors(&self) -> bool {
109        !self.errors.is_empty()
110    }
111}
112
113/// Detects fixable drift in the ARA artifact at `dir`, applies the **safe** fixes
114/// to `trace/exploration_tree.yaml` / `logic/claims.md` in place, and returns a
115/// [`FixOutcome`]. Native only.
116///
117/// Edits and guard validation run entirely in memory; a file is written only
118/// after its edits pass, so a rejected fix never corrupts a source file. Running
119/// `fix_dir` twice is a no-op the second time (idempotent).
120pub fn fix_dir(dir: &Path) -> FixOutcome {
121    let tree_path = dir.join("trace/exploration_tree.yaml");
122    let claims_path = dir.join("logic/claims.md");
123    let orig_tree = std::fs::read_to_string(&tree_path).unwrap_or_default();
124    let orig_claims = std::fs::read_to_string(&claims_path).ok();
125
126    let mut applier = Applier::new(orig_tree.clone(), orig_claims.clone());
127    applier.run();
128
129    // Write back only the files that actually changed. A successful write is
130    // recorded in `changed_files`; a failed write is recorded in `errors` so the
131    // caller never mistakes an un-written file for clean.
132    let mut changed_files = Vec::new();
133    let mut errors = Vec::new();
134    if applier.tree != orig_tree {
135        match std::fs::write(&tree_path, &applier.tree) {
136            Ok(()) => changed_files.push(LintFile::Tree),
137            Err(e) => errors.push((LintFile::Tree, e.to_string())),
138        }
139    }
140    if let Some(new_claims) = &applier.claims
141        && orig_claims.as_deref() != Some(new_claims.as_str())
142    {
143        match std::fs::write(&claims_path, new_claims) {
144            Ok(()) => changed_files.push(LintFile::Claims),
145            Err(e) => errors.push((LintFile::Claims, e.to_string())),
146        }
147    }
148
149    // Re-detect on the final in-memory text: what remains is exactly the fixable
150    // drift we chose not to apply (applied fixes are gone), so the skip list is
151    // built directly from it, annotated with the reason recorded during the run.
152    let remaining = check_sources(&applier.tree, applier.claims.as_deref());
153    let skipped = remaining
154        .diagnostics()
155        .iter()
156        .filter(|d| d.fixable)
157        .map(|d| SkippedFix {
158            rule: d.rule,
159            file: d.file,
160            reason: applier.reason_for(d),
161        })
162        .collect();
163
164    FixOutcome {
165        applied: applier.applied,
166        skipped,
167        remaining,
168        changed_files,
169        errors,
170    }
171}
172
173/// Which recovering alias field a targeted guard is validating.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175enum AliasField {
176    /// ARA002: `dead_end.why_failed`.
177    WhyFailed,
178    /// ARA003: `decision.rationale`.
179    Rationale,
180    /// ARA005: `pivot.prior_direction` (alias `from:`).
181    PriorDirection,
182    /// ARA006: `pivot.new_direction` (alias `to:`).
183    NewDirection,
184    /// ARA007: `pivot.reason` (alias `trigger:`).
185    PivotReason,
186}
187
188/// In-memory applier state driving the fixpoint loop.
189struct Applier {
190    /// Current `exploration_tree.yaml` text.
191    tree: String,
192    /// Current `claims.md` text, when the file exists.
193    claims: Option<String>,
194    /// Applied fixes, in order.
195    applied: Vec<AppliedFix>,
196    /// Candidates rejected in the current pass: `(rule, file, line, reason)`. Line
197    /// numbers are stable across every fix kind (all edits are single-line or
198    /// keep the line count), so `(rule, file, line)` uniquely keys a candidate.
199    /// Cleared whenever a fix is applied, so previously-rejected candidates get
200    /// re-evaluated against the new state (e.g. an ARA004 claim recovery may
201    /// resolve the error that had blocked an ARA002 rename).
202    failed: Vec<(LintRuleId, LintFile, usize, String)>,
203}
204
205impl Applier {
206    fn new(tree: String, claims: Option<String>) -> Self {
207        Self {
208            tree,
209            claims,
210            applied: Vec::new(),
211            failed: Vec::new(),
212        }
213    }
214
215    /// Runs the detect → apply/discard → re-detect fixpoint to completion.
216    fn run(&mut self) {
217        for _ in 0..MAX_ITERS {
218            let report = check_sources(&self.tree, self.claims.as_deref());
219            let Some(diag) = report
220                .diagnostics()
221                .iter()
222                .find(|d| d.fixable && d.fix.is_some() && !self.is_failed(d))
223                .cloned()
224            else {
225                break;
226            };
227            if self.step(&diag) {
228                // A fix landed: text (and thus the parse baseline) changed, so
229                // reconsider anything we had rejected earlier.
230                self.failed.clear();
231            }
232        }
233    }
234
235    /// Attempts one candidate. Returns `true` iff it was applied.
236    fn step(&mut self, diag: &LintDiagnostic) -> bool {
237        let base = parse_sources_detailed(&self.tree, self.claims.as_deref());
238        let Some((new_tree, new_claims)) = self.render_candidate(diag) else {
239            self.fail(
240                diag,
241                "fix candidate could not be rendered onto the source text",
242            );
243            return false;
244        };
245        let cand = parse_sources_detailed(&new_tree, new_claims.as_deref());
246
247        let accept = match diag.rule {
248            LintRuleId::RootDialect => guard_ara001(&base, &cand),
249            LintRuleId::DeadEndReasonAlias => guard_alias(&base, &cand, AliasField::WhyFailed),
250            LintRuleId::DecisionRationaleAlias => guard_alias(&base, &cand, AliasField::Rationale),
251            LintRuleId::PivotFromAlias => guard_alias(&base, &cand, AliasField::PriorDirection),
252            LintRuleId::PivotToAlias => guard_alias(&base, &cand, AliasField::NewDirection),
253            LintRuleId::PivotTriggerAlias => guard_alias(&base, &cand, AliasField::PivotReason),
254            LintRuleId::ClaimHeaderStyle => {
255                self.guard_ara004(diag, &base, &cand, new_claims.as_deref())
256            }
257        };
258        if !accept {
259            self.fail(diag, guard_rejection_reason(diag.rule, &base, &cand));
260            return false;
261        }
262
263        // Idempotence backstop: the same drift must not survive at this line, or
264        // the loop could re-detect and re-apply it forever.
265        let recheck = check_sources(&new_tree, new_claims.as_deref());
266        let line = diag_line(diag);
267        if recheck
268            .diagnostics()
269            .iter()
270            .any(|d| d.rule == diag.rule && diag_line(d) == line)
271        {
272            self.fail(diag, "fix did not eliminate the drift (non-idempotent)");
273            return false;
274        }
275
276        self.tree = new_tree;
277        self.claims = new_claims;
278        self.applied.push(AppliedFix {
279            rule: diag.rule,
280            file: diag.file,
281            description: applied_desc(diag.rule),
282        });
283        true
284    }
285
286    /// Renders `diag`'s fix candidate onto the current text, returning the edited
287    /// `(tree, claims)` pair. `None` if the offsets don't fit the text.
288    fn render_candidate(&self, diag: &LintDiagnostic) -> Option<(String, Option<String>)> {
289        let fix = diag.fix.as_ref()?;
290        match diag.file {
291            LintFile::Tree => Some((apply_fix_to_text(&self.tree, fix)?, self.claims.clone())),
292            LintFile::Claims => {
293                let claims = self.claims.as_deref()?;
294                Some((self.tree.clone(), Some(apply_fix_to_text(claims, fix)?)))
295            }
296        }
297    }
298
299    /// ARA004 targeted guard: no new error occurrence may appear, and removing
300    /// the one recovered header-matching claim plus every binding to it must
301    /// reproduce the base claims and bindings exactly. Nodes and links cannot
302    /// change.
303    fn guard_ara004(
304        &self,
305        diag: &LintDiagnostic,
306        base: &ParseOutcome,
307        cand: &ParseOutcome,
308        new_claims: Option<&str>,
309    ) -> bool {
310        let (ParseOutcome::Normalized(mb, _), ParseOutcome::Normalized(mc, _)) = (base, cand)
311        else {
312            return false;
313        };
314        if !errors_subset(cand, base) {
315            return false;
316        }
317
318        let Some((rec_id, rec_title)) = header_at(new_claims, diag_line(diag)) else {
319            return false;
320        };
321
322        // Genuine targeted recovery: the header id was absent before and occurs
323        // after the edit with exactly the title rendered on that header.
324        if mb.claims.iter().any(|claim| claim.id.as_str() == rec_id) {
325            return false;
326        }
327        let Some(recovered_index) = mc
328            .claims
329            .iter()
330            .position(|claim| claim.id.as_str() == rec_id)
331        else {
332            return false;
333        };
334        if mc.claims[recovered_index].title != rec_title {
335            return false;
336        }
337
338        let mut claims_without_recovered = mc.claims.clone();
339        claims_without_recovered.remove(recovered_index);
340        if claims_without_recovered != mb.claims {
341            return false;
342        }
343        if mc.nodes != mb.nodes || mc.links != mb.links {
344            return false;
345        }
346
347        let mut bindings_without_recovered = mc.bindings.clone();
348        bindings_without_recovered.retain(|binding| binding.claim.as_str() != rec_id);
349        bindings_without_recovered == mb.bindings
350    }
351
352    /// True if `diag`'s candidate was already rejected in the current pass.
353    fn is_failed(&self, diag: &LintDiagnostic) -> bool {
354        let key = (diag.rule, diag.file, diag_line(diag));
355        self.failed.iter().any(|(r, f, l, _)| (*r, *f, *l) == key)
356    }
357
358    /// Records a rejection reason for `diag` (first reason per candidate wins).
359    fn fail(&mut self, diag: &LintDiagnostic, reason: impl Into<String>) {
360        if !self.is_failed(diag) {
361            self.failed
362                .push((diag.rule, diag.file, diag_line(diag), reason.into()));
363        }
364    }
365
366    /// Looks up the recorded rejection reason for a remaining diagnostic, falling
367    /// back to a generic per-rule reason.
368    fn reason_for(&self, diag: &LintDiagnostic) -> String {
369        let key = (diag.rule, diag.file, diag_line(diag));
370        self.failed
371            .iter()
372            .find(|(r, f, l, _)| (*r, *f, *l) == key)
373            .map(|(_, _, _, reason)| reason.clone())
374            .unwrap_or_else(|| guard_reason(diag.rule))
375    }
376}
377
378// ---- guards ---------------------------------------------------------------
379
380/// ARA001 structural guard: both parses must be clean normalized outcomes and
381/// the root→tree rewrite must be a semantic no-op.
382fn guard_ara001(base: &ParseOutcome, cand: &ParseOutcome) -> bool {
383    match (base, cand) {
384        (ParseOutcome::Normalized(mb, rb), ParseOutcome::Normalized(mc, rc)) => {
385            rb.is_ok() && rc.is_ok() && mc == mb
386        }
387        _ => false,
388    }
389}
390
391/// ARA002/ARA003/ARA005–ARA007 targeted guard: after proving no new error
392/// occurrence appears, exactly one node's target field goes `None → Some`, and
393/// nothing else differs.
394fn guard_alias(base: &ParseOutcome, cand: &ParseOutcome, field: AliasField) -> bool {
395    let (ParseOutcome::Normalized(mb, _), ParseOutcome::Normalized(mc, _)) = (base, cand) else {
396        return false;
397    };
398    if !errors_subset(cand, base) {
399        return false;
400    }
401    if mc.nodes.len() != mb.nodes.len() {
402        return false;
403    }
404    if mb.nodes.iter().zip(&mc.nodes).any(|(a, b)| a.id != b.id) {
405        return false;
406    }
407
408    let diffs: Vec<usize> = (0..mb.nodes.len())
409        .filter(|&i| mb.nodes[i] != mc.nodes[i])
410        .collect();
411    if diffs.len() != 1 {
412        return false;
413    }
414    let i = diffs[0];
415
416    // The value must be recovered: `None` in base, `Some` in cand.
417    if field_is_some(&mb.nodes[i], field) || !field_is_some(&mc.nodes[i], field) {
418        return false;
419    }
420
421    // Resetting that one recovered field to `None` must reproduce base exactly —
422    // proof that nothing else moved and the value landed in the right place.
423    let mut mc2 = mc.clone();
424    clear_field(&mut mc2.nodes[i], field);
425    mc2 == *mb
426}
427
428/// True when `node`'s `field` is populated.
429fn field_is_some(node: &Node, field: AliasField) -> bool {
430    match (field, &node.fields) {
431        (AliasField::WhyFailed, NodeFields::DeadEnd { why_failed, .. }) => why_failed.is_some(),
432        (AliasField::Rationale, NodeFields::Decision { rationale, .. }) => rationale.is_some(),
433        (
434            AliasField::PriorDirection,
435            NodeFields::Pivot {
436                prior_direction, ..
437            },
438        ) => prior_direction.is_some(),
439        (AliasField::NewDirection, NodeFields::Pivot { new_direction, .. }) => {
440            new_direction.is_some()
441        }
442        (AliasField::PivotReason, NodeFields::Pivot { reason, .. }) => reason.is_some(),
443        _ => false,
444    }
445}
446
447/// Clears `node`'s `field` (no-op if the node isn't the matching kind).
448fn clear_field(node: &mut Node, field: AliasField) {
449    match (field, &mut node.fields) {
450        (AliasField::WhyFailed, NodeFields::DeadEnd { why_failed, .. }) => *why_failed = None,
451        (AliasField::Rationale, NodeFields::Decision { rationale, .. }) => *rationale = None,
452        (
453            AliasField::PriorDirection,
454            NodeFields::Pivot {
455                prior_direction, ..
456            },
457        ) => *prior_direction = None,
458        (AliasField::NewDirection, NodeFields::Pivot { new_direction, .. }) => {
459            *new_direction = None
460        }
461        (AliasField::PivotReason, NodeFields::Pivot { reason, .. }) => *reason = None,
462        _ => {}
463    }
464}
465
466/// True iff every distinct error occurs no more often in `cand` than in `base`.
467/// Each repeated candidate occurrence must have a matching base occurrence.
468fn errors_subset(cand: &ParseOutcome, base: &ParseOutcome) -> bool {
469    let candidate_errors = errors_of(cand);
470    let base_errors = errors_of(base);
471
472    for (index, error) in candidate_errors.iter().enumerate() {
473        if candidate_errors[..index].contains(error) {
474            continue;
475        }
476
477        let candidate_count = candidate_errors[index..]
478            .iter()
479            .filter(|other| *other == error)
480            .count();
481        let base_count = base_errors.iter().filter(|other| *other == error).count();
482        if candidate_count > base_count {
483            return false;
484        }
485    }
486
487    true
488}
489
490/// Error diagnostics retained by either a normalized or fatal parse outcome.
491fn errors_of(result: &ParseOutcome) -> &[Diagnostic] {
492    match result {
493        ParseOutcome::Normalized(_, report) | ParseOutcome::Fatal(report) => report.errors(),
494    }
495}
496
497// ---- text edits -----------------------------------------------------------
498
499/// Applies a single [`FixCandidate`] to `text`, returning the edited text.
500fn apply_fix_to_text(text: &str, fix: &FixCandidate) -> Option<String> {
501    match fix {
502        FixCandidate::ReplaceInLine {
503            line,
504            start_col,
505            end_col,
506            replacement,
507        } => apply_replace_in_line(text, *line, *start_col, *end_col, replacement),
508        FixCandidate::RewriteRootToTree {
509            root_line,
510            root_indent,
511            block_end_line,
512        } => apply_root_to_tree(text, *root_line, *root_indent, *block_end_line),
513    }
514}
515
516/// Replaces the byte range `[start, end)` on 0-based `line` with `repl`.
517/// Splitting/joining on `'\n'` round-trips the exact text (including a trailing
518/// newline and any `\r` from CRLF, which sits past the edited span).
519fn apply_replace_in_line(
520    text: &str,
521    line: usize,
522    start: usize,
523    end: usize,
524    repl: &str,
525) -> Option<String> {
526    let mut segs: Vec<String> = text.split('\n').map(str::to_string).collect();
527    let seg = segs.get_mut(line)?;
528    if start > end || end > seg.len() || !seg.is_char_boundary(start) || !seg.is_char_boundary(end)
529    {
530        return None;
531    }
532    seg.replace_range(start..end, repl);
533    Some(segs.join("\n"))
534}
535
536/// Rewrites a top-level `root:` single-node map into a one-element `tree:` list:
537/// rename the key, add one indent level to every block line, and turn the first
538/// block content line into the list element with a `- ` marker.
539fn apply_root_to_tree(
540    text: &str,
541    root_line: usize,
542    root_indent: usize,
543    block_end_line: usize,
544) -> Option<String> {
545    let mut segs: Vec<String> = text.split('\n').map(str::to_string).collect();
546    if root_line >= segs.len() || block_end_line > segs.len() || block_end_line <= root_line {
547        return None;
548    }
549
550    // 1. `root` → `tree` at the key's indent.
551    {
552        let seg = &mut segs[root_line];
553        let end = root_indent + "root".len();
554        if end > seg.len() || !seg.is_char_boundary(root_indent) || &seg[root_indent..end] != "root"
555        {
556            return None;
557        }
558        seg.replace_range(root_indent..end, "tree");
559    }
560
561    // 2. Indent the block by one level; the first content line becomes the list
562    //    element (`- ` marker inserted after its existing indentation). Blank
563    //    lines are left untouched so no trailing whitespace is introduced.
564    let mut first_seen = false;
565    for seg in segs.iter_mut().take(block_end_line).skip(root_line + 1) {
566        if seg.trim().is_empty() {
567            continue;
568        }
569        if first_seen {
570            seg.insert_str(0, "  ");
571        } else {
572            first_seen = true;
573            let ws = leading_spaces(seg);
574            seg.insert_str(ws, "- ");
575        }
576    }
577
578    Some(segs.join("\n"))
579}
580
581/// Counts leading ASCII spaces.
582fn leading_spaces(s: &str) -> usize {
583    s.len() - s.trim_start_matches(' ').len()
584}
585
586/// The claim id+title of a canonical `## C\d+: title` header at 0-based `line`,
587/// mirroring the claims parser so a recovered title compares equal.
588fn header_at(claims: Option<&str>, line: usize) -> Option<(String, String)> {
589    let l = claims?.split('\n').nth(line)?;
590    let rest = l.trim_start().strip_prefix("## ")?;
591    let (raw_id, raw_title) = rest.split_once(':')?;
592    let id = raw_id.trim();
593    if !is_canonical_id(id, 'C') {
594        return None;
595    }
596    let title = raw_title.trim();
597    if title.is_empty() {
598        return None;
599    }
600    Some((id.to_string(), title.to_string()))
601}
602
603/// The 0-based source line a diagnostic's fix targets (used to key candidates).
604fn diag_line(diag: &LintDiagnostic) -> usize {
605    match &diag.fix {
606        Some(FixCandidate::ReplaceInLine { line, .. }) => *line,
607        Some(FixCandidate::RewriteRootToTree { root_line, .. }) => *root_line,
608        None => usize::MAX,
609    }
610}
611
612/// Human-readable description of an applied fix.
613fn applied_desc(rule: LintRuleId) -> String {
614    match rule {
615        LintRuleId::RootDialect => {
616            "rewrote top-level `root:` single node into a one-element `tree:` list".to_string()
617        }
618        LintRuleId::DeadEndReasonAlias => {
619            "renamed `reason:` to `why_failed:` on a dead_end node".to_string()
620        }
621        LintRuleId::DecisionRationaleAlias => {
622            "renamed `justification:` to `rationale:` on a decision node".to_string()
623        }
624        LintRuleId::ClaimHeaderStyle => "rewrote dash claim-header separator to `: `".to_string(),
625        LintRuleId::PivotFromAlias => {
626            "renamed `from:` to `prior_direction:` on a pivot node".to_string()
627        }
628        LintRuleId::PivotToAlias => "renamed `to:` to `new_direction:` on a pivot node".to_string(),
629        LintRuleId::PivotTriggerAlias => {
630            "renamed `trigger:` to `reason:` on a pivot node".to_string()
631        }
632    }
633}
634
635/// Specific reason for a guard rejection when the candidate regresses parse
636/// errors; otherwise the rule's semantic-delta reason.
637fn guard_rejection_reason(rule: LintRuleId, base: &ParseOutcome, cand: &ParseOutcome) -> String {
638    let normalized = matches!(
639        (base, cand),
640        (
641            ParseOutcome::Normalized(_, _),
642            ParseOutcome::Normalized(_, _)
643        )
644    );
645    if normalized && !errors_subset(cand, base) {
646        return match rule {
647            LintRuleId::DeadEndReasonAlias
648            | LintRuleId::DecisionRationaleAlias
649            | LintRuleId::PivotFromAlias
650            | LintRuleId::PivotToAlias
651            | LintRuleId::PivotTriggerAlias => {
652                "alias rename would introduce a new parse error occurrence; left unchanged"
653                    .to_string()
654            }
655            LintRuleId::ClaimHeaderStyle => {
656                "claim-header rewrite would introduce a new parse error occurrence; left unchanged"
657                    .to_string()
658            }
659            LintRuleId::RootDialect => guard_reason(rule),
660        };
661    }
662
663    guard_reason(rule)
664}
665
666/// Generic reason recorded when a rule's guard rejects a candidate.
667fn guard_reason(rule: LintRuleId) -> String {
668    match rule {
669        LintRuleId::RootDialect => {
670            "root→tree rewrite would change the parsed manifest; left unchanged".to_string()
671        }
672        LintRuleId::DeadEndReasonAlias
673        | LintRuleId::DecisionRationaleAlias
674        | LintRuleId::PivotFromAlias
675        | LintRuleId::PivotToAlias
676        | LintRuleId::PivotTriggerAlias => {
677            "alias rename would change more than the recovered field; left unchanged".to_string()
678        }
679        LintRuleId::ClaimHeaderStyle => {
680            "claim-header rewrite would change more than the recovered claim; left unchanged"
681                .to_string()
682        }
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crate::manifest::NodeId;
690    use crate::parse::parse_sources;
691    use crate::report::ParseReport;
692
693    /// Builds a temp ARA artifact with the given tree YAML and optional claims.
694    fn artifact(tree_yaml: &str, claims_md: Option<&str>) -> tempfile::TempDir {
695        let dir = tempfile::TempDir::new().unwrap();
696        std::fs::create_dir_all(dir.path().join("trace")).unwrap();
697        std::fs::write(dir.path().join("trace/exploration_tree.yaml"), tree_yaml).unwrap();
698        if let Some(claims) = claims_md {
699            std::fs::create_dir_all(dir.path().join("logic")).unwrap();
700            std::fs::write(dir.path().join("logic/claims.md"), claims).unwrap();
701        }
702        dir
703    }
704
705    fn read_tree(dir: &tempfile::TempDir) -> String {
706        std::fs::read_to_string(dir.path().join("trace/exploration_tree.yaml")).unwrap()
707    }
708
709    fn read_claims(dir: &tempfile::TempDir) -> String {
710        std::fs::read_to_string(dir.path().join("logic/claims.md")).unwrap()
711    }
712
713    fn parse_errors(errors: &[(&str, &str)]) -> ParseOutcome {
714        let mut report = ParseReport::default();
715        for &(path, message) in errors {
716            report.error(path, message);
717        }
718        ParseOutcome::Fatal(report)
719    }
720
721    #[test]
722    fn errors_subset_accepts_equal_multisets() {
723        let base = parse_errors(&[
724            ("nodes[N01]", "duplicate node id"),
725            ("links[0].target", "unknown node N99"),
726        ]);
727        let cand = parse_errors(&[
728            ("nodes[N01]", "duplicate node id"),
729            ("links[0].target", "unknown node N99"),
730        ]);
731
732        assert!(errors_subset(&cand, &base));
733    }
734
735    #[test]
736    fn errors_subset_accepts_removed_errors() {
737        let base = parse_errors(&[
738            ("nodes[N01]", "duplicate node id"),
739            ("links[0].target", "unknown node N99"),
740        ]);
741        let cand = parse_errors(&[("links[0].target", "unknown node N99")]);
742
743        assert!(errors_subset(&cand, &base));
744    }
745
746    #[test]
747    fn errors_subset_rejects_same_size_with_different_identity() {
748        let base = parse_errors(&[("nodes[N01]", "duplicate node id")]);
749        let different_path = parse_errors(&[("nodes[N02]", "duplicate node id")]);
750        let different_message = parse_errors(&[("nodes[N01]", "unknown node id")]);
751
752        assert!(!errors_subset(&different_path, &base));
753        assert!(!errors_subset(&different_message, &base));
754    }
755
756    #[test]
757    fn errors_subset_rejects_duplicate_candidate_occurrence() {
758        let base = parse_errors(&[("nodes[N01]", "duplicate node id")]);
759        let cand = parse_errors(&[
760            ("nodes[N01]", "duplicate node id"),
761            ("nodes[N01]", "duplicate node id"),
762        ]);
763
764        assert!(!errors_subset(&cand, &base));
765    }
766
767    // ---- ARA001 -----------------------------------------------------------
768
769    #[test]
770    fn ara001_root_rewritten_to_tree_preserving_manifest() {
771        let yaml = "\
772root:
773  id: N01
774  type: question
775  title: q
776  children:
777    - id: N02
778      type: experiment
779      result: 28.4 BLEU
780";
781        let before = parse_sources(yaml, None).expect("root parses").0;
782        let dir = artifact(yaml, None);
783        let outcome = fix_dir(dir.path());
784
785        assert_eq!(outcome.applied.len(), 1);
786        assert_eq!(outcome.applied[0].rule, LintRuleId::RootDialect);
787        assert_eq!(outcome.changed_files, vec![LintFile::Tree]);
788        assert!(outcome.remaining.is_empty());
789
790        let after_text = read_tree(&dir);
791        assert!(after_text.starts_with("tree:\n"), "got: {after_text}");
792        // Rewritten YAML is well-formed and re-parses to the same manifest.
793        let after = parse_sources(&after_text, None)
794            .expect("rewritten parses")
795            .0;
796        assert_eq!(before.nodes, after.nodes);
797        assert_eq!(before.links, after.links);
798        assert_eq!(before, after);
799    }
800
801    #[test]
802    fn ara001_expected_reindented_text() {
803        let yaml = "root:\n  id: RQ\n  type: question\n  children:\n    - id: N02\n";
804        let dir = artifact(yaml, None);
805        fix_dir(dir.path());
806        assert_eq!(
807            read_tree(&dir),
808            "tree:\n  - id: RQ\n    type: question\n    children:\n      - id: N02\n"
809        );
810    }
811
812    #[test]
813    fn ara001_guard_discards_when_manifest_would_differ() {
814        // Directly exercise the load-bearing guard: two DIFFERENT valid manifests
815        // must be rejected, an identical one accepted.
816        let base = parse_sources_detailed("tree:\n  - id: N01\n    type: question\n", None);
817        let different = parse_sources_detailed("tree:\n  - id: N99\n    type: question\n", None);
818        let same = parse_sources_detailed("tree:\n  - id: N01\n    type: question\n", None);
819        assert!(!guard_ara001(&base, &different));
820        assert!(guard_ara001(&base, &same));
821    }
822
823    // ---- ARA002 / ARA003 --------------------------------------------------
824
825    #[test]
826    fn ara002_reason_recovered_as_why_failed() {
827        let yaml = "\
828tree:
829  - id: N01
830    type: dead_end
831    reason: it diverged
832";
833        let dir = artifact(yaml, None);
834        let outcome = fix_dir(dir.path());
835
836        assert_eq!(outcome.applied.len(), 1);
837        assert_eq!(outcome.applied[0].rule, LintRuleId::DeadEndReasonAlias);
838        assert!(read_tree(&dir).contains("why_failed: it diverged"));
839
840        let (m, _) = parse_sources(&read_tree(&dir), None).expect("ok");
841        match &m.nodes[0].fields {
842            NodeFields::DeadEnd { why_failed, .. } => {
843                assert_eq!(why_failed.as_deref(), Some("it diverged"));
844            }
845            other => panic!("expected DeadEnd fields, got {other:?}"),
846        }
847    }
848
849    #[test]
850    fn ara003_justification_recovered_as_rationale() {
851        let yaml = "\
852tree:
853  - id: N01
854    type: decision
855    justification: cheaper to train
856";
857        let dir = artifact(yaml, None);
858        let outcome = fix_dir(dir.path());
859
860        assert_eq!(outcome.applied.len(), 1);
861        assert_eq!(outcome.applied[0].rule, LintRuleId::DecisionRationaleAlias);
862
863        let (m, _) = parse_sources(&read_tree(&dir), None).expect("ok");
864        match &m.nodes[0].fields {
865            NodeFields::Decision { rationale, .. } => {
866                assert_eq!(rationale.as_deref(), Some("cheaper to train"));
867            }
868            other => panic!("expected Decision fields, got {other:?}"),
869        }
870    }
871
872    #[test]
873    fn alias_fixes_apply_with_unrelated_duplicate_id_error() {
874        let yaml = "\
875tree:
876  - id: N01
877    type: dead_end
878    reason: diverged
879  - id: N02
880    type: decision
881    justification: cheaper
882  - id: N03
883    type: question
884  - id: N03
885    type: insight
886";
887        let before = parse_sources(yaml, None).expect_err("duplicate id must remain an error");
888        let before_errors = before.errors().to_vec();
889        let dir = artifact(yaml, None);
890
891        let outcome = fix_dir(dir.path());
892
893        assert_eq!(outcome.applied.len(), 2);
894        assert_eq!(
895            outcome
896                .applied
897                .iter()
898                .map(|fix| fix.rule)
899                .collect::<Vec<_>>(),
900            vec![
901                LintRuleId::DeadEndReasonAlias,
902                LintRuleId::DecisionRationaleAlias,
903            ]
904        );
905        assert_eq!(outcome.changed_files, vec![LintFile::Tree]);
906        let fixed = read_tree(&dir);
907        assert!(fixed.contains("why_failed: diverged"));
908        assert!(fixed.contains("rationale: cheaper"));
909        assert!(!fixed.contains("\n    reason:"));
910        assert!(!fixed.contains("\n    justification:"));
911
912        let after = parse_sources(&fixed, None).expect_err("duplicate id must remain an error");
913        assert_eq!(after.errors(), before_errors);
914    }
915
916    #[test]
917    fn alias_fix_applies_on_kept_duplicate_id_occurrence() {
918        let yaml = "\
919tree:
920  - id: N01
921    type: dead_end
922    reason: diverged
923  - id: N01
924    type: question
925";
926        let before = parse_sources(yaml, None).expect_err("duplicate id must remain an error");
927        let before_errors = before.errors().to_vec();
928        let dir = artifact(yaml, None);
929
930        let outcome = fix_dir(dir.path());
931
932        assert_eq!(outcome.applied.len(), 1);
933        assert_eq!(outcome.applied[0].rule, LintRuleId::DeadEndReasonAlias);
934        assert_eq!(outcome.changed_files, vec![LintFile::Tree]);
935        let fixed = read_tree(&dir);
936        assert!(fixed.contains("why_failed: diverged"));
937        assert!(!fixed.contains("\n    reason:"));
938        let after = parse_sources(&fixed, None).expect_err("duplicate id must remain an error");
939        assert_eq!(after.errors(), before_errors);
940    }
941
942    #[test]
943    fn alias_fix_rejects_dropped_duplicate_id_occurrence_byte_identically() {
944        let yaml = "\
945tree:
946  - id: N01
947    type: question
948  - id: N01
949    type: dead_end
950    reason: diverged
951";
952        assert!(
953            parse_sources(yaml, None).is_err(),
954            "duplicate id must remain an error"
955        );
956        let dir = artifact(yaml, None);
957
958        let outcome = fix_dir(dir.path());
959
960        assert!(outcome.applied.is_empty());
961        assert!(outcome.changed_files.is_empty());
962        assert_eq!(outcome.skipped.len(), 1);
963        assert_eq!(outcome.skipped[0].rule, LintRuleId::DeadEndReasonAlias);
964        assert_eq!(read_tree(&dir), yaml);
965    }
966
967    #[test]
968    fn alias_guard_discards_multi_node_change() {
969        // Two nodes' fields change → not "exactly one recovered field" → discard.
970        let base = parse_sources_detailed(
971            "tree:\n  - id: N01\n    type: dead_end\n  - id: N02\n    type: dead_end\n",
972            None,
973        );
974        let cand = parse_sources_detailed(
975            "tree:\n  - id: N01\n    type: dead_end\n    why_failed: a\n  - id: N02\n    type: dead_end\n    why_failed: b\n",
976            None,
977        );
978        assert!(!guard_alias(&base, &cand, AliasField::WhyFailed));
979
980        // A single recovered field is accepted.
981        let base1 = parse_sources_detailed("tree:\n  - id: N01\n    type: dead_end\n", None);
982        let cand1 = parse_sources_detailed(
983            "tree:\n  - id: N01\n    type: dead_end\n    why_failed: a\n",
984            None,
985        );
986        assert!(guard_alias(&base1, &cand1, AliasField::WhyFailed));
987    }
988
989    // ---- ARA005 / ARA006 / ARA007 (pivot aliases) -------------------------
990
991    #[test]
992    fn ara005_from_recovered_as_prior_direction() {
993        let yaml = "\
994tree:
995  - id: N01
996    type: pivot
997    from: dense retrieval
998";
999        let dir = artifact(yaml, None);
1000        let outcome = fix_dir(dir.path());
1001
1002        assert_eq!(outcome.applied.len(), 1);
1003        assert_eq!(outcome.applied[0].rule, LintRuleId::PivotFromAlias);
1004        assert!(outcome.skipped.is_empty());
1005        assert!(read_tree(&dir).contains("prior_direction: dense retrieval"));
1006
1007        let (m, report) = parse_sources(&read_tree(&dir), None).expect("ok");
1008        assert!(
1009            report.warnings().is_empty(),
1010            "unknown-field warning must be gone, got: {report}"
1011        );
1012        match &m.nodes[0].fields {
1013            NodeFields::Pivot {
1014                prior_direction, ..
1015            } => {
1016                assert_eq!(prior_direction.as_deref(), Some("dense retrieval"));
1017            }
1018            other => panic!("expected Pivot fields, got {other:?}"),
1019        }
1020    }
1021
1022    #[test]
1023    fn ara006_to_recovered_as_new_direction() {
1024        let yaml = "\
1025tree:
1026  - id: N01
1027    type: pivot
1028    to: sparse retrieval
1029";
1030        let dir = artifact(yaml, None);
1031        let outcome = fix_dir(dir.path());
1032
1033        assert_eq!(outcome.applied.len(), 1);
1034        assert_eq!(outcome.applied[0].rule, LintRuleId::PivotToAlias);
1035        assert!(read_tree(&dir).contains("new_direction: sparse retrieval"));
1036
1037        let (m, report) = parse_sources(&read_tree(&dir), None).expect("ok");
1038        assert!(
1039            report.warnings().is_empty(),
1040            "unknown-field warning must be gone, got: {report}"
1041        );
1042        match &m.nodes[0].fields {
1043            NodeFields::Pivot { new_direction, .. } => {
1044                assert_eq!(new_direction.as_deref(), Some("sparse retrieval"));
1045            }
1046            other => panic!("expected Pivot fields, got {other:?}"),
1047        }
1048    }
1049
1050    #[test]
1051    fn ara007_trigger_recovered_as_reason() {
1052        let yaml = "\
1053tree:
1054  - id: N01
1055    type: pivot
1056    trigger: latency budget
1057";
1058        let dir = artifact(yaml, None);
1059        let outcome = fix_dir(dir.path());
1060
1061        assert_eq!(outcome.applied.len(), 1);
1062        assert_eq!(outcome.applied[0].rule, LintRuleId::PivotTriggerAlias);
1063        assert!(read_tree(&dir).contains("reason: latency budget"));
1064
1065        let (m, report) = parse_sources(&read_tree(&dir), None).expect("ok");
1066        assert!(
1067            report.warnings().is_empty(),
1068            "unknown-field warning must be gone, got: {report}"
1069        );
1070        match &m.nodes[0].fields {
1071            NodeFields::Pivot { reason, .. } => {
1072                assert_eq!(reason.as_deref(), Some("latency budget"));
1073            }
1074            other => panic!("expected Pivot fields, got {other:?}"),
1075        }
1076    }
1077
1078    #[test]
1079    fn pivot_alias_rejects_when_canonical_and_alias_coexist_byte_identically() {
1080        // A node carrying BOTH the alias and its canonical key is auto-rejected:
1081        // the rename would duplicate the canonical key, so the guard records a
1082        // SkippedFix with a precise reason and never writes.
1083        let cases = [
1084            (
1085                "\
1086tree:
1087  - id: N01
1088    type: pivot
1089    prior_direction: canonical
1090    from: alias
1091",
1092                LintRuleId::PivotFromAlias,
1093            ),
1094            (
1095                "\
1096tree:
1097  - id: N01
1098    type: pivot
1099    new_direction: canonical
1100    to: alias
1101",
1102                LintRuleId::PivotToAlias,
1103            ),
1104            (
1105                "\
1106tree:
1107  - id: N01
1108    type: pivot
1109    reason: canonical
1110    trigger: alias
1111",
1112                LintRuleId::PivotTriggerAlias,
1113            ),
1114        ];
1115
1116        for (yaml, rule) in cases {
1117            let dir = artifact(yaml, None);
1118            let outcome = fix_dir(dir.path());
1119
1120            assert!(outcome.applied.is_empty(), "{rule:?}");
1121            assert!(outcome.changed_files.is_empty(), "{rule:?}");
1122            let skipped = outcome
1123                .skipped
1124                .iter()
1125                .find(|skipped| skipped.rule == rule)
1126                .unwrap_or_else(|| panic!("{rule:?}: {:?}", outcome.skipped));
1127            assert_eq!(
1128                skipped.reason,
1129                "alias rename would change more than the recovered field; left unchanged",
1130                "{rule:?}"
1131            );
1132            assert_eq!(read_tree(&dir), yaml, "{rule:?}");
1133        }
1134    }
1135
1136    #[test]
1137    fn pivot_multi_alias_fixpoint_recovers_all_fields() {
1138        // from+to+trigger on one pivot all rename in a single fix run.
1139        let yaml = "\
1140tree:
1141  - id: N01
1142    type: pivot
1143    from: dense retrieval
1144    to: sparse retrieval
1145    trigger: latency budget
1146";
1147        let dir = artifact(yaml, None);
1148        let outcome = fix_dir(dir.path());
1149
1150        assert_eq!(outcome.applied.len(), 3);
1151        let rules: Vec<LintRuleId> = outcome.applied.iter().map(|fix| fix.rule).collect();
1152        for rule in [
1153            LintRuleId::PivotFromAlias,
1154            LintRuleId::PivotToAlias,
1155            LintRuleId::PivotTriggerAlias,
1156        ] {
1157            assert!(rules.contains(&rule), "missing {rule}, got: {rules:?}");
1158        }
1159        assert!(outcome.skipped.is_empty());
1160
1161        let fixed = read_tree(&dir);
1162        assert!(fixed.contains("prior_direction: dense retrieval"));
1163        assert!(fixed.contains("new_direction: sparse retrieval"));
1164        assert!(fixed.contains("reason: latency budget"));
1165        assert!(!fixed.contains("\n    from:"));
1166        assert!(!fixed.contains("\n    to:"));
1167        assert!(!fixed.contains("\n    trigger:"));
1168
1169        let (m, report) = parse_sources(&fixed, None).expect("ok");
1170        assert!(report.warnings().is_empty(), "got: {report}");
1171        assert_eq!(
1172            m.nodes[0].fields,
1173            NodeFields::Pivot {
1174                prior_direction: Some("dense retrieval".to_string()),
1175                new_direction: Some("sparse retrieval".to_string()),
1176                reason: Some("latency budget".to_string()),
1177                lesson: None,
1178            }
1179        );
1180    }
1181
1182    #[test]
1183    fn pivot_alias_fix_second_run_is_noop() {
1184        let yaml = "\
1185tree:
1186  - id: N01
1187    type: pivot
1188    from: dense retrieval
1189    to: sparse retrieval
1190    trigger: latency budget
1191";
1192        let dir = artifact(yaml, None);
1193
1194        let first = fix_dir(dir.path());
1195        assert_eq!(first.applied.len(), 3);
1196        let tree_after_first = read_tree(&dir);
1197
1198        let second = fix_dir(dir.path());
1199        assert!(
1200            second.applied.is_empty(),
1201            "second run must apply nothing, got: {:?}",
1202            second.applied
1203        );
1204        assert!(second.changed_files.is_empty());
1205        assert_eq!(
1206            read_tree(&dir),
1207            tree_after_first,
1208            "tree must be byte-identical"
1209        );
1210    }
1211
1212    #[test]
1213    fn pivot_alias_guard_validates_single_recovery() {
1214        // A single recovered pivot field is accepted...
1215        let base = parse_sources_detailed("tree:\n  - id: N01\n    type: pivot\n", None);
1216        let cand = parse_sources_detailed(
1217            "tree:\n  - id: N01\n    type: pivot\n    prior_direction: a\n",
1218            None,
1219        );
1220        assert!(guard_alias(&base, &cand, AliasField::PriorDirection));
1221
1222        // ...but not when the field was already populated in base...
1223        let cand2 = parse_sources_detailed(
1224            "tree:\n  - id: N01\n    type: pivot\n    prior_direction: b\n",
1225            None,
1226        );
1227        assert!(!guard_alias(&cand, &cand2, AliasField::PriorDirection));
1228
1229        // ...and a recovery lands in the right field only.
1230        assert!(!guard_alias(&base, &cand, AliasField::NewDirection));
1231        assert!(!guard_alias(&base, &cand, AliasField::PivotReason));
1232    }
1233
1234    // ---- ARA004 -----------------------------------------------------------
1235
1236    #[test]
1237    fn ara004_dash_header_recovers_claim() {
1238        // Standalone claim (not referenced) that silently disappears today.
1239        let yaml = "tree:\n  - id: N01\n    type: question\n";
1240        let claims = "## C01 — Attention is all you need\n- **Statement**: yes\n";
1241        let dir = artifact(yaml, Some(claims));
1242
1243        let before = parse_sources(yaml, Some(claims)).expect("ok").0;
1244        assert!(before.claims.is_empty(), "dash header must not parse today");
1245
1246        let outcome = fix_dir(dir.path());
1247        assert_eq!(outcome.applied.len(), 1);
1248        assert_eq!(outcome.applied[0].rule, LintRuleId::ClaimHeaderStyle);
1249        assert_eq!(outcome.changed_files, vec![LintFile::Claims]);
1250
1251        let after_claims = read_claims(&dir);
1252        assert!(after_claims.starts_with("## C01: Attention is all you need\n"));
1253        let (m, _) = parse_sources(&read_tree(&dir), Some(&after_claims)).expect("ok");
1254        assert_eq!(m.claims.len(), 1);
1255        assert_eq!(m.claims[0].id, crate::manifest::ClaimId::new("C01"));
1256        assert_eq!(m.claims[0].title, "Attention is all you need");
1257    }
1258
1259    #[test]
1260    fn ara004_recovers_referenced_claim_and_resolves_dangling_error() {
1261        // A node references C01 whose header is dash-separated → base parse errors
1262        // (dangling reference). The fix recovers the claim and the binding.
1263        let yaml = "\
1264tree:
1265  - id: N01
1266    type: experiment
1267    evidence: [C01]
1268";
1269        let claims = "## C01 - Faster training\n- **Statement**: yes\n";
1270        let dir = artifact(yaml, Some(claims));
1271
1272        assert!(
1273            parse_sources(yaml, Some(claims)).is_err(),
1274            "dangling C01 must error before the fix"
1275        );
1276
1277        let outcome = fix_dir(dir.path());
1278        assert_eq!(outcome.applied.len(), 1);
1279        assert_eq!(outcome.applied[0].rule, LintRuleId::ClaimHeaderStyle);
1280
1281        let (m, report) =
1282            parse_sources(&read_tree(&dir), Some(&read_claims(&dir))).expect("ok now");
1283        assert!(report.is_ok());
1284        assert_eq!(m.claims.len(), 1);
1285        assert_eq!(m.bindings.len(), 1);
1286        assert_eq!(m.bindings[0].claim, crate::manifest::ClaimId::new("C01"));
1287    }
1288
1289    #[test]
1290    fn mixed_recovering_rules_apply_with_persisting_duplicate_id_error() {
1291        let yaml = "\
1292tree:
1293  - id: N01
1294    type: dead_end
1295    reason: diverged
1296    evidence: [C01]
1297  - id: N02
1298    type: question
1299  - id: N02
1300    type: insight
1301";
1302        let claims = "## C01 — Recovered claim\n- **Statement**: supported\n";
1303        let before =
1304            parse_sources(yaml, Some(claims)).expect_err("both semantic errors must be present");
1305        let duplicate_errors = before
1306            .errors()
1307            .iter()
1308            .filter(|error| error.message == "duplicate node id")
1309            .cloned()
1310            .collect::<Vec<_>>();
1311        assert_eq!(duplicate_errors.len(), 1);
1312        assert!(
1313            before
1314                .errors()
1315                .iter()
1316                .any(|error| error.message == "evidence references unknown claim `C01`")
1317        );
1318        let dir = artifact(yaml, Some(claims));
1319
1320        let outcome = fix_dir(dir.path());
1321
1322        assert_eq!(
1323            outcome
1324                .applied
1325                .iter()
1326                .map(|fix| fix.rule)
1327                .collect::<Vec<_>>(),
1328            vec![LintRuleId::DeadEndReasonAlias, LintRuleId::ClaimHeaderStyle,]
1329        );
1330        assert_eq!(
1331            outcome.changed_files,
1332            vec![LintFile::Tree, LintFile::Claims]
1333        );
1334        assert!(outcome.skipped.is_empty());
1335        assert!(outcome.remaining.is_empty());
1336        let fixed_tree = read_tree(&dir);
1337        let fixed_claims = read_claims(&dir);
1338        assert!(fixed_tree.contains("why_failed: diverged"));
1339        assert!(fixed_claims.starts_with("## C01: Recovered claim\n"));
1340        let after = parse_sources(&fixed_tree, Some(&fixed_claims))
1341            .expect_err("duplicate id must remain an error");
1342        assert_eq!(after.errors(), duplicate_errors);
1343    }
1344
1345    #[test]
1346    fn ara004_guard_requires_exact_recovered_binding_delta() {
1347        let yaml = "\
1348tree:
1349  - id: N01
1350    type: experiment
1351    evidence: [C01, C02]
1352";
1353        let claims = "\
1354## C01 — Recovered
1355- **Statement**: one
1356
1357## C02: Existing
1358- **Statement**: two
1359";
1360        let lint = check_sources(yaml, Some(claims));
1361        let diag = lint
1362            .diagnostics()
1363            .iter()
1364            .find(|diag| diag.rule == LintRuleId::ClaimHeaderStyle)
1365            .expect("ARA004 candidate");
1366        let fixed_claims = apply_fix_to_text(claims, diag.fix.as_ref().unwrap()).unwrap();
1367        let base = parse_sources_detailed(yaml, Some(claims));
1368        let candidate = parse_sources_detailed(yaml, Some(&fixed_claims));
1369        let applier = Applier::new(yaml.to_string(), Some(claims.to_string()));
1370
1371        assert!(applier.guard_ara004(diag, &base, &candidate, Some(&fixed_claims)));
1372        let (
1373            ParseOutcome::Normalized(base_manifest, _),
1374            ParseOutcome::Normalized(candidate_manifest, _),
1375        ) = (&base, &candidate)
1376        else {
1377            panic!("both artifacts must normalize");
1378        };
1379        let mut bindings_without_recovered = candidate_manifest.bindings.clone();
1380        bindings_without_recovered.retain(|binding| binding.claim.as_str() != "C01");
1381        assert_eq!(bindings_without_recovered, base_manifest.bindings);
1382
1383        let mut perturbed_manifest = candidate_manifest.clone();
1384        perturbed_manifest
1385            .bindings
1386            .push(base_manifest.bindings[0].clone());
1387        let perturbed = match candidate {
1388            ParseOutcome::Normalized(_, report) => {
1389                ParseOutcome::Normalized(perturbed_manifest, report)
1390            }
1391            ParseOutcome::Fatal(_) => unreachable!(),
1392        };
1393        assert!(!applier.guard_ara004(diag, &base, &perturbed, Some(&fixed_claims)));
1394    }
1395
1396    #[test]
1397    fn speedrun_claim_headers_fix_on_erroring_artifact() {
1398        let yaml = include_str!(
1399            "../tests/fixtures/corpus/speedrun/nanogpt-speedrun/trace/exploration_tree.yaml"
1400        );
1401        let claims =
1402            include_str!("../tests/fixtures/corpus/speedrun/nanogpt-speedrun/logic/claims.md");
1403        let pre_fix =
1404            parse_sources(yaml, Some(claims)).expect_err("referenced claims must be absent");
1405        assert!(
1406            pre_fix
1407                .errors()
1408                .iter()
1409                .any(|error| error.message.contains("evidence references unknown claim")),
1410            "expected absent referenced-claim errors, got: {pre_fix}"
1411        );
1412        let ParseOutcome::Normalized(base, _) = parse_sources_detailed(yaml, Some(claims)) else {
1413            panic!("speedrun fixture must normalize despite semantic errors");
1414        };
1415        assert!(base.claims.is_empty());
1416        let dir = artifact(yaml, Some(claims));
1417
1418        let first = fix_dir(dir.path());
1419
1420        // 10 claim-header rewrites plus 2 pivot `trigger:`→`reason:` recoveries
1421        // (ARA007): the fixture's two pivot nodes carry the pre-canonical alias.
1422        assert_eq!(first.applied.len(), 12);
1423        assert_eq!(
1424            first
1425                .applied
1426                .iter()
1427                .filter(|fix| fix.rule == LintRuleId::ClaimHeaderStyle)
1428                .count(),
1429            10
1430        );
1431        assert_eq!(
1432            first
1433                .applied
1434                .iter()
1435                .filter(|fix| fix.rule == LintRuleId::PivotTriggerAlias)
1436                .count(),
1437            2
1438        );
1439        assert_eq!(first.changed_files, vec![LintFile::Tree, LintFile::Claims]);
1440        let fixed_tree = read_tree(&dir);
1441        let fixed_claims = read_claims(&dir);
1442        let (manifest, report) =
1443            parse_sources(&fixed_tree, Some(&fixed_claims)).expect("fixed fixture must parse");
1444        assert!(report.is_ok());
1445        assert_eq!(
1446            manifest
1447                .claims
1448                .iter()
1449                .map(|claim| claim.id.as_str())
1450                .collect::<Vec<_>>(),
1451            (1..=10)
1452                .map(|number| format!("C{number:02}"))
1453                .collect::<Vec<_>>()
1454        );
1455        for claim in &manifest.claims {
1456            assert!(
1457                fixed_claims.contains(&format!("## {}: {}", claim.id, claim.title)),
1458                "missing canonical header for {}",
1459                claim.id
1460            );
1461        }
1462        assert_eq!(
1463            manifest.claims[0].title,
1464            "16× Training Speedup Through Incremental Optimization"
1465        );
1466        assert_eq!(
1467            manifest.claims[0].statement.as_deref(),
1468            Some(
1469                "Human-authored optimizations compress GPT-2 124M training (val_loss ≤ 3.28) from 49.5 min to 3.1 min across 21 records, achieving a 16.1× wall-clock speedup on 8×H100."
1470            )
1471        );
1472        // The two pivot `trigger:` aliases were recovered into `reason:`; clearing
1473        // exactly those recovered fields must reproduce the base nodes.
1474        let recovered: Vec<&Node> = manifest
1475            .nodes
1476            .iter()
1477            .filter(|node| {
1478                matches!(
1479                    &node.fields,
1480                    NodeFields::Pivot {
1481                        reason: Some(_),
1482                        ..
1483                    }
1484                )
1485            })
1486            .collect();
1487        assert_eq!(recovered.len(), 2, "got: {recovered:?}");
1488        let mut without_recovered = manifest.nodes.clone();
1489        for node in &mut without_recovered {
1490            if let NodeFields::Pivot { reason, .. } = &mut node.fields {
1491                *reason = None;
1492            }
1493        }
1494        assert_eq!(without_recovered, base.nodes);
1495        assert_eq!(manifest.links, base.links);
1496        assert!(manifest.bindings.iter().all(|binding| {
1497            manifest
1498                .claims
1499                .iter()
1500                .any(|claim| claim.id == binding.claim)
1501        }));
1502        assert_eq!(
1503            manifest
1504                .bindings
1505                .iter()
1506                .filter(|binding| {
1507                    !manifest
1508                        .claims
1509                        .iter()
1510                        .any(|claim| claim.id == binding.claim)
1511                })
1512                .collect::<Vec<_>>(),
1513            base.bindings.iter().collect::<Vec<_>>()
1514        );
1515
1516        let second = fix_dir(dir.path());
1517        assert!(second.applied.is_empty());
1518        assert!(second.changed_files.is_empty());
1519        assert_eq!(read_tree(&dir), fixed_tree);
1520        assert_eq!(read_claims(&dir), fixed_claims);
1521    }
1522
1523    // ---- idempotence / safety --------------------------------------------
1524
1525    #[test]
1526    fn fix_dir_is_idempotent() {
1527        let yaml = "\
1528root:
1529  id: N01
1530  type: question
1531  children:
1532    - id: N02
1533      type: dead_end
1534      reason: diverged
1535    - id: N03
1536      type: decision
1537      justification: cheaper
1538";
1539        let claims = "## C01 — A claim\n- **Statement**: yes\n";
1540        let dir = artifact(yaml, Some(claims));
1541
1542        let first = fix_dir(dir.path());
1543        assert!(!first.applied.is_empty());
1544        let tree_after_first = read_tree(&dir);
1545        let claims_after_first = read_claims(&dir);
1546
1547        let second = fix_dir(dir.path());
1548        assert!(
1549            second.applied.is_empty(),
1550            "second run must apply nothing, got: {:?}",
1551            second.applied
1552        );
1553        assert!(second.changed_files.is_empty());
1554        assert_eq!(
1555            read_tree(&dir),
1556            tree_after_first,
1557            "tree must be byte-identical"
1558        );
1559        assert_eq!(
1560            read_claims(&dir),
1561            claims_after_first,
1562            "claims must be byte-identical"
1563        );
1564    }
1565
1566    #[test]
1567    fn ara001_rejects_error_bearing_normalized_artifact_byte_identically() {
1568        let yaml = "\
1569root:
1570  id: N01
1571  type: question
1572  children:
1573    - id: N01
1574      type: insight
1575";
1576        assert!(matches!(
1577            parse_sources_detailed(yaml, None),
1578            ParseOutcome::Normalized(_, ref report) if !report.is_ok()
1579        ));
1580        let dir = artifact(yaml, None);
1581
1582        let outcome = fix_dir(dir.path());
1583
1584        assert!(outcome.applied.is_empty());
1585        assert!(outcome.changed_files.is_empty());
1586        assert_eq!(outcome.skipped.len(), 1);
1587        assert_eq!(outcome.skipped[0].rule, LintRuleId::RootDialect);
1588        assert_eq!(read_tree(&dir), yaml);
1589    }
1590
1591    #[test]
1592    fn alias_fixes_reject_when_canonical_and_alias_fields_coexist_byte_identically() {
1593        let cases = [
1594            (
1595                "\
1596tree:
1597  - id: N01
1598    type: dead_end
1599    why_failed: canonical
1600    reason: alias
1601",
1602                LintRuleId::DeadEndReasonAlias,
1603            ),
1604            (
1605                "\
1606tree:
1607  - id: N01
1608    type: decision
1609    rationale: canonical
1610    justification: alias
1611",
1612                LintRuleId::DecisionRationaleAlias,
1613            ),
1614        ];
1615
1616        for (yaml, rule) in cases {
1617            let dir = artifact(yaml, None);
1618            let outcome = fix_dir(dir.path());
1619
1620            assert!(outcome.applied.is_empty(), "{rule:?}");
1621            assert!(outcome.changed_files.is_empty(), "{rule:?}");
1622            assert!(
1623                outcome.skipped.iter().any(|skipped| skipped.rule == rule),
1624                "{rule:?}: {:?}",
1625                outcome.skipped
1626            );
1627            assert_eq!(read_tree(&dir), yaml, "{rule:?}");
1628        }
1629    }
1630
1631    #[test]
1632    fn fatal_alias_artifact_is_rejected_byte_identically() {
1633        let yaml = "\
1634tree:
1635  - id: N01
1636    type: dead_end
1637    reason: diverged
1638  - broken: [
1639";
1640        assert!(matches!(
1641            parse_sources_detailed(yaml, None),
1642            ParseOutcome::Fatal(_)
1643        ));
1644        let dir = artifact(yaml, None);
1645
1646        let outcome = fix_dir(dir.path());
1647
1648        assert!(outcome.applied.is_empty());
1649        assert!(outcome.changed_files.is_empty());
1650        assert!(
1651            outcome
1652                .skipped
1653                .iter()
1654                .any(|skipped| skipped.rule == LintRuleId::DeadEndReasonAlias)
1655        );
1656        assert_eq!(read_tree(&dir), yaml);
1657    }
1658
1659    #[test]
1660    fn ara004_rejects_new_unknown_dependency_byte_identically() {
1661        let yaml = "tree:\n  - id: N01\n    type: question\n";
1662        let claims = "\
1663## C01 — Recovered claim
1664- **Statement**: value
1665- **Dependencies**: [C99]
1666";
1667        let dir = artifact(yaml, Some(claims));
1668
1669        let outcome = fix_dir(dir.path());
1670
1671        assert!(outcome.applied.is_empty());
1672        assert!(outcome.changed_files.is_empty());
1673        assert!(
1674            outcome
1675                .skipped
1676                .iter()
1677                .any(|skipped| skipped.rule == LintRuleId::ClaimHeaderStyle)
1678        );
1679        assert_eq!(
1680            outcome.skipped[0].reason,
1681            "claim-header rewrite would introduce a new parse error occurrence; left unchanged"
1682        );
1683        assert_eq!(read_tree(&dir), yaml);
1684        assert_eq!(read_claims(&dir), claims);
1685    }
1686
1687    #[test]
1688    fn ara004_rejects_fatal_tree_byte_identically() {
1689        let yaml = "tree: [\n";
1690        let claims = "## C01 — Recovered claim\n- **Statement**: value\n";
1691        assert!(matches!(
1692            parse_sources_detailed(yaml, Some(claims)),
1693            ParseOutcome::Fatal(_)
1694        ));
1695        let dir = artifact(yaml, Some(claims));
1696
1697        let outcome = fix_dir(dir.path());
1698
1699        assert!(outcome.applied.is_empty());
1700        assert!(outcome.changed_files.is_empty());
1701        assert!(
1702            outcome
1703                .skipped
1704                .iter()
1705                .any(|skipped| skipped.rule == LintRuleId::ClaimHeaderStyle)
1706        );
1707        assert_eq!(read_tree(&dir), yaml);
1708        assert_eq!(read_claims(&dir), claims);
1709    }
1710
1711    #[test]
1712    fn happy_path_reports_no_write_errors() {
1713        let yaml = "root:\n  id: N01\n  type: question\n";
1714        let dir = artifact(yaml, None);
1715        let outcome = fix_dir(dir.path());
1716        assert!(!outcome.applied.is_empty());
1717        assert!(
1718            outcome.errors.is_empty(),
1719            "clean write must record no errors"
1720        );
1721        assert!(!outcome.has_errors());
1722    }
1723
1724    #[cfg(unix)]
1725    #[test]
1726    fn write_failure_is_surfaced_in_errors() {
1727        use std::os::unix::fs::PermissionsExt;
1728
1729        let yaml = "root:\n  id: N01\n  type: question\n";
1730        let dir = artifact(yaml, None);
1731        let tree_path = dir.path().join("trace/exploration_tree.yaml");
1732
1733        // Make the tree file read-only so the write-back fails (non-root).
1734        let mut perms = std::fs::metadata(&tree_path).unwrap().permissions();
1735        perms.set_mode(0o444);
1736        std::fs::set_permissions(&tree_path, perms).unwrap();
1737
1738        // Probe whether we can still write despite the read-only bit (i.e. running
1739        // as root, where the permission is bypassed); skip the assertion if so.
1740        if std::fs::OpenOptions::new()
1741            .write(true)
1742            .open(&tree_path)
1743            .is_ok()
1744        {
1745            eprintln!("skipping: write not denied (likely running as root)");
1746            return;
1747        }
1748
1749        let outcome = fix_dir(dir.path());
1750
1751        assert!(outcome.has_errors());
1752        assert!(
1753            outcome.errors.iter().any(|(f, _)| *f == LintFile::Tree),
1754            "tree write failure must be surfaced, got: {:?}",
1755            outcome.errors
1756        );
1757        // The write failed, so the file must NOT be marked changed and the drift
1758        // is still on disk (no false "clean").
1759        assert!(!outcome.changed_files.contains(&LintFile::Tree));
1760        assert_eq!(read_tree(&dir), yaml, "on-disk file must be untouched");
1761
1762        // Restore write permission so TempDir cleanup succeeds.
1763        let mut perms = std::fs::metadata(&tree_path).unwrap().permissions();
1764        perms.set_mode(0o644);
1765        std::fs::set_permissions(&tree_path, perms).unwrap();
1766    }
1767
1768    #[test]
1769    fn clean_artifact_is_a_noop() {
1770        let yaml = "tree:\n  - id: N01\n    type: question\n";
1771        let dir = artifact(yaml, None);
1772        let outcome = fix_dir(dir.path());
1773        assert!(outcome.is_noop());
1774        assert!(outcome.applied.is_empty());
1775        assert!(outcome.skipped.is_empty());
1776        assert_eq!(read_tree(&dir), yaml);
1777    }
1778
1779    #[test]
1780    fn combined_ara001_and_alias_fixes_both_apply() {
1781        // ARA001 re-indent shifts the `reason:` column; the fixpoint re-detects
1782        // ARA002 on the rewritten text and still fixes it.
1783        let yaml = "\
1784root:
1785  id: N01
1786  type: question
1787  children:
1788    - id: N02
1789      type: dead_end
1790      reason: diverged
1791";
1792        let dir = artifact(yaml, None);
1793        let outcome = fix_dir(dir.path());
1794
1795        let rules: Vec<LintRuleId> = outcome.applied.iter().map(|a| a.rule).collect();
1796        assert!(rules.contains(&LintRuleId::RootDialect));
1797        assert!(rules.contains(&LintRuleId::DeadEndReasonAlias));
1798        assert!(outcome.remaining.is_empty());
1799
1800        let (m, _) = parse_sources(&read_tree(&dir), None).expect("ok");
1801        assert_eq!(m.nodes[0].id, NodeId::new("N01"));
1802        match &m.nodes[1].fields {
1803            NodeFields::DeadEnd { why_failed, .. } => {
1804                assert_eq!(why_failed.as_deref(), Some("diverged"));
1805            }
1806            other => panic!("expected DeadEnd, got {other:?}"),
1807        }
1808    }
1809}