use serde::Serialize;
#[cfg(feature = "native")]
use crate::manifest::is_canonical_id;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum LintRuleId {
#[serde(rename = "ARA001")]
RootDialect,
#[serde(rename = "ARA002")]
DeadEndReasonAlias,
#[serde(rename = "ARA003")]
DecisionRationaleAlias,
#[serde(rename = "ARA004")]
ClaimHeaderStyle,
}
impl LintRuleId {
pub fn as_str(&self) -> &'static str {
match self {
LintRuleId::RootDialect => "ARA001",
LintRuleId::DeadEndReasonAlias => "ARA002",
LintRuleId::DecisionRationaleAlias => "ARA003",
LintRuleId::ClaimHeaderStyle => "ARA004",
}
}
}
impl std::fmt::Display for LintRuleId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LintFile {
Tree,
Claims,
}
impl LintFile {
pub fn relative_path(&self) -> &'static str {
match self {
LintFile::Tree => "trace/exploration_tree.yaml",
LintFile::Claims => "logic/claims.md",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum FixCandidate {
ReplaceInLine {
line: usize,
start_col: usize,
end_col: usize,
replacement: String,
},
RewriteRootToTree {
root_line: usize,
root_indent: usize,
block_end_line: usize,
},
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct LintDiagnostic {
pub rule: LintRuleId,
pub message: String,
pub file: LintFile,
pub fixable: bool,
pub fix: Option<FixCandidate>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct LintReport {
pub diagnostics: Vec<LintDiagnostic>,
}
impl LintReport {
pub fn diagnostics(&self) -> &[LintDiagnostic] {
&self.diagnostics
}
pub fn is_empty(&self) -> bool {
self.diagnostics.is_empty()
}
pub fn fixable(&self) -> usize {
self.diagnostics.iter().filter(|d| d.fixable).count()
}
}
#[cfg(feature = "native")]
pub fn check_dir(dir: &std::path::Path) -> LintReport {
let tree = std::fs::read_to_string(dir.join("trace/exploration_tree.yaml")).ok();
let claims = std::fs::read_to_string(dir.join("logic/claims.md")).ok();
check_sources(tree.as_deref().unwrap_or_default(), claims.as_deref())
}
#[cfg(feature = "native")]
pub fn check_sources(tree_yaml: &str, claims_md: Option<&str>) -> LintReport {
let mut diagnostics = lint_tree(tree_yaml);
if let Some(md) = claims_md {
diagnostics.extend(lint_claims(md));
}
LintReport { diagnostics }
}
#[cfg(feature = "native")]
struct KeyLine {
key: String,
value: String,
is_list_item: bool,
key_col: usize,
}
#[cfg(feature = "native")]
struct KeyHit {
line: usize,
col: usize,
}
#[cfg(feature = "native")]
struct NodeFrame {
key_indent: usize,
ty: Option<String>,
reason_hits: Vec<KeyHit>,
justification_hits: Vec<KeyHit>,
}
#[cfg(feature = "native")]
fn leading_spaces(s: &str) -> usize {
s.len() - s.trim_start_matches(' ').len()
}
#[cfg(feature = "native")]
fn parse_key_line(line: &str) -> Option<KeyLine> {
let indent = leading_spaces(line);
let after = &line[indent..];
if after.is_empty() || after.starts_with('#') {
return None;
}
let (is_list_item, content, base) = match after.strip_prefix("- ") {
Some(rest) => {
let extra = leading_spaces(rest);
(true, &rest[extra..], indent + 2 + extra)
}
None => (false, after, indent),
};
let colon = content.find(':')?;
let key = &content[..colon];
if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
return None;
}
let after_colon = &content[colon + 1..];
if !(after_colon.is_empty() || after_colon.starts_with(' ')) {
return None;
}
Some(KeyLine {
key: key.to_string(),
value: after_colon.trim().to_string(),
is_list_item,
key_col: base,
})
}
#[cfg(feature = "native")]
fn root_block_end(lines: &[&str], root_line: usize) -> usize {
let mut j = root_line + 1;
while j < lines.len() {
let l = lines[j];
if l.trim().is_empty() {
j += 1;
continue;
}
if leading_spaces(l) == 0 {
break;
}
j += 1;
}
j
}
#[cfg(feature = "native")]
fn lint_tree(text: &str) -> Vec<LintDiagnostic> {
let lines: Vec<&str> = text.lines().collect();
let mut diags = Vec::new();
let mut frames: Vec<NodeFrame> = Vec::new();
let mut stack: Vec<usize> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let Some(kl) = parse_key_line(line) else {
continue;
};
if !kl.is_list_item && kl.key_col == 0 && kl.key == "root" {
diags.push(LintDiagnostic {
rule: LintRuleId::RootDialect,
message: "top-level `root:` uses the single-node dialect; canonical form is a \
`tree:` list with one element"
.to_string(),
file: LintFile::Tree,
fixable: true,
fix: Some(FixCandidate::RewriteRootToTree {
root_line: i,
root_indent: 0,
block_end_line: root_block_end(&lines, i),
}),
});
continue;
}
while let Some(&top) = stack.last() {
if frames[top].key_indent > kl.key_col {
stack.pop();
} else {
break;
}
}
if kl.is_list_item {
if let Some(&top) = stack.last()
&& frames[top].key_indent == kl.key_col
{
stack.pop();
}
let idx = frames.len();
frames.push(NodeFrame {
key_indent: kl.key_col,
ty: None,
reason_hits: Vec::new(),
justification_hits: Vec::new(),
});
stack.push(idx);
}
if let Some(&top) = stack.last()
&& frames[top].key_indent == kl.key_col
{
match kl.key.as_str() {
"type" => frames[top].ty = Some(kl.value.clone()),
"reason" => frames[top].reason_hits.push(KeyHit {
line: i,
col: kl.key_col,
}),
"justification" => frames[top].justification_hits.push(KeyHit {
line: i,
col: kl.key_col,
}),
_ => {}
}
}
}
for f in &frames {
if f.ty.as_deref() == Some("dead_end") {
for hit in &f.reason_hits {
diags.push(LintDiagnostic {
rule: LintRuleId::DeadEndReasonAlias,
message: "`reason:` on a dead_end node is an alias; canonical key is \
`why_failed:`"
.to_string(),
file: LintFile::Tree,
fixable: true,
fix: Some(FixCandidate::ReplaceInLine {
line: hit.line,
start_col: hit.col,
end_col: hit.col + "reason".len(),
replacement: "why_failed".to_string(),
}),
});
}
}
if f.ty.as_deref() == Some("decision") {
for hit in &f.justification_hits {
diags.push(LintDiagnostic {
rule: LintRuleId::DecisionRationaleAlias,
message: "`justification:` on a decision node is an alias; canonical key is \
`rationale:`"
.to_string(),
file: LintFile::Tree,
fixable: true,
fix: Some(FixCandidate::ReplaceInLine {
line: hit.line,
start_col: hit.col,
end_col: hit.col + "justification".len(),
replacement: "rationale".to_string(),
}),
});
}
}
}
diags
}
#[cfg(feature = "native")]
fn lint_claims(text: &str) -> Vec<LintDiagnostic> {
text.lines()
.enumerate()
.filter_map(|(i, line)| claim_header_drift(line, i))
.collect()
}
#[cfg(feature = "native")]
fn claim_header_drift(line: &str, line_idx: usize) -> Option<LintDiagnostic> {
let ws = leading_spaces(line);
let rest = line[ws..].strip_prefix("## ")?;
let id_start = ws + 3;
let id: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric())
.collect();
if !is_canonical_id(&id, 'C') {
return None;
}
let id_end = id_start + id.len();
let tail = &line[id_end..];
let trimmed = tail.trim_start();
let leading_ws = tail.len() - trimmed.len();
let sep = trimmed.chars().next()?;
if !matches!(sep, '—' | '–' | '-') {
return None;
}
let after_sep = &trimmed[sep.len_utf8()..];
let title = after_sep.trim_start();
if title.is_empty() {
return None;
}
let title_ws = after_sep.len() - title.len();
let title_start = id_end + leading_ws + sep.len_utf8() + title_ws;
Some(LintDiagnostic {
rule: LintRuleId::ClaimHeaderStyle,
message: "claim header uses a dash separator; canonical form is `## <id>: <title>`"
.to_string(),
file: LintFile::Claims,
fixable: true,
fix: Some(FixCandidate::ReplaceInLine {
line: line_idx,
start_col: id_end,
end_col: title_start,
replacement: ": ".to_string(),
}),
})
}
#[cfg(all(test, feature = "native"))]
mod tests {
use super::*;
fn only(diags: Vec<LintDiagnostic>, rule: LintRuleId) -> LintDiagnostic {
let mut hits: Vec<LintDiagnostic> = diags.into_iter().filter(|d| d.rule == rule).collect();
assert_eq!(hits.len(), 1, "expected exactly one {rule}, got {hits:?}");
hits.pop().unwrap()
}
#[test]
fn ara001_root_dialect_is_detected() {
let yaml = "\
root:
id: N01
type: question
title: q
";
let diags = lint_tree(yaml);
let d = only(diags, LintRuleId::RootDialect);
assert!(d.fixable);
match &d.fix {
Some(FixCandidate::RewriteRootToTree {
root_line,
root_indent,
block_end_line,
}) => {
assert_eq!(*root_line, 0);
assert_eq!(*root_indent, 0);
assert_eq!(*block_end_line, 4); }
other => panic!("expected RewriteRootToTree, got {other:?}"),
}
}
#[test]
fn ara001_tree_dialect_not_flagged() {
let yaml = "tree:\n - id: N01\n type: question\n";
assert!(
lint_tree(yaml)
.iter()
.all(|d| d.rule != LintRuleId::RootDialect)
);
}
#[test]
fn ara001_block_end_stops_at_next_top_level_key() {
let yaml = "\
root:
id: N01
type: question
meta: trailing
";
let d = only(lint_tree(yaml), LintRuleId::RootDialect);
match &d.fix {
Some(FixCandidate::RewriteRootToTree { block_end_line, .. }) => {
assert_eq!(*block_end_line, 3); }
other => panic!("expected RewriteRootToTree, got {other:?}"),
}
}
#[test]
fn ara002_reason_on_dead_end_is_detected_and_fixable() {
let yaml = "\
tree:
- id: N01
type: dead_end
reason: it diverged
";
let d = only(lint_tree(yaml), LintRuleId::DeadEndReasonAlias);
assert!(d.fixable);
assert_eq!(d.file, LintFile::Tree);
match &d.fix {
Some(FixCandidate::ReplaceInLine {
line,
start_col,
end_col,
replacement,
}) => {
assert_eq!(*line, 3); assert_eq!(*start_col, 4); assert_eq!(*end_col, 4 + "reason".len());
assert_eq!(replacement, "why_failed");
}
other => panic!("expected ReplaceInLine, got {other:?}"),
}
}
#[test]
fn ara002_type_after_reason_still_resolves() {
let yaml = "\
tree:
- id: N01
reason: it diverged
type: dead_end
";
let d = only(lint_tree(yaml), LintRuleId::DeadEndReasonAlias);
match &d.fix {
Some(FixCandidate::ReplaceInLine { line, .. }) => assert_eq!(*line, 2),
other => panic!("expected ReplaceInLine, got {other:?}"),
}
}
#[test]
fn ara002_reason_on_non_dead_end_not_flagged() {
let yaml = "\
tree:
- id: N01
type: experiment
reason: some prose
";
assert!(
lint_tree(yaml)
.iter()
.all(|d| d.rule != LintRuleId::DeadEndReasonAlias)
);
}
#[test]
fn ara002_canonical_why_failed_not_flagged() {
let yaml = "\
tree:
- id: N01
type: dead_end
why_failed: it diverged
";
assert!(lint_tree(yaml).is_empty());
}
#[test]
fn ara002_siblings_scoped_independently() {
let yaml = "\
tree:
- id: N01
type: dead_end
reason: x
- id: N02
type: decision
reason: y
";
let diags = lint_tree(yaml);
let d = only(diags, LintRuleId::DeadEndReasonAlias);
match &d.fix {
Some(FixCandidate::ReplaceInLine { line, .. }) => assert_eq!(*line, 3),
other => panic!("expected ReplaceInLine, got {other:?}"),
}
}
#[test]
fn ara002_reason_on_nested_dead_end_child_is_detected() {
let yaml = "\
tree:
- id: N01
type: question
children:
- id: N02
type: dead_end
reason: nested
";
let d = only(lint_tree(yaml), LintRuleId::DeadEndReasonAlias);
match &d.fix {
Some(FixCandidate::ReplaceInLine {
line, start_col, ..
}) => {
assert_eq!(*line, 6);
assert_eq!(*start_col, 8); }
other => panic!("expected ReplaceInLine, got {other:?}"),
}
}
#[test]
fn ara003_justification_on_decision_is_detected() {
let yaml = "\
tree:
- id: N01
type: decision
justification: cheaper
";
let d = only(lint_tree(yaml), LintRuleId::DecisionRationaleAlias);
assert!(d.fixable);
match &d.fix {
Some(FixCandidate::ReplaceInLine {
line,
start_col,
end_col,
replacement,
}) => {
assert_eq!(*line, 3);
assert_eq!(*start_col, 4);
assert_eq!(*end_col, 4 + "justification".len());
assert_eq!(replacement, "rationale");
}
other => panic!("expected ReplaceInLine, got {other:?}"),
}
}
#[test]
fn ara003_justification_on_non_decision_not_flagged() {
let yaml = "\
tree:
- id: N01
type: experiment
justification: some prose
";
assert!(
lint_tree(yaml)
.iter()
.all(|d| d.rule != LintRuleId::DecisionRationaleAlias)
);
}
#[test]
fn ara004_em_dash_header_is_detected() {
let md = "## C01 — Attention is all you need";
let d = only(lint_claims(md), LintRuleId::ClaimHeaderStyle);
assert!(d.fixable);
assert_eq!(d.file, LintFile::Claims);
match &d.fix {
Some(FixCandidate::ReplaceInLine {
line,
start_col,
end_col,
replacement,
}) => {
assert_eq!(*line, 0);
assert_eq!(*start_col, 6); assert_eq!(replacement, ": ");
let fixed = format!("{}{}{}", &md[..*start_col], replacement, &md[*end_col..]);
assert_eq!(fixed, "## C01: Attention is all you need");
}
other => panic!("expected ReplaceInLine, got {other:?}"),
}
}
#[test]
fn ara004_hyphen_header_is_detected() {
let md = "## C02 - Faster training";
let d = only(lint_claims(md), LintRuleId::ClaimHeaderStyle);
match &d.fix {
Some(FixCandidate::ReplaceInLine {
start_col,
end_col,
replacement,
..
}) => {
let fixed = format!("{}{}{}", &md[..*start_col], replacement, &md[*end_col..]);
assert_eq!(fixed, "## C02: Faster training");
}
other => panic!("expected ReplaceInLine, got {other:?}"),
}
}
#[test]
fn ara004_colon_header_not_flagged() {
assert!(lint_claims("## C01: Attention is all you need").is_empty());
}
#[test]
fn ara004_non_claim_dash_header_not_flagged() {
assert!(lint_claims("## Overview — background").is_empty());
}
#[test]
fn ara004_hyphen_in_title_with_colon_not_flagged() {
assert!(lint_claims("## C01: Multi-head attention").is_empty());
}
#[test]
fn check_dir_tolerates_missing_claims_and_does_not_panic() {
use std::sync::atomic::{AtomicUsize, Ordering};
static CTR: AtomicUsize = AtomicUsize::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("ara_lint_test_{}_{n}", std::process::id()));
std::fs::create_dir_all(dir.join("trace")).unwrap();
std::fs::write(
dir.join("trace/exploration_tree.yaml"),
"root:\n id: N01\n type: question\n",
)
.unwrap();
let report = check_dir(&dir);
assert!(
report
.diagnostics()
.iter()
.any(|d| d.rule == LintRuleId::RootDialect)
);
assert_eq!(report.fixable(), report.diagnostics().len());
assert!(!report.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn check_dir_missing_tree_yields_empty_report() {
use std::sync::atomic::{AtomicUsize, Ordering};
static CTR: AtomicUsize = AtomicUsize::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("ara_lint_empty_{}_{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let report = check_dir(&dir);
assert!(report.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
}