1use serde::Serialize;
21
22#[cfg(feature = "native")]
23use crate::manifest::is_canonical_id;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27pub enum LintRuleId {
28 #[serde(rename = "ARA001")]
30 RootDialect,
31 #[serde(rename = "ARA002")]
33 DeadEndReasonAlias,
34 #[serde(rename = "ARA003")]
37 DecisionRationaleAlias,
38 #[serde(rename = "ARA004")]
40 ClaimHeaderStyle,
41}
42
43impl LintRuleId {
44 pub fn as_str(&self) -> &'static str {
46 match self {
47 LintRuleId::RootDialect => "ARA001",
48 LintRuleId::DeadEndReasonAlias => "ARA002",
49 LintRuleId::DecisionRationaleAlias => "ARA003",
50 LintRuleId::ClaimHeaderStyle => "ARA004",
51 }
52 }
53}
54
55impl std::fmt::Display for LintRuleId {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.write_str(self.as_str())
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum LintFile {
65 Tree,
67 Claims,
69}
70
71impl LintFile {
72 pub fn relative_path(&self) -> &'static str {
74 match self {
75 LintFile::Tree => "trace/exploration_tree.yaml",
76 LintFile::Claims => "logic/claims.md",
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize)]
84pub enum FixCandidate {
85 ReplaceInLine {
89 line: usize,
91 start_col: usize,
93 end_col: usize,
95 replacement: String,
97 },
98 RewriteRootToTree {
103 root_line: usize,
105 root_indent: usize,
107 block_end_line: usize,
110 },
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize)]
115pub struct LintDiagnostic {
116 pub rule: LintRuleId,
118 pub message: String,
120 pub file: LintFile,
122 pub fixable: bool,
124 pub fix: Option<FixCandidate>,
126}
127
128#[derive(Debug, Clone, Default, PartialEq, Serialize)]
130pub struct LintReport {
131 pub diagnostics: Vec<LintDiagnostic>,
133}
134
135impl LintReport {
136 pub fn diagnostics(&self) -> &[LintDiagnostic] {
138 &self.diagnostics
139 }
140
141 pub fn is_empty(&self) -> bool {
143 self.diagnostics.is_empty()
144 }
145
146 pub fn fixable(&self) -> usize {
148 self.diagnostics.iter().filter(|d| d.fixable).count()
149 }
150}
151
152#[cfg(feature = "native")]
160pub fn check_dir(dir: &std::path::Path) -> LintReport {
161 let tree = std::fs::read_to_string(dir.join("trace/exploration_tree.yaml")).ok();
162 let claims = std::fs::read_to_string(dir.join("logic/claims.md")).ok();
163 check_sources(tree.as_deref().unwrap_or_default(), claims.as_deref())
164}
165
166#[cfg(feature = "native")]
175pub fn check_sources(tree_yaml: &str, claims_md: Option<&str>) -> LintReport {
176 let mut diagnostics = lint_tree(tree_yaml);
177 if let Some(md) = claims_md {
178 diagnostics.extend(lint_claims(md));
179 }
180 LintReport { diagnostics }
181}
182
183#[cfg(feature = "native")]
185struct KeyLine {
186 key: String,
188 value: String,
190 is_list_item: bool,
192 key_col: usize,
195}
196
197#[cfg(feature = "native")]
199struct KeyHit {
200 line: usize,
201 col: usize,
202}
203
204#[cfg(feature = "native")]
207struct NodeFrame {
208 key_indent: usize,
210 ty: Option<String>,
212 reason_hits: Vec<KeyHit>,
214 justification_hits: Vec<KeyHit>,
216}
217
218#[cfg(feature = "native")]
220fn leading_spaces(s: &str) -> usize {
221 s.len() - s.trim_start_matches(' ').len()
222}
223
224#[cfg(feature = "native")]
229fn parse_key_line(line: &str) -> Option<KeyLine> {
230 let indent = leading_spaces(line);
231 let after = &line[indent..];
232 if after.is_empty() || after.starts_with('#') {
233 return None;
234 }
235
236 let (is_list_item, content, base) = match after.strip_prefix("- ") {
237 Some(rest) => {
238 let extra = leading_spaces(rest);
239 (true, &rest[extra..], indent + 2 + extra)
240 }
241 None => (false, after, indent),
242 };
243
244 let colon = content.find(':')?;
245 let key = &content[..colon];
246 if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
249 return None;
250 }
251 let after_colon = &content[colon + 1..];
254 if !(after_colon.is_empty() || after_colon.starts_with(' ')) {
255 return None;
256 }
257
258 Some(KeyLine {
259 key: key.to_string(),
260 value: after_colon.trim().to_string(),
261 is_list_item,
262 key_col: base,
263 })
264}
265
266#[cfg(feature = "native")]
270fn root_block_end(lines: &[&str], root_line: usize) -> usize {
271 let mut j = root_line + 1;
272 while j < lines.len() {
273 let l = lines[j];
274 if l.trim().is_empty() {
275 j += 1;
276 continue;
277 }
278 if leading_spaces(l) == 0 {
279 break;
280 }
281 j += 1;
282 }
283 j
284}
285
286#[cfg(feature = "native")]
295fn lint_tree(text: &str) -> Vec<LintDiagnostic> {
296 let lines: Vec<&str> = text.lines().collect();
297 let mut diags = Vec::new();
298 let mut frames: Vec<NodeFrame> = Vec::new();
299 let mut stack: Vec<usize> = Vec::new();
300
301 for (i, line) in lines.iter().enumerate() {
302 let Some(kl) = parse_key_line(line) else {
303 continue;
304 };
305
306 if !kl.is_list_item && kl.key_col == 0 && kl.key == "root" {
308 diags.push(LintDiagnostic {
309 rule: LintRuleId::RootDialect,
310 message: "top-level `root:` uses the single-node dialect; canonical form is a \
311 `tree:` list with one element"
312 .to_string(),
313 file: LintFile::Tree,
314 fixable: true,
315 fix: Some(FixCandidate::RewriteRootToTree {
316 root_line: i,
317 root_indent: 0,
318 block_end_line: root_block_end(&lines, i),
319 }),
320 });
321 continue;
322 }
323
324 while let Some(&top) = stack.last() {
326 if frames[top].key_indent > kl.key_col {
327 stack.pop();
328 } else {
329 break;
330 }
331 }
332
333 if kl.is_list_item {
334 if let Some(&top) = stack.last()
337 && frames[top].key_indent == kl.key_col
338 {
339 stack.pop();
340 }
341 let idx = frames.len();
342 frames.push(NodeFrame {
343 key_indent: kl.key_col,
344 ty: None,
345 reason_hits: Vec::new(),
346 justification_hits: Vec::new(),
347 });
348 stack.push(idx);
349 }
350
351 if let Some(&top) = stack.last()
353 && frames[top].key_indent == kl.key_col
354 {
355 match kl.key.as_str() {
356 "type" => frames[top].ty = Some(kl.value.clone()),
357 "reason" => frames[top].reason_hits.push(KeyHit {
358 line: i,
359 col: kl.key_col,
360 }),
361 "justification" => frames[top].justification_hits.push(KeyHit {
362 line: i,
363 col: kl.key_col,
364 }),
365 _ => {}
366 }
367 }
368 }
369
370 for f in &frames {
372 if f.ty.as_deref() == Some("dead_end") {
373 for hit in &f.reason_hits {
374 diags.push(LintDiagnostic {
375 rule: LintRuleId::DeadEndReasonAlias,
376 message: "`reason:` on a dead_end node is an alias; canonical key is \
377 `why_failed:`"
378 .to_string(),
379 file: LintFile::Tree,
380 fixable: true,
381 fix: Some(FixCandidate::ReplaceInLine {
382 line: hit.line,
383 start_col: hit.col,
384 end_col: hit.col + "reason".len(),
385 replacement: "why_failed".to_string(),
386 }),
387 });
388 }
389 }
390 if f.ty.as_deref() == Some("decision") {
391 for hit in &f.justification_hits {
392 diags.push(LintDiagnostic {
393 rule: LintRuleId::DecisionRationaleAlias,
394 message: "`justification:` on a decision node is an alias; canonical key is \
395 `rationale:`"
396 .to_string(),
397 file: LintFile::Tree,
398 fixable: true,
399 fix: Some(FixCandidate::ReplaceInLine {
400 line: hit.line,
401 start_col: hit.col,
402 end_col: hit.col + "justification".len(),
403 replacement: "rationale".to_string(),
404 }),
405 });
406 }
407 }
408 }
409
410 diags
411}
412
413#[cfg(feature = "native")]
415fn lint_claims(text: &str) -> Vec<LintDiagnostic> {
416 text.lines()
417 .enumerate()
418 .filter_map(|(i, line)| claim_header_drift(line, i))
419 .collect()
420}
421
422#[cfg(feature = "native")]
427fn claim_header_drift(line: &str, line_idx: usize) -> Option<LintDiagnostic> {
428 let ws = leading_spaces(line);
429 let rest = line[ws..].strip_prefix("## ")?;
430 let id_start = ws + 3; let id: String = rest
433 .chars()
434 .take_while(|c| c.is_ascii_alphanumeric())
435 .collect();
436 if !is_canonical_id(&id, 'C') {
437 return None;
438 }
439 let id_end = id_start + id.len();
440
441 let tail = &line[id_end..];
443 let trimmed = tail.trim_start();
444 let leading_ws = tail.len() - trimmed.len();
445 let sep = trimmed.chars().next()?;
446 if !matches!(sep, '—' | '–' | '-') {
448 return None;
449 }
450
451 let after_sep = &trimmed[sep.len_utf8()..];
454 let title = after_sep.trim_start();
455 if title.is_empty() {
456 return None;
457 }
458 let title_ws = after_sep.len() - title.len();
459 let title_start = id_end + leading_ws + sep.len_utf8() + title_ws;
460
461 Some(LintDiagnostic {
462 rule: LintRuleId::ClaimHeaderStyle,
463 message: "claim header uses a dash separator; canonical form is `## <id>: <title>`"
464 .to_string(),
465 file: LintFile::Claims,
466 fixable: true,
467 fix: Some(FixCandidate::ReplaceInLine {
468 line: line_idx,
469 start_col: id_end,
470 end_col: title_start,
471 replacement: ": ".to_string(),
472 }),
473 })
474}
475
476#[cfg(all(test, feature = "native"))]
479mod tests {
480 use super::*;
481
482 fn only(diags: Vec<LintDiagnostic>, rule: LintRuleId) -> LintDiagnostic {
484 let mut hits: Vec<LintDiagnostic> = diags.into_iter().filter(|d| d.rule == rule).collect();
485 assert_eq!(hits.len(), 1, "expected exactly one {rule}, got {hits:?}");
486 hits.pop().unwrap()
487 }
488
489 #[test]
492 fn ara001_root_dialect_is_detected() {
493 let yaml = "\
494root:
495 id: N01
496 type: question
497 title: q
498";
499 let diags = lint_tree(yaml);
500 let d = only(diags, LintRuleId::RootDialect);
501 assert!(d.fixable);
502 match &d.fix {
503 Some(FixCandidate::RewriteRootToTree {
504 root_line,
505 root_indent,
506 block_end_line,
507 }) => {
508 assert_eq!(*root_line, 0);
509 assert_eq!(*root_indent, 0);
510 assert_eq!(*block_end_line, 4); }
512 other => panic!("expected RewriteRootToTree, got {other:?}"),
513 }
514 }
515
516 #[test]
517 fn ara001_tree_dialect_not_flagged() {
518 let yaml = "tree:\n - id: N01\n type: question\n";
519 assert!(
520 lint_tree(yaml)
521 .iter()
522 .all(|d| d.rule != LintRuleId::RootDialect)
523 );
524 }
525
526 #[test]
527 fn ara001_block_end_stops_at_next_top_level_key() {
528 let yaml = "\
529root:
530 id: N01
531 type: question
532meta: trailing
533";
534 let d = only(lint_tree(yaml), LintRuleId::RootDialect);
535 match &d.fix {
536 Some(FixCandidate::RewriteRootToTree { block_end_line, .. }) => {
537 assert_eq!(*block_end_line, 3); }
539 other => panic!("expected RewriteRootToTree, got {other:?}"),
540 }
541 }
542
543 #[test]
546 fn ara002_reason_on_dead_end_is_detected_and_fixable() {
547 let yaml = "\
548tree:
549 - id: N01
550 type: dead_end
551 reason: it diverged
552";
553 let d = only(lint_tree(yaml), LintRuleId::DeadEndReasonAlias);
554 assert!(d.fixable);
555 assert_eq!(d.file, LintFile::Tree);
556 match &d.fix {
557 Some(FixCandidate::ReplaceInLine {
558 line,
559 start_col,
560 end_col,
561 replacement,
562 }) => {
563 assert_eq!(*line, 3); assert_eq!(*start_col, 4); assert_eq!(*end_col, 4 + "reason".len());
566 assert_eq!(replacement, "why_failed");
567 }
568 other => panic!("expected ReplaceInLine, got {other:?}"),
569 }
570 }
571
572 #[test]
573 fn ara002_type_after_reason_still_resolves() {
574 let yaml = "\
576tree:
577 - id: N01
578 reason: it diverged
579 type: dead_end
580";
581 let d = only(lint_tree(yaml), LintRuleId::DeadEndReasonAlias);
582 match &d.fix {
583 Some(FixCandidate::ReplaceInLine { line, .. }) => assert_eq!(*line, 2),
584 other => panic!("expected ReplaceInLine, got {other:?}"),
585 }
586 }
587
588 #[test]
589 fn ara002_reason_on_non_dead_end_not_flagged() {
590 let yaml = "\
591tree:
592 - id: N01
593 type: experiment
594 reason: some prose
595";
596 assert!(
597 lint_tree(yaml)
598 .iter()
599 .all(|d| d.rule != LintRuleId::DeadEndReasonAlias)
600 );
601 }
602
603 #[test]
604 fn ara002_canonical_why_failed_not_flagged() {
605 let yaml = "\
606tree:
607 - id: N01
608 type: dead_end
609 why_failed: it diverged
610";
611 assert!(lint_tree(yaml).is_empty());
612 }
613
614 #[test]
615 fn ara002_siblings_scoped_independently() {
616 let yaml = "\
618tree:
619 - id: N01
620 type: dead_end
621 reason: x
622 - id: N02
623 type: decision
624 reason: y
625";
626 let diags = lint_tree(yaml);
627 let d = only(diags, LintRuleId::DeadEndReasonAlias);
628 match &d.fix {
629 Some(FixCandidate::ReplaceInLine { line, .. }) => assert_eq!(*line, 3),
630 other => panic!("expected ReplaceInLine, got {other:?}"),
631 }
632 }
633
634 #[test]
635 fn ara002_reason_on_nested_dead_end_child_is_detected() {
636 let yaml = "\
637tree:
638 - id: N01
639 type: question
640 children:
641 - id: N02
642 type: dead_end
643 reason: nested
644";
645 let d = only(lint_tree(yaml), LintRuleId::DeadEndReasonAlias);
646 match &d.fix {
647 Some(FixCandidate::ReplaceInLine {
648 line, start_col, ..
649 }) => {
650 assert_eq!(*line, 6);
651 assert_eq!(*start_col, 8); }
653 other => panic!("expected ReplaceInLine, got {other:?}"),
654 }
655 }
656
657 #[test]
660 fn ara003_justification_on_decision_is_detected() {
661 let yaml = "\
662tree:
663 - id: N01
664 type: decision
665 justification: cheaper
666";
667 let d = only(lint_tree(yaml), LintRuleId::DecisionRationaleAlias);
668 assert!(d.fixable);
669 match &d.fix {
670 Some(FixCandidate::ReplaceInLine {
671 line,
672 start_col,
673 end_col,
674 replacement,
675 }) => {
676 assert_eq!(*line, 3);
677 assert_eq!(*start_col, 4);
678 assert_eq!(*end_col, 4 + "justification".len());
679 assert_eq!(replacement, "rationale");
680 }
681 other => panic!("expected ReplaceInLine, got {other:?}"),
682 }
683 }
684
685 #[test]
686 fn ara003_justification_on_non_decision_not_flagged() {
687 let yaml = "\
688tree:
689 - id: N01
690 type: experiment
691 justification: some prose
692";
693 assert!(
694 lint_tree(yaml)
695 .iter()
696 .all(|d| d.rule != LintRuleId::DecisionRationaleAlias)
697 );
698 }
699
700 #[test]
703 fn ara004_em_dash_header_is_detected() {
704 let md = "## C01 — Attention is all you need";
705 let d = only(lint_claims(md), LintRuleId::ClaimHeaderStyle);
706 assert!(d.fixable);
707 assert_eq!(d.file, LintFile::Claims);
708 match &d.fix {
709 Some(FixCandidate::ReplaceInLine {
710 line,
711 start_col,
712 end_col,
713 replacement,
714 }) => {
715 assert_eq!(*line, 0);
716 assert_eq!(*start_col, 6); assert_eq!(replacement, ": ");
718 let fixed = format!("{}{}{}", &md[..*start_col], replacement, &md[*end_col..]);
720 assert_eq!(fixed, "## C01: Attention is all you need");
721 }
722 other => panic!("expected ReplaceInLine, got {other:?}"),
723 }
724 }
725
726 #[test]
727 fn ara004_hyphen_header_is_detected() {
728 let md = "## C02 - Faster training";
729 let d = only(lint_claims(md), LintRuleId::ClaimHeaderStyle);
730 match &d.fix {
731 Some(FixCandidate::ReplaceInLine {
732 start_col,
733 end_col,
734 replacement,
735 ..
736 }) => {
737 let fixed = format!("{}{}{}", &md[..*start_col], replacement, &md[*end_col..]);
738 assert_eq!(fixed, "## C02: Faster training");
739 }
740 other => panic!("expected ReplaceInLine, got {other:?}"),
741 }
742 }
743
744 #[test]
745 fn ara004_colon_header_not_flagged() {
746 assert!(lint_claims("## C01: Attention is all you need").is_empty());
747 }
748
749 #[test]
750 fn ara004_non_claim_dash_header_not_flagged() {
751 assert!(lint_claims("## Overview — background").is_empty());
753 }
754
755 #[test]
756 fn ara004_hyphen_in_title_with_colon_not_flagged() {
757 assert!(lint_claims("## C01: Multi-head attention").is_empty());
759 }
760
761 #[test]
764 fn check_dir_tolerates_missing_claims_and_does_not_panic() {
765 use std::sync::atomic::{AtomicUsize, Ordering};
766 static CTR: AtomicUsize = AtomicUsize::new(0);
767
768 let n = CTR.fetch_add(1, Ordering::Relaxed);
769 let dir = std::env::temp_dir().join(format!("ara_lint_test_{}_{n}", std::process::id()));
770 std::fs::create_dir_all(dir.join("trace")).unwrap();
771 std::fs::write(
772 dir.join("trace/exploration_tree.yaml"),
773 "root:\n id: N01\n type: question\n",
774 )
775 .unwrap();
776 let report = check_dir(&dir);
779 assert!(
780 report
781 .diagnostics()
782 .iter()
783 .any(|d| d.rule == LintRuleId::RootDialect)
784 );
785 assert_eq!(report.fixable(), report.diagnostics().len());
786 assert!(!report.is_empty());
787
788 std::fs::remove_dir_all(&dir).ok();
789 }
790
791 #[test]
792 fn check_dir_missing_tree_yields_empty_report() {
793 use std::sync::atomic::{AtomicUsize, Ordering};
794 static CTR: AtomicUsize = AtomicUsize::new(0);
795
796 let n = CTR.fetch_add(1, Ordering::Relaxed);
797 let dir = std::env::temp_dir().join(format!("ara_lint_empty_{}_{n}", std::process::id()));
798 std::fs::create_dir_all(&dir).unwrap();
799
800 let report = check_dir(&dir);
801 assert!(report.is_empty());
802
803 std::fs::remove_dir_all(&dir).ok();
804 }
805}