Skip to main content

ara_core/
lint.rs

1//! Format-lint layer: detects canonicalizable *drift* in an ARA artifact's raw
2//! source text and emits diagnostics paired with data-only fix candidates.
3//!
4//! This is deliberately **separate** from [`crate::parse`]: `parse_sources` /
5//! `parse_dir` normalize an artifact into a [`crate::Manifest`] and are tolerant
6//! by design — drifted keys like `reason:` (on a `dead_end`), `justification:`,
7//! or the pivot `from:` / `to:` / `trigger:` don't error; they are **not**
8//! `serde` aliases of the canonical keys, so each surfaces as a warning
9//! (`unknown field` for the unmodeled spellings, `field dropped for type` for
10//! `reason:`, which names the pivot-canonical key) and its value is **dropped**.
11//! This module instead works on the *unparsed* text so it can point at the exact
12//! line/byte span a later applier rewrites to **recover** those dropped values,
13//! and it is **not** wired into parsing — `ara validate` behavior is unchanged.
14//!
15//! Each [`LintDiagnostic`] carries a [`LintRuleId`], a human message, the file
16//! it lives in, and (when fixable) a [`FixCandidate`] describing the edit as
17//! data. Applying those candidates is step 2's job; this layer only detects.
18//!
19//! Scanning is regex-free string work, matching the rest of the crate (see
20//! [`crate::manifest::is_canonical_id`]).
21
22use serde::Serialize;
23
24#[cfg(feature = "native")]
25use crate::manifest::is_canonical_id;
26
27/// A format-lint rule identifier. Serializes to its `ARA0NN` code.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29pub enum LintRuleId {
30    /// `ARA001`: top-level `root:` single-node dialect (canonical is `tree:`).
31    #[serde(rename = "ARA001")]
32    RootDialect,
33    /// `ARA002`: `reason:` key on a `dead_end` node (canonical `why_failed:`).
34    #[serde(rename = "ARA002")]
35    DeadEndReasonAlias,
36    /// `ARA003`: `justification:` key on a `decision` node (canonical
37    /// `rationale:`).
38    #[serde(rename = "ARA003")]
39    DecisionRationaleAlias,
40    /// `ARA004`: claim header with a dash separator instead of a colon.
41    #[serde(rename = "ARA004")]
42    ClaimHeaderStyle,
43    /// `ARA005`: `from:` key on a `pivot` node (canonical `prior_direction:`).
44    #[serde(rename = "ARA005")]
45    PivotFromAlias,
46    /// `ARA006`: `to:` key on a `pivot` node (canonical `new_direction:`).
47    #[serde(rename = "ARA006")]
48    PivotToAlias,
49    /// `ARA007`: `trigger:` key on a `pivot` node (canonical `reason:`).
50    #[serde(rename = "ARA007")]
51    PivotTriggerAlias,
52}
53
54impl LintRuleId {
55    /// The stable `ARA0NN` code string.
56    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/// Which source file a diagnostic (and its fix) applies to.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "snake_case")]
78pub enum LintFile {
79    /// `trace/exploration_tree.yaml`.
80    Tree,
81    /// `logic/claims.md`.
82    Claims,
83}
84
85impl LintFile {
86    /// The file's path relative to the artifact root.
87    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/// A fix described as data, so a later step can apply it as a surgical text
96/// edit without re-deriving the location.
97#[derive(Debug, Clone, PartialEq, Serialize)]
98pub enum FixCandidate {
99    /// Replace the byte range `[start_col, end_col)` on 0-based line `line` with
100    /// `replacement`. Columns are byte offsets within that line. Used for the
101    /// line-level renames (ARA002/ARA003, ARA005–ARA007) and the claim-header
102    /// rewrite (ARA004).
103    ReplaceInLine {
104        /// 0-based line index.
105        line: usize,
106        /// Byte offset within the line where the replaced span begins.
107        start_col: usize,
108        /// Byte offset within the line where the replaced span ends (exclusive).
109        end_col: usize,
110        /// Text to substitute for `[start_col, end_col)`.
111        replacement: String,
112    },
113    /// ARA001 structural rewrite: turn the top-level `root:` single-node map into
114    /// a one-element `tree:` list. The re-indent/re-emit algorithm is step 2's
115    /// job; this candidate carries the block's location so the applier can read
116    /// and transform it deterministically.
117    RewriteRootToTree {
118        /// 0-based line index of the top-level `root:` key.
119        root_line: usize,
120        /// Leading-space indentation of the `root:` key (canonically `0`).
121        root_indent: usize,
122        /// 0-based line index one past the last line of the `root:` block
123        /// (exclusive); the block runs `[root_line, block_end_line)`.
124        block_end_line: usize,
125    },
126}
127
128/// One format-lint finding.
129#[derive(Debug, Clone, PartialEq, Serialize)]
130pub struct LintDiagnostic {
131    /// The rule that fired.
132    pub rule: LintRuleId,
133    /// Human-readable explanation of the drift.
134    pub message: String,
135    /// The file the drift lives in.
136    pub file: LintFile,
137    /// Whether a canonical fix is known (mirrors `fix.is_some()`).
138    pub fixable: bool,
139    /// The edit to apply, when known.
140    pub fix: Option<FixCandidate>,
141}
142
143/// The outcome of a format-lint pass: findings in source order.
144#[derive(Debug, Clone, Default, PartialEq, Serialize)]
145pub struct LintReport {
146    /// All diagnostics, tree file first then claims, each in source order.
147    pub diagnostics: Vec<LintDiagnostic>,
148}
149
150impl LintReport {
151    /// All diagnostics.
152    pub fn diagnostics(&self) -> &[LintDiagnostic] {
153        &self.diagnostics
154    }
155
156    /// True when no drift was detected.
157    pub fn is_empty(&self) -> bool {
158        self.diagnostics.is_empty()
159    }
160
161    /// Number of diagnostics carrying a fix candidate.
162    pub fn fixable(&self) -> usize {
163        self.diagnostics.iter().filter(|d| d.fixable).count()
164    }
165}
166
167/// Reads `trace/exploration_tree.yaml` and `logic/claims.md` from `dir` and
168/// runs the format-lint rules over their raw text. Native only.
169///
170/// Missing files are tolerated: an absent `claims.md` (or `exploration_tree.yaml`)
171/// simply contributes no diagnostics rather than erroring or panicking. This
172/// never reads the parse layer — it is a pure text pass. It is a thin wrapper
173/// over [`check_sources`], reading the two files then delegating.
174#[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/// Runs the format-lint rules over in-memory `trace/exploration_tree.yaml` text
182/// and optional `logic/claims.md` text, returning findings in source order
183/// (tree first, then claims).
184///
185/// This is the pure entry that [`check_dir`] wraps after reading the two files,
186/// and that the fix applier ([`crate::fix::fix_dir`]) uses to re-detect drift on
187/// edited text in memory without touching the filesystem. Native only, because
188/// the scanning rules themselves are native-gated.
189#[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/// A parsed YAML mapping-key line.
199#[cfg(feature = "native")]
200struct KeyLine {
201    /// The key name (the token before `:`).
202    key: String,
203    /// The scalar value after `:` (trimmed); empty for block keys.
204    value: String,
205    /// True when the line is a `- ` list item.
206    is_list_item: bool,
207    /// Byte offset within the line of the key's first character. Sibling keys of
208    /// one node map share this column, so it doubles as the node's indent key.
209    key_col: usize,
210}
211
212/// A recorded occurrence of a context-scoped key (`reason:` / `justification:` /
213/// `from:` / `to:` / `trigger:`).
214#[cfg(feature = "native")]
215struct KeyHit {
216    line: usize,
217    col: usize,
218}
219
220/// One node map encountered while scanning, retained after it leaves the stack
221/// so its keys can be resolved against its (possibly later-declared) `type:`.
222#[cfg(feature = "native")]
223struct NodeFrame {
224    /// The column at which this node's direct keys live.
225    key_indent: usize,
226    /// The node's `type:`, once seen.
227    ty: Option<String>,
228    /// `reason:` keys directly on this node.
229    reason_hits: Vec<KeyHit>,
230    /// `justification:` keys directly on this node.
231    justification_hits: Vec<KeyHit>,
232    /// `from:` keys directly on this node.
233    from_hits: Vec<KeyHit>,
234    /// `to:` keys directly on this node.
235    to_hits: Vec<KeyHit>,
236    /// `trigger:` keys directly on this node.
237    trigger_hits: Vec<KeyHit>,
238}
239
240/// Counts leading ASCII spaces (YAML indentation is spaces, never tabs).
241#[cfg(feature = "native")]
242fn leading_spaces(s: &str) -> usize {
243    s.len() - s.trim_start_matches(' ').len()
244}
245
246/// Parses a line into a [`KeyLine`] when it is a `word: ...` mapping entry
247/// (optionally introduced by `- `). Returns `None` for blanks, comments, scalar
248/// list items, and block-scalar continuations (free text that happens to hold a
249/// colon), so those never masquerade as node keys.
250#[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    // A real key is a single bare identifier token; reject free text (which
269    // contains spaces) and other punctuation so block scalars never match.
270    if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
271        return None;
272    }
273    // YAML requires a space (or end of line) after a mapping colon; this also
274    // rejects scalars like `http://x` that carry an inner colon.
275    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/// Returns the exclusive end line of the `root:` block: the first later line at
289/// indent 0 (a new top-level key), or the end of the file. Blank lines are
290/// skipped so trailing blanks are not treated as a boundary.
291#[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/// Runs the tree-file rules (ARA001/ARA002/ARA003/ARA005/ARA006/ARA007) over raw
309/// YAML text.
310///
311/// ARA002/ARA003 and ARA005–ARA007 are context-scoped: a
312/// `reason:`/`justification:`/`from:`/`to:`/`trigger:` key is only flagged when
313/// it sits directly on a `dead_end`/`decision`/`pivot` node. A stack of node
314/// frames (keyed by their direct-key column) tracks which node owns each
315/// key line; frames are retained so a `type:` declared after the aliased key is
316/// still resolved. Node maps are recognized by their `- ` list items, matching
317/// the `tree:`/`children:` list dialect.
318#[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        // ARA001: a top-level `root:` key uses the single-node dialect.
331        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        // Close any frames deeper than this key (dedent).
349        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            // A list item at the same column as the current node is its sibling:
359            // close the current node before opening the new one.
360            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        // Attribute the key to the node whose direct keys live at this column.
379        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    // Resolve context-scoped hits against each node's type.
410    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/// Runs the claims-file rule (ARA004) over raw Markdown text.
488#[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/// Detects a claim header whose id/title separator is a dash instead of a colon
497/// (`## C01 — Title` / `## C01 - Title`) and returns the fix that rewrites the
498/// separator to `: `. Non-claim `##` headers (id not `^C\d+$`) and canonical
499/// colon headers are left untouched.
500#[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; // "## " is three bytes.
505
506    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    // Inspect the separator immediately after the id.
516    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    // A colon is already canonical; only dash separators drift.
521    if !matches!(sep, '—' | '–' | '-') {
522        return None;
523    }
524
525    // The title follows the separator (and any spaces); require it be non-empty
526    // so degenerate `## C01 -` lines are not "fixed" into `## C01: `.
527    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// The test suite drives the native-only scanning entry points, so it compiles
551// only when the `native` feature is on (it is, by default).
552#[cfg(all(test, feature = "native"))]
553mod tests {
554    use super::*;
555
556    /// Extracts the single diagnostic for `rule`, asserting exactly one fired.
557    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    // ---- ARA001 -----------------------------------------------------------
564
565    #[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); // all four lines belong to the block
585            }
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); // stops at `meta:` on line 3
612            }
613            other => panic!("expected RewriteRootToTree, got {other:?}"),
614        }
615    }
616
617    // ---- ARA002 -----------------------------------------------------------
618
619    #[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); // 0-based: the `reason:` line
638                assert_eq!(*start_col, 4); // key column under a 2-space list item
639                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        // `type:` declared *after* the aliased key must still be attributed.
649        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        // A dead_end sibling's reason fires; a decision sibling's reason does not.
691        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); // deeper nesting → deeper key column
726            }
727            other => panic!("expected ReplaceInLine, got {other:?}"),
728        }
729    }
730
731    // ---- ARA003 -----------------------------------------------------------
732
733    #[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    // ---- ARA005 / ARA006 / ARA007 -----------------------------------------
775
776    #[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); // 0-based: the `from:` line
795                assert_eq!(*start_col, 4); // key column under a 2-space list item
796                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        // Kind-scoping is what makes short keys like `to:` safe: the same keys
858        // on other node kinds (or as prose-like entries) must not fire.
859        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        // `type:` declared *after* the aliased key must still be attributed.
890        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); // deeper nesting → deeper key column
932            }
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        // `reason` is canonical on pivot and aliased on dead_end; `trigger` is
953        // the pivot alias of `reason`. Kind-scoping keeps them unambiguous.
954        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    // ---- ARA004 -----------------------------------------------------------
978
979    #[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); // right after "## C01"
994                assert_eq!(replacement, ": ");
995                // Splicing the replacement yields the canonical header.
996                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        // The id is not `^C\d+$`, so a dash-separated section header is left alone.
1029        assert!(lint_claims("## Overview — background").is_empty());
1030    }
1031
1032    #[test]
1033    fn ara004_hyphen_in_title_with_colon_not_flagged() {
1034        // The separator is a colon; a hyphen later in the title is irrelevant.
1035        assert!(lint_claims("## C01: Multi-head attention").is_empty());
1036    }
1037
1038    // ---- check_dir --------------------------------------------------------
1039
1040    #[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        // Deliberately no `logic/claims.md`.
1054
1055        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}