1use serde::Serialize;
23
24#[cfg(feature = "native")]
25use crate::manifest::is_canonical_id;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29pub enum LintRuleId {
30 #[serde(rename = "ARA001")]
32 RootDialect,
33 #[serde(rename = "ARA002")]
35 DeadEndReasonAlias,
36 #[serde(rename = "ARA003")]
39 DecisionRationaleAlias,
40 #[serde(rename = "ARA004")]
42 ClaimHeaderStyle,
43 #[serde(rename = "ARA005")]
45 PivotFromAlias,
46 #[serde(rename = "ARA006")]
48 PivotToAlias,
49 #[serde(rename = "ARA007")]
51 PivotTriggerAlias,
52}
53
54impl LintRuleId {
55 pub fn as_str(&self) -> &'static str {
57 match self {
58 LintRuleId::RootDialect => "ARA001",
59 LintRuleId::DeadEndReasonAlias => "ARA002",
60 LintRuleId::DecisionRationaleAlias => "ARA003",
61 LintRuleId::ClaimHeaderStyle => "ARA004",
62 LintRuleId::PivotFromAlias => "ARA005",
63 LintRuleId::PivotToAlias => "ARA006",
64 LintRuleId::PivotTriggerAlias => "ARA007",
65 }
66 }
67}
68
69impl std::fmt::Display for LintRuleId {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 f.write_str(self.as_str())
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "snake_case")]
78pub enum LintFile {
79 Tree,
81 Claims,
83}
84
85impl LintFile {
86 pub fn relative_path(&self) -> &'static str {
88 match self {
89 LintFile::Tree => "trace/exploration_tree.yaml",
90 LintFile::Claims => "logic/claims.md",
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize)]
98pub enum FixCandidate {
99 ReplaceInLine {
104 line: usize,
106 start_col: usize,
108 end_col: usize,
110 replacement: String,
112 },
113 RewriteRootToTree {
118 root_line: usize,
120 root_indent: usize,
122 block_end_line: usize,
125 },
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize)]
130pub struct LintDiagnostic {
131 pub rule: LintRuleId,
133 pub message: String,
135 pub file: LintFile,
137 pub fixable: bool,
139 pub fix: Option<FixCandidate>,
141}
142
143#[derive(Debug, Clone, Default, PartialEq, Serialize)]
145pub struct LintReport {
146 pub diagnostics: Vec<LintDiagnostic>,
148}
149
150impl LintReport {
151 pub fn diagnostics(&self) -> &[LintDiagnostic] {
153 &self.diagnostics
154 }
155
156 pub fn is_empty(&self) -> bool {
158 self.diagnostics.is_empty()
159 }
160
161 pub fn fixable(&self) -> usize {
163 self.diagnostics.iter().filter(|d| d.fixable).count()
164 }
165}
166
167#[cfg(feature = "native")]
175pub fn check_dir(dir: &std::path::Path) -> LintReport {
176 let tree = std::fs::read_to_string(dir.join("trace/exploration_tree.yaml")).ok();
177 let claims = std::fs::read_to_string(dir.join("logic/claims.md")).ok();
178 check_sources(tree.as_deref().unwrap_or_default(), claims.as_deref())
179}
180
181#[cfg(feature = "native")]
190pub fn check_sources(tree_yaml: &str, claims_md: Option<&str>) -> LintReport {
191 let mut diagnostics = lint_tree(tree_yaml);
192 if let Some(md) = claims_md {
193 diagnostics.extend(lint_claims(md));
194 }
195 LintReport { diagnostics }
196}
197
198#[cfg(feature = "native")]
200struct KeyLine {
201 key: String,
203 value: String,
205 is_list_item: bool,
207 key_col: usize,
210}
211
212#[cfg(feature = "native")]
215struct KeyHit {
216 line: usize,
217 col: usize,
218}
219
220#[cfg(feature = "native")]
223struct NodeFrame {
224 key_indent: usize,
226 ty: Option<String>,
228 reason_hits: Vec<KeyHit>,
230 justification_hits: Vec<KeyHit>,
232 from_hits: Vec<KeyHit>,
234 to_hits: Vec<KeyHit>,
236 trigger_hits: Vec<KeyHit>,
238}
239
240#[cfg(feature = "native")]
242fn leading_spaces(s: &str) -> usize {
243 s.len() - s.trim_start_matches(' ').len()
244}
245
246#[cfg(feature = "native")]
251fn parse_key_line(line: &str) -> Option<KeyLine> {
252 let indent = leading_spaces(line);
253 let after = &line[indent..];
254 if after.is_empty() || after.starts_with('#') {
255 return None;
256 }
257
258 let (is_list_item, content, base) = match after.strip_prefix("- ") {
259 Some(rest) => {
260 let extra = leading_spaces(rest);
261 (true, &rest[extra..], indent + 2 + extra)
262 }
263 None => (false, after, indent),
264 };
265
266 let colon = content.find(':')?;
267 let key = &content[..colon];
268 if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
271 return None;
272 }
273 let after_colon = &content[colon + 1..];
276 if !(after_colon.is_empty() || after_colon.starts_with(' ')) {
277 return None;
278 }
279
280 Some(KeyLine {
281 key: key.to_string(),
282 value: after_colon.trim().to_string(),
283 is_list_item,
284 key_col: base,
285 })
286}
287
288#[cfg(feature = "native")]
292fn root_block_end(lines: &[&str], root_line: usize) -> usize {
293 let mut j = root_line + 1;
294 while j < lines.len() {
295 let l = lines[j];
296 if l.trim().is_empty() {
297 j += 1;
298 continue;
299 }
300 if leading_spaces(l) == 0 {
301 break;
302 }
303 j += 1;
304 }
305 j
306}
307
308#[cfg(feature = "native")]
319fn lint_tree(text: &str) -> Vec<LintDiagnostic> {
320 let lines: Vec<&str> = text.lines().collect();
321 let mut diags = Vec::new();
322 let mut frames: Vec<NodeFrame> = Vec::new();
323 let mut stack: Vec<usize> = Vec::new();
324
325 for (i, line) in lines.iter().enumerate() {
326 let Some(kl) = parse_key_line(line) else {
327 continue;
328 };
329
330 if !kl.is_list_item && kl.key_col == 0 && kl.key == "root" {
332 diags.push(LintDiagnostic {
333 rule: LintRuleId::RootDialect,
334 message: "top-level `root:` uses the single-node dialect; canonical form is a \
335 `tree:` list with one element"
336 .to_string(),
337 file: LintFile::Tree,
338 fixable: true,
339 fix: Some(FixCandidate::RewriteRootToTree {
340 root_line: i,
341 root_indent: 0,
342 block_end_line: root_block_end(&lines, i),
343 }),
344 });
345 continue;
346 }
347
348 while let Some(&top) = stack.last() {
350 if frames[top].key_indent > kl.key_col {
351 stack.pop();
352 } else {
353 break;
354 }
355 }
356
357 if kl.is_list_item {
358 if let Some(&top) = stack.last()
361 && frames[top].key_indent == kl.key_col
362 {
363 stack.pop();
364 }
365 let idx = frames.len();
366 frames.push(NodeFrame {
367 key_indent: kl.key_col,
368 ty: None,
369 reason_hits: Vec::new(),
370 justification_hits: Vec::new(),
371 from_hits: Vec::new(),
372 to_hits: Vec::new(),
373 trigger_hits: Vec::new(),
374 });
375 stack.push(idx);
376 }
377
378 if let Some(&top) = stack.last()
380 && frames[top].key_indent == kl.key_col
381 {
382 match kl.key.as_str() {
383 "type" => frames[top].ty = Some(kl.value.clone()),
384 "reason" => frames[top].reason_hits.push(KeyHit {
385 line: i,
386 col: kl.key_col,
387 }),
388 "justification" => frames[top].justification_hits.push(KeyHit {
389 line: i,
390 col: kl.key_col,
391 }),
392 "from" => frames[top].from_hits.push(KeyHit {
393 line: i,
394 col: kl.key_col,
395 }),
396 "to" => frames[top].to_hits.push(KeyHit {
397 line: i,
398 col: kl.key_col,
399 }),
400 "trigger" => frames[top].trigger_hits.push(KeyHit {
401 line: i,
402 col: kl.key_col,
403 }),
404 _ => {}
405 }
406 }
407 }
408
409 for f in &frames {
411 if f.ty.as_deref() == Some("dead_end") {
412 for hit in &f.reason_hits {
413 diags.push(LintDiagnostic {
414 rule: LintRuleId::DeadEndReasonAlias,
415 message: "`reason:` on a dead_end node is an alias; canonical key is \
416 `why_failed:`"
417 .to_string(),
418 file: LintFile::Tree,
419 fixable: true,
420 fix: Some(FixCandidate::ReplaceInLine {
421 line: hit.line,
422 start_col: hit.col,
423 end_col: hit.col + "reason".len(),
424 replacement: "why_failed".to_string(),
425 }),
426 });
427 }
428 }
429 if f.ty.as_deref() == Some("decision") {
430 for hit in &f.justification_hits {
431 diags.push(LintDiagnostic {
432 rule: LintRuleId::DecisionRationaleAlias,
433 message: "`justification:` on a decision node is an alias; canonical key is \
434 `rationale:`"
435 .to_string(),
436 file: LintFile::Tree,
437 fixable: true,
438 fix: Some(FixCandidate::ReplaceInLine {
439 line: hit.line,
440 start_col: hit.col,
441 end_col: hit.col + "justification".len(),
442 replacement: "rationale".to_string(),
443 }),
444 });
445 }
446 }
447 if f.ty.as_deref() == Some("pivot") {
448 for (hits, rule, alias, canonical) in [
449 (
450 &f.from_hits,
451 LintRuleId::PivotFromAlias,
452 "from",
453 "prior_direction",
454 ),
455 (&f.to_hits, LintRuleId::PivotToAlias, "to", "new_direction"),
456 (
457 &f.trigger_hits,
458 LintRuleId::PivotTriggerAlias,
459 "trigger",
460 "reason",
461 ),
462 ] {
463 for hit in hits {
464 diags.push(LintDiagnostic {
465 rule,
466 message: format!(
467 "`{alias}:` on a pivot node is an alias; canonical key is \
468 `{canonical}:`"
469 ),
470 file: LintFile::Tree,
471 fixable: true,
472 fix: Some(FixCandidate::ReplaceInLine {
473 line: hit.line,
474 start_col: hit.col,
475 end_col: hit.col + alias.len(),
476 replacement: canonical.to_string(),
477 }),
478 });
479 }
480 }
481 }
482 }
483
484 diags
485}
486
487#[cfg(feature = "native")]
489fn lint_claims(text: &str) -> Vec<LintDiagnostic> {
490 text.lines()
491 .enumerate()
492 .filter_map(|(i, line)| claim_header_drift(line, i))
493 .collect()
494}
495
496#[cfg(feature = "native")]
501fn claim_header_drift(line: &str, line_idx: usize) -> Option<LintDiagnostic> {
502 let ws = leading_spaces(line);
503 let rest = line[ws..].strip_prefix("## ")?;
504 let id_start = ws + 3; let id: String = rest
507 .chars()
508 .take_while(|c| c.is_ascii_alphanumeric())
509 .collect();
510 if !is_canonical_id(&id, 'C') {
511 return None;
512 }
513 let id_end = id_start + id.len();
514
515 let tail = &line[id_end..];
517 let trimmed = tail.trim_start();
518 let leading_ws = tail.len() - trimmed.len();
519 let sep = trimmed.chars().next()?;
520 if !matches!(sep, '—' | '–' | '-') {
522 return None;
523 }
524
525 let after_sep = &trimmed[sep.len_utf8()..];
528 let title = after_sep.trim_start();
529 if title.is_empty() {
530 return None;
531 }
532 let title_ws = after_sep.len() - title.len();
533 let title_start = id_end + leading_ws + sep.len_utf8() + title_ws;
534
535 Some(LintDiagnostic {
536 rule: LintRuleId::ClaimHeaderStyle,
537 message: "claim header uses a dash separator; canonical form is `## <id>: <title>`"
538 .to_string(),
539 file: LintFile::Claims,
540 fixable: true,
541 fix: Some(FixCandidate::ReplaceInLine {
542 line: line_idx,
543 start_col: id_end,
544 end_col: title_start,
545 replacement: ": ".to_string(),
546 }),
547 })
548}
549
550#[cfg(all(test, feature = "native"))]
553mod tests {
554 use super::*;
555
556 fn only(diags: Vec<LintDiagnostic>, rule: LintRuleId) -> LintDiagnostic {
558 let mut hits: Vec<LintDiagnostic> = diags.into_iter().filter(|d| d.rule == rule).collect();
559 assert_eq!(hits.len(), 1, "expected exactly one {rule}, got {hits:?}");
560 hits.pop().unwrap()
561 }
562
563 #[test]
566 fn ara001_root_dialect_is_detected() {
567 let yaml = "\
568root:
569 id: N01
570 type: question
571 title: q
572";
573 let diags = lint_tree(yaml);
574 let d = only(diags, LintRuleId::RootDialect);
575 assert!(d.fixable);
576 match &d.fix {
577 Some(FixCandidate::RewriteRootToTree {
578 root_line,
579 root_indent,
580 block_end_line,
581 }) => {
582 assert_eq!(*root_line, 0);
583 assert_eq!(*root_indent, 0);
584 assert_eq!(*block_end_line, 4); }
586 other => panic!("expected RewriteRootToTree, got {other:?}"),
587 }
588 }
589
590 #[test]
591 fn ara001_tree_dialect_not_flagged() {
592 let yaml = "tree:\n - id: N01\n type: question\n";
593 assert!(
594 lint_tree(yaml)
595 .iter()
596 .all(|d| d.rule != LintRuleId::RootDialect)
597 );
598 }
599
600 #[test]
601 fn ara001_block_end_stops_at_next_top_level_key() {
602 let yaml = "\
603root:
604 id: N01
605 type: question
606meta: trailing
607";
608 let d = only(lint_tree(yaml), LintRuleId::RootDialect);
609 match &d.fix {
610 Some(FixCandidate::RewriteRootToTree { block_end_line, .. }) => {
611 assert_eq!(*block_end_line, 3); }
613 other => panic!("expected RewriteRootToTree, got {other:?}"),
614 }
615 }
616
617 #[test]
620 fn ara002_reason_on_dead_end_is_detected_and_fixable() {
621 let yaml = "\
622tree:
623 - id: N01
624 type: dead_end
625 reason: it diverged
626";
627 let d = only(lint_tree(yaml), LintRuleId::DeadEndReasonAlias);
628 assert!(d.fixable);
629 assert_eq!(d.file, LintFile::Tree);
630 match &d.fix {
631 Some(FixCandidate::ReplaceInLine {
632 line,
633 start_col,
634 end_col,
635 replacement,
636 }) => {
637 assert_eq!(*line, 3); assert_eq!(*start_col, 4); assert_eq!(*end_col, 4 + "reason".len());
640 assert_eq!(replacement, "why_failed");
641 }
642 other => panic!("expected ReplaceInLine, got {other:?}"),
643 }
644 }
645
646 #[test]
647 fn ara002_type_after_reason_still_resolves() {
648 let yaml = "\
650tree:
651 - id: N01
652 reason: it diverged
653 type: dead_end
654";
655 let d = only(lint_tree(yaml), LintRuleId::DeadEndReasonAlias);
656 match &d.fix {
657 Some(FixCandidate::ReplaceInLine { line, .. }) => assert_eq!(*line, 2),
658 other => panic!("expected ReplaceInLine, got {other:?}"),
659 }
660 }
661
662 #[test]
663 fn ara002_reason_on_non_dead_end_not_flagged() {
664 let yaml = "\
665tree:
666 - id: N01
667 type: experiment
668 reason: some prose
669";
670 assert!(
671 lint_tree(yaml)
672 .iter()
673 .all(|d| d.rule != LintRuleId::DeadEndReasonAlias)
674 );
675 }
676
677 #[test]
678 fn ara002_canonical_why_failed_not_flagged() {
679 let yaml = "\
680tree:
681 - id: N01
682 type: dead_end
683 why_failed: it diverged
684";
685 assert!(lint_tree(yaml).is_empty());
686 }
687
688 #[test]
689 fn ara002_siblings_scoped_independently() {
690 let yaml = "\
692tree:
693 - id: N01
694 type: dead_end
695 reason: x
696 - id: N02
697 type: decision
698 reason: y
699";
700 let diags = lint_tree(yaml);
701 let d = only(diags, LintRuleId::DeadEndReasonAlias);
702 match &d.fix {
703 Some(FixCandidate::ReplaceInLine { line, .. }) => assert_eq!(*line, 3),
704 other => panic!("expected ReplaceInLine, got {other:?}"),
705 }
706 }
707
708 #[test]
709 fn ara002_reason_on_nested_dead_end_child_is_detected() {
710 let yaml = "\
711tree:
712 - id: N01
713 type: question
714 children:
715 - id: N02
716 type: dead_end
717 reason: nested
718";
719 let d = only(lint_tree(yaml), LintRuleId::DeadEndReasonAlias);
720 match &d.fix {
721 Some(FixCandidate::ReplaceInLine {
722 line, start_col, ..
723 }) => {
724 assert_eq!(*line, 6);
725 assert_eq!(*start_col, 8); }
727 other => panic!("expected ReplaceInLine, got {other:?}"),
728 }
729 }
730
731 #[test]
734 fn ara003_justification_on_decision_is_detected() {
735 let yaml = "\
736tree:
737 - id: N01
738 type: decision
739 justification: cheaper
740";
741 let d = only(lint_tree(yaml), LintRuleId::DecisionRationaleAlias);
742 assert!(d.fixable);
743 match &d.fix {
744 Some(FixCandidate::ReplaceInLine {
745 line,
746 start_col,
747 end_col,
748 replacement,
749 }) => {
750 assert_eq!(*line, 3);
751 assert_eq!(*start_col, 4);
752 assert_eq!(*end_col, 4 + "justification".len());
753 assert_eq!(replacement, "rationale");
754 }
755 other => panic!("expected ReplaceInLine, got {other:?}"),
756 }
757 }
758
759 #[test]
760 fn ara003_justification_on_non_decision_not_flagged() {
761 let yaml = "\
762tree:
763 - id: N01
764 type: experiment
765 justification: some prose
766";
767 assert!(
768 lint_tree(yaml)
769 .iter()
770 .all(|d| d.rule != LintRuleId::DecisionRationaleAlias)
771 );
772 }
773
774 #[test]
777 fn ara005_from_on_pivot_is_detected_and_fixable() {
778 let yaml = "\
779tree:
780 - id: N01
781 type: pivot
782 from: dense retrieval
783";
784 let d = only(lint_tree(yaml), LintRuleId::PivotFromAlias);
785 assert!(d.fixable);
786 assert_eq!(d.file, LintFile::Tree);
787 match &d.fix {
788 Some(FixCandidate::ReplaceInLine {
789 line,
790 start_col,
791 end_col,
792 replacement,
793 }) => {
794 assert_eq!(*line, 3); assert_eq!(*start_col, 4); assert_eq!(*end_col, 4 + "from".len());
797 assert_eq!(replacement, "prior_direction");
798 }
799 other => panic!("expected ReplaceInLine, got {other:?}"),
800 }
801 }
802
803 #[test]
804 fn ara006_to_on_pivot_is_detected_and_fixable() {
805 let yaml = "\
806tree:
807 - id: N01
808 type: pivot
809 to: sparse retrieval
810";
811 let d = only(lint_tree(yaml), LintRuleId::PivotToAlias);
812 assert!(d.fixable);
813 match &d.fix {
814 Some(FixCandidate::ReplaceInLine {
815 line,
816 start_col,
817 end_col,
818 replacement,
819 }) => {
820 assert_eq!(*line, 3);
821 assert_eq!(*start_col, 4);
822 assert_eq!(*end_col, 4 + "to".len());
823 assert_eq!(replacement, "new_direction");
824 }
825 other => panic!("expected ReplaceInLine, got {other:?}"),
826 }
827 }
828
829 #[test]
830 fn ara007_trigger_on_pivot_is_detected_and_fixable() {
831 let yaml = "\
832tree:
833 - id: N01
834 type: pivot
835 trigger: latency budget
836";
837 let d = only(lint_tree(yaml), LintRuleId::PivotTriggerAlias);
838 assert!(d.fixable);
839 match &d.fix {
840 Some(FixCandidate::ReplaceInLine {
841 line,
842 start_col,
843 end_col,
844 replacement,
845 }) => {
846 assert_eq!(*line, 3);
847 assert_eq!(*start_col, 4);
848 assert_eq!(*end_col, 4 + "trigger".len());
849 assert_eq!(replacement, "reason");
850 }
851 other => panic!("expected ReplaceInLine, got {other:?}"),
852 }
853 }
854
855 #[test]
856 fn pivot_aliases_on_non_pivot_kinds_not_flagged() {
857 let yaml = "\
860tree:
861 - id: N01
862 type: experiment
863 from: baseline
864 to: candidate
865 trigger: schedule
866 - id: N02
867 type: dead_end
868 from: a
869 to: b
870 trigger: c
871 - id: N03
872 type: decision
873 trigger: d
874";
875 let diags = lint_tree(yaml);
876 assert!(
877 diags.iter().all(|d| !matches!(
878 d.rule,
879 LintRuleId::PivotFromAlias
880 | LintRuleId::PivotToAlias
881 | LintRuleId::PivotTriggerAlias
882 )),
883 "no pivot-alias rule may fire on non-pivot kinds, got: {diags:?}"
884 );
885 }
886
887 #[test]
888 fn pivot_alias_type_after_key_still_resolves() {
889 let yaml = "\
891tree:
892 - id: N01
893 from: dense retrieval
894 to: sparse retrieval
895 trigger: latency budget
896 type: pivot
897";
898 let diags = lint_tree(yaml);
899 for rule in [
900 LintRuleId::PivotFromAlias,
901 LintRuleId::PivotToAlias,
902 LintRuleId::PivotTriggerAlias,
903 ] {
904 let d = only(diags.clone(), rule);
905 match &d.fix {
906 Some(FixCandidate::ReplaceInLine { line, .. }) => {
907 assert!((2..=4).contains(line), "{rule} at line {line}");
908 }
909 other => panic!("expected ReplaceInLine, got {other:?}"),
910 }
911 }
912 }
913
914 #[test]
915 fn pivot_alias_on_nested_child_is_detected() {
916 let yaml = "\
917tree:
918 - id: N01
919 type: question
920 children:
921 - id: N02
922 type: pivot
923 trigger: nested
924";
925 let d = only(lint_tree(yaml), LintRuleId::PivotTriggerAlias);
926 match &d.fix {
927 Some(FixCandidate::ReplaceInLine {
928 line, start_col, ..
929 }) => {
930 assert_eq!(*line, 6);
931 assert_eq!(*start_col, 8); }
933 other => panic!("expected ReplaceInLine, got {other:?}"),
934 }
935 }
936
937 #[test]
938 fn canonical_pivot_keys_not_flagged() {
939 let yaml = "\
940tree:
941 - id: N01
942 type: pivot
943 prior_direction: dense retrieval
944 new_direction: sparse retrieval
945 reason: latency budget
946";
947 assert!(lint_tree(yaml).is_empty());
948 }
949
950 #[test]
951 fn ara002_dead_end_reason_and_ara007_pivot_trigger_do_not_collide() {
952 let yaml = "\
955tree:
956 - id: N01
957 type: dead_end
958 reason: diverged
959 - id: N02
960 type: pivot
961 reason: canonical
962 trigger: aliased
963";
964 let diags = lint_tree(yaml);
965 let dead_end = only(diags.clone(), LintRuleId::DeadEndReasonAlias);
966 match &dead_end.fix {
967 Some(FixCandidate::ReplaceInLine { line, .. }) => assert_eq!(*line, 3),
968 other => panic!("expected ReplaceInLine, got {other:?}"),
969 }
970 let pivot = only(diags, LintRuleId::PivotTriggerAlias);
971 match &pivot.fix {
972 Some(FixCandidate::ReplaceInLine { line, .. }) => assert_eq!(*line, 7),
973 other => panic!("expected ReplaceInLine, got {other:?}"),
974 }
975 }
976
977 #[test]
980 fn ara004_em_dash_header_is_detected() {
981 let md = "## C01 — Attention is all you need";
982 let d = only(lint_claims(md), LintRuleId::ClaimHeaderStyle);
983 assert!(d.fixable);
984 assert_eq!(d.file, LintFile::Claims);
985 match &d.fix {
986 Some(FixCandidate::ReplaceInLine {
987 line,
988 start_col,
989 end_col,
990 replacement,
991 }) => {
992 assert_eq!(*line, 0);
993 assert_eq!(*start_col, 6); assert_eq!(replacement, ": ");
995 let fixed = format!("{}{}{}", &md[..*start_col], replacement, &md[*end_col..]);
997 assert_eq!(fixed, "## C01: Attention is all you need");
998 }
999 other => panic!("expected ReplaceInLine, got {other:?}"),
1000 }
1001 }
1002
1003 #[test]
1004 fn ara004_hyphen_header_is_detected() {
1005 let md = "## C02 - Faster training";
1006 let d = only(lint_claims(md), LintRuleId::ClaimHeaderStyle);
1007 match &d.fix {
1008 Some(FixCandidate::ReplaceInLine {
1009 start_col,
1010 end_col,
1011 replacement,
1012 ..
1013 }) => {
1014 let fixed = format!("{}{}{}", &md[..*start_col], replacement, &md[*end_col..]);
1015 assert_eq!(fixed, "## C02: Faster training");
1016 }
1017 other => panic!("expected ReplaceInLine, got {other:?}"),
1018 }
1019 }
1020
1021 #[test]
1022 fn ara004_colon_header_not_flagged() {
1023 assert!(lint_claims("## C01: Attention is all you need").is_empty());
1024 }
1025
1026 #[test]
1027 fn ara004_non_claim_dash_header_not_flagged() {
1028 assert!(lint_claims("## Overview — background").is_empty());
1030 }
1031
1032 #[test]
1033 fn ara004_hyphen_in_title_with_colon_not_flagged() {
1034 assert!(lint_claims("## C01: Multi-head attention").is_empty());
1036 }
1037
1038 #[test]
1041 fn check_dir_tolerates_missing_claims_and_does_not_panic() {
1042 use std::sync::atomic::{AtomicUsize, Ordering};
1043 static CTR: AtomicUsize = AtomicUsize::new(0);
1044
1045 let n = CTR.fetch_add(1, Ordering::Relaxed);
1046 let dir = std::env::temp_dir().join(format!("ara_lint_test_{}_{n}", std::process::id()));
1047 std::fs::create_dir_all(dir.join("trace")).unwrap();
1048 std::fs::write(
1049 dir.join("trace/exploration_tree.yaml"),
1050 "root:\n id: N01\n type: question\n",
1051 )
1052 .unwrap();
1053 let report = check_dir(&dir);
1056 assert!(
1057 report
1058 .diagnostics()
1059 .iter()
1060 .any(|d| d.rule == LintRuleId::RootDialect)
1061 );
1062 assert_eq!(report.fixable(), report.diagnostics().len());
1063 assert!(!report.is_empty());
1064
1065 std::fs::remove_dir_all(&dir).ok();
1066 }
1067
1068 #[test]
1069 fn check_dir_missing_tree_yields_empty_report() {
1070 use std::sync::atomic::{AtomicUsize, Ordering};
1071 static CTR: AtomicUsize = AtomicUsize::new(0);
1072
1073 let n = CTR.fetch_add(1, Ordering::Relaxed);
1074 let dir = std::env::temp_dir().join(format!("ara_lint_empty_{}_{n}", std::process::id()));
1075 std::fs::create_dir_all(&dir).unwrap();
1076
1077 let report = check_dir(&dir);
1078 assert!(report.is_empty());
1079
1080 std::fs::remove_dir_all(&dir).ok();
1081 }
1082}