1use 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
51const MAX_ITERS: usize = 1000;
55
56#[derive(Debug, Clone, PartialEq, Serialize)]
58pub struct AppliedFix {
59 pub rule: LintRuleId,
61 pub file: LintFile,
63 pub description: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize)]
70pub struct SkippedFix {
71 pub rule: LintRuleId,
73 pub file: LintFile,
75 pub reason: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize)]
81pub struct FixOutcome {
82 pub applied: Vec<AppliedFix>,
84 pub skipped: Vec<SkippedFix>,
86 pub remaining: LintReport,
94 pub changed_files: Vec<LintFile>,
96 pub errors: Vec<(LintFile, String)>,
99}
100
101impl FixOutcome {
102 pub fn is_noop(&self) -> bool {
104 self.applied.is_empty() && self.changed_files.is_empty()
105 }
106
107 pub fn has_errors(&self) -> bool {
109 !self.errors.is_empty()
110 }
111}
112
113pub 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175enum AliasField {
176 WhyFailed,
178 Rationale,
180 PriorDirection,
182 NewDirection,
184 PivotReason,
186}
187
188struct Applier {
190 tree: String,
192 claims: Option<String>,
194 applied: Vec<AppliedFix>,
196 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 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 self.failed.clear();
231 }
232 }
233 }
234
235 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 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 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 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 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 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 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 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
378fn 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
391fn 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 if field_is_some(&mb.nodes[i], field) || !field_is_some(&mc.nodes[i], field) {
418 return false;
419 }
420
421 let mut mc2 = mc.clone();
424 clear_field(&mut mc2.nodes[i], field);
425 mc2 == *mb
426}
427
428fn 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
447fn 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
466fn 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
490fn errors_of(result: &ParseOutcome) -> &[Diagnostic] {
492 match result {
493 ParseOutcome::Normalized(_, report) | ParseOutcome::Fatal(report) => report.errors(),
494 }
495}
496
497fn 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
516fn 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
536fn 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 {
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 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
581fn leading_spaces(s: &str) -> usize {
583 s.len() - s.trim_start_matches(' ').len()
584}
585
586fn 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
603fn 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
612fn 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
635fn 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
666fn 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 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 #[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 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 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 #[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 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 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 #[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 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 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 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 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 assert!(!guard_alias(&base, &cand, AliasField::NewDirection));
1231 assert!(!guard_alias(&base, &cand, AliasField::PivotReason));
1232 }
1233
1234 #[test]
1237 fn ara004_dash_header_recovers_claim() {
1238 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 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 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 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 #[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 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 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 assert!(!outcome.changed_files.contains(&LintFile::Tree));
1760 assert_eq!(read_tree(&dir), yaml, "on-disk file must be untouched");
1761
1762 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 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}