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 — unrecognized keys like `reason:` / `justification:` don't error;
7//! they are **not** `serde` aliases of `why_failed:` / `rationale:` but fall into
8//! `extra`, surface as `unknown field` warnings, and their values are **dropped**.
9//! This module instead works on the *unparsed* text so it can point at the exact
10//! line/byte span a later applier rewrites to **recover** those dropped values,
11//! and it is **not** wired into parsing — `ara validate` behavior is unchanged.
12//!
13//! Each [`LintDiagnostic`] carries a [`LintRuleId`], a human message, the file
14//! it lives in, and (when fixable) a [`FixCandidate`] describing the edit as
15//! data. Applying those candidates is step 2's job; this layer only detects.
16//!
17//! Scanning is regex-free string work, matching the rest of the crate (see
18//! [`crate::manifest::is_canonical_id`]).
19
20use serde::Serialize;
21
22#[cfg(feature = "native")]
23use crate::manifest::is_canonical_id;
24
25/// A format-lint rule identifier. Serializes to its `ARA0NN` code.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27pub enum LintRuleId {
28    /// `ARA001`: top-level `root:` single-node dialect (canonical is `tree:`).
29    #[serde(rename = "ARA001")]
30    RootDialect,
31    /// `ARA002`: `reason:` key on a `dead_end` node (canonical `why_failed:`).
32    #[serde(rename = "ARA002")]
33    DeadEndReasonAlias,
34    /// `ARA003`: `justification:` key on a `decision` node (canonical
35    /// `rationale:`).
36    #[serde(rename = "ARA003")]
37    DecisionRationaleAlias,
38    /// `ARA004`: claim header with a dash separator instead of a colon.
39    #[serde(rename = "ARA004")]
40    ClaimHeaderStyle,
41}
42
43impl LintRuleId {
44    /// The stable `ARA0NN` code string.
45    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/// Which source file a diagnostic (and its fix) applies to.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum LintFile {
65    /// `trace/exploration_tree.yaml`.
66    Tree,
67    /// `logic/claims.md`.
68    Claims,
69}
70
71impl LintFile {
72    /// The file's path relative to the artifact root.
73    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/// A fix described as data, so a later step can apply it as a surgical text
82/// edit without re-deriving the location.
83#[derive(Debug, Clone, PartialEq, Serialize)]
84pub enum FixCandidate {
85    /// Replace the byte range `[start_col, end_col)` on 0-based line `line` with
86    /// `replacement`. Columns are byte offsets within that line. Used for the
87    /// line-level renames (ARA002/ARA003) and the claim-header rewrite (ARA004).
88    ReplaceInLine {
89        /// 0-based line index.
90        line: usize,
91        /// Byte offset within the line where the replaced span begins.
92        start_col: usize,
93        /// Byte offset within the line where the replaced span ends (exclusive).
94        end_col: usize,
95        /// Text to substitute for `[start_col, end_col)`.
96        replacement: String,
97    },
98    /// ARA001 structural rewrite: turn the top-level `root:` single-node map into
99    /// a one-element `tree:` list. The re-indent/re-emit algorithm is step 2's
100    /// job; this candidate carries the block's location so the applier can read
101    /// and transform it deterministically.
102    RewriteRootToTree {
103        /// 0-based line index of the top-level `root:` key.
104        root_line: usize,
105        /// Leading-space indentation of the `root:` key (canonically `0`).
106        root_indent: usize,
107        /// 0-based line index one past the last line of the `root:` block
108        /// (exclusive); the block runs `[root_line, block_end_line)`.
109        block_end_line: usize,
110    },
111}
112
113/// One format-lint finding.
114#[derive(Debug, Clone, PartialEq, Serialize)]
115pub struct LintDiagnostic {
116    /// The rule that fired.
117    pub rule: LintRuleId,
118    /// Human-readable explanation of the drift.
119    pub message: String,
120    /// The file the drift lives in.
121    pub file: LintFile,
122    /// Whether a canonical fix is known (mirrors `fix.is_some()`).
123    pub fixable: bool,
124    /// The edit to apply, when known.
125    pub fix: Option<FixCandidate>,
126}
127
128/// The outcome of a format-lint pass: findings in source order.
129#[derive(Debug, Clone, Default, PartialEq, Serialize)]
130pub struct LintReport {
131    /// All diagnostics, tree file first then claims, each in source order.
132    pub diagnostics: Vec<LintDiagnostic>,
133}
134
135impl LintReport {
136    /// All diagnostics.
137    pub fn diagnostics(&self) -> &[LintDiagnostic] {
138        &self.diagnostics
139    }
140
141    /// True when no drift was detected.
142    pub fn is_empty(&self) -> bool {
143        self.diagnostics.is_empty()
144    }
145
146    /// Number of diagnostics carrying a fix candidate.
147    pub fn fixable(&self) -> usize {
148        self.diagnostics.iter().filter(|d| d.fixable).count()
149    }
150}
151
152/// Reads `trace/exploration_tree.yaml` and `logic/claims.md` from `dir` and
153/// runs the format-lint rules over their raw text. Native only.
154///
155/// Missing files are tolerated: an absent `claims.md` (or `exploration_tree.yaml`)
156/// simply contributes no diagnostics rather than erroring or panicking. This
157/// never reads the parse layer — it is a pure text pass. It is a thin wrapper
158/// over [`check_sources`], reading the two files then delegating.
159#[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/// Runs the format-lint rules over in-memory `trace/exploration_tree.yaml` text
167/// and optional `logic/claims.md` text, returning findings in source order
168/// (tree first, then claims).
169///
170/// This is the pure entry that [`check_dir`] wraps after reading the two files,
171/// and that the fix applier ([`crate::fix::fix_dir`]) uses to re-detect drift on
172/// edited text in memory without touching the filesystem. Native only, because
173/// the scanning rules themselves are native-gated.
174#[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/// A parsed YAML mapping-key line.
184#[cfg(feature = "native")]
185struct KeyLine {
186    /// The key name (the token before `:`).
187    key: String,
188    /// The scalar value after `:` (trimmed); empty for block keys.
189    value: String,
190    /// True when the line is a `- ` list item.
191    is_list_item: bool,
192    /// Byte offset within the line of the key's first character. Sibling keys of
193    /// one node map share this column, so it doubles as the node's indent key.
194    key_col: usize,
195}
196
197/// A recorded occurrence of a context-scoped key (`reason:` / `justification:`).
198#[cfg(feature = "native")]
199struct KeyHit {
200    line: usize,
201    col: usize,
202}
203
204/// One node map encountered while scanning, retained after it leaves the stack
205/// so its keys can be resolved against its (possibly later-declared) `type:`.
206#[cfg(feature = "native")]
207struct NodeFrame {
208    /// The column at which this node's direct keys live.
209    key_indent: usize,
210    /// The node's `type:`, once seen.
211    ty: Option<String>,
212    /// `reason:` keys directly on this node.
213    reason_hits: Vec<KeyHit>,
214    /// `justification:` keys directly on this node.
215    justification_hits: Vec<KeyHit>,
216}
217
218/// Counts leading ASCII spaces (YAML indentation is spaces, never tabs).
219#[cfg(feature = "native")]
220fn leading_spaces(s: &str) -> usize {
221    s.len() - s.trim_start_matches(' ').len()
222}
223
224/// Parses a line into a [`KeyLine`] when it is a `word: ...` mapping entry
225/// (optionally introduced by `- `). Returns `None` for blanks, comments, scalar
226/// list items, and block-scalar continuations (free text that happens to hold a
227/// colon), so those never masquerade as node keys.
228#[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    // A real key is a single bare identifier token; reject free text (which
247    // contains spaces) and other punctuation so block scalars never match.
248    if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
249        return None;
250    }
251    // YAML requires a space (or end of line) after a mapping colon; this also
252    // rejects scalars like `http://x` that carry an inner colon.
253    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/// Returns the exclusive end line of the `root:` block: the first later line at
267/// indent 0 (a new top-level key), or the end of the file. Blank lines are
268/// skipped so trailing blanks are not treated as a boundary.
269#[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/// Runs the tree-file rules (ARA001/ARA002/ARA003) over raw YAML text.
287///
288/// ARA002/ARA003 are context-scoped: a `reason:`/`justification:` key is only
289/// flagged when it sits directly on a `dead_end`/`decision` node. A stack of
290/// node frames (keyed by their direct-key column) tracks which node owns each
291/// key line; frames are retained so a `type:` declared after the aliased key is
292/// still resolved. Node maps are recognized by their `- ` list items, matching
293/// the `tree:`/`children:` list dialect.
294#[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        // ARA001: a top-level `root:` key uses the single-node dialect.
307        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        // Close any frames deeper than this key (dedent).
325        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            // A list item at the same column as the current node is its sibling:
335            // close the current node before opening the new one.
336            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        // Attribute the key to the node whose direct keys live at this column.
352        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    // Resolve context-scoped hits against each node's type.
371    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/// Runs the claims-file rule (ARA004) over raw Markdown text.
414#[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/// Detects a claim header whose id/title separator is a dash instead of a colon
423/// (`## C01 — Title` / `## C01 - Title`) and returns the fix that rewrites the
424/// separator to `: `. Non-claim `##` headers (id not `^C\d+$`) and canonical
425/// colon headers are left untouched.
426#[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; // "## " is three bytes.
431
432    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    // Inspect the separator immediately after the id.
442    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    // A colon is already canonical; only dash separators drift.
447    if !matches!(sep, '—' | '–' | '-') {
448        return None;
449    }
450
451    // The title follows the separator (and any spaces); require it be non-empty
452    // so degenerate `## C01 -` lines are not "fixed" into `## C01: `.
453    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// The test suite drives the native-only scanning entry points, so it compiles
477// only when the `native` feature is on (it is, by default).
478#[cfg(all(test, feature = "native"))]
479mod tests {
480    use super::*;
481
482    /// Extracts the single diagnostic for `rule`, asserting exactly one fired.
483    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    // ---- ARA001 -----------------------------------------------------------
490
491    #[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); // all four lines belong to the block
511            }
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); // stops at `meta:` on line 3
538            }
539            other => panic!("expected RewriteRootToTree, got {other:?}"),
540        }
541    }
542
543    // ---- ARA002 -----------------------------------------------------------
544
545    #[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); // 0-based: the `reason:` line
564                assert_eq!(*start_col, 4); // key column under a 2-space list item
565                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        // `type:` declared *after* the aliased key must still be attributed.
575        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        // A dead_end sibling's reason fires; a decision sibling's reason does not.
617        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); // deeper nesting → deeper key column
652            }
653            other => panic!("expected ReplaceInLine, got {other:?}"),
654        }
655    }
656
657    // ---- ARA003 -----------------------------------------------------------
658
659    #[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    // ---- ARA004 -----------------------------------------------------------
701
702    #[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); // right after "## C01"
717                assert_eq!(replacement, ": ");
718                // Splicing the replacement yields the canonical header.
719                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        // The id is not `^C\d+$`, so a dash-separated section header is left alone.
752        assert!(lint_claims("## Overview — background").is_empty());
753    }
754
755    #[test]
756    fn ara004_hyphen_in_title_with_colon_not_flagged() {
757        // The separator is a colon; a hyphen later in the title is irrelevant.
758        assert!(lint_claims("## C01: Multi-head attention").is_empty());
759    }
760
761    // ---- check_dir --------------------------------------------------------
762
763    #[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        // Deliberately no `logic/claims.md`.
777
778        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}