Skip to main content

surf_parse/
lint.rs

1//! Style lint rules (L-codes) and the unified `check` entry point.
2//!
3//! Three diagnostic layers feed [`check`]:
4//!
5//! 1. **syntax** — [`crate::parse::parse`] P-codes (parse-layer diagnostics)
6//! 2. **schema** — [`crate::validate::validate`] V-codes (`SurfDoc::validate()`)
7//! 3. **style** — the [`LintRule`] implementations in this module (L-codes)
8//!
9//! Rule metadata (default severity, fixability, message templates) is loaded
10//! at compile time from `spec/rules.toml` via `include_str!`. The module is
11//! wasm-clean: no filesystem access, no clock, no randomness. The
12//! `spec_compliance` integration test asserts that the implemented rule set
13//! and the registry match exactly.
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::sync::LazyLock;
17
18use serde::{Deserialize, Serialize};
19
20use crate::attrs::parse_attrs;
21use crate::error::{Diagnostic, Fix, FixSafety, Severity, TextEdit};
22use crate::parse::{closing_directive_depth, directive_name_start, opening_directive};
23use crate::types::{AttrValue, Block, DocType, FrontMatter, Span, SurfDoc};
24
25// ------------------------------------------------------------------
26// Rule registry (spec/rules.toml)
27// ------------------------------------------------------------------
28
29/// Metadata for a single lint rule, loaded from `spec/rules.toml`.
30#[derive(Debug, Clone, Deserialize)]
31pub struct RuleMeta {
32    /// Rule layer: `"syntax"` (P-codes) or `"style"` (L-codes).
33    pub layer: String,
34    /// Default severity; overridable per rule id via [`LintConfig`].
35    pub severity: Severity,
36    /// Whether Phase 2 ships a deterministic fix for this rule.
37    pub fixable: bool,
38    /// Fix tier: `"safe"` (auto-apply) or `"suggested"`; only set when fixable.
39    #[serde(default)]
40    pub fix_safety: Option<String>,
41    /// Human-readable message template with `{placeholder}` slots.
42    pub message: String,
43    /// One-line rule description.
44    pub description: String,
45}
46
47#[derive(Debug, Deserialize)]
48struct RegistryMeta {
49    #[allow(dead_code)]
50    spec_version: String,
51    total_rules: usize,
52    #[allow(dead_code)]
53    registry_updated: String,
54}
55
56#[derive(Debug, Deserialize)]
57struct RegistryFile {
58    meta: RegistryMeta,
59    rules: BTreeMap<String, RuleMeta>,
60}
61
62static RULE_REGISTRY: LazyLock<RegistryFile> = LazyLock::new(|| {
63    toml::from_str(include_str!("../spec/rules.toml"))
64        .expect("spec/rules.toml is embedded at compile time and must parse as valid TOML — covered by spec_compliance tests")
65});
66
67/// The lint rule registry, keyed by rule id (`P001`, `L001`, …).
68pub fn rule_registry() -> &'static BTreeMap<String, RuleMeta> {
69    &RULE_REGISTRY.rules
70}
71
72/// `meta.total_rules` declared in `spec/rules.toml`.
73pub fn rule_registry_total() -> usize {
74    RULE_REGISTRY.meta.total_rules
75}
76
77/// Rule ids emitted by the parse layer (`src/parse.rs`), pinned for the
78/// registry drift test in `tests/spec_compliance.rs`.
79pub const PARSE_RULE_IDS: &[&str] = &["P001", "P002", "P003", "P005", "P006"];
80
81/// Default severity for a rule id, falling back to `Warning` for ids that are
82/// not in the registry (defensive; the drift test makes this unreachable).
83fn registry_severity(id: &str) -> Severity {
84    rule_registry()
85        .get(id)
86        .map_or(Severity::Warning, |m| m.severity)
87}
88
89/// Build a diagnostic for a rule id with its registry default severity.
90fn diag(id: &str, message: String, span: Option<Span>) -> Diagnostic {
91    diag_fix(id, message, span, None)
92}
93
94/// [`diag`] with an attached auto-fix.
95fn diag_fix(id: &str, message: String, span: Option<Span>, fix: Option<Fix>) -> Diagnostic {
96    Diagnostic {
97        severity: registry_severity(id),
98        message,
99        span,
100        code: Some(id.to_string()),
101        fix,
102    }
103}
104
105/// Build a [`Fix`] whose safety tier comes from the rule registry
106/// (`fix_safety` in `spec/rules.toml`; defaults to `Safe`).
107fn rule_fix(id: &str, description: String, edits: Vec<TextEdit>) -> Fix {
108    let safety = match rule_registry()
109        .get(id)
110        .and_then(|m| m.fix_safety.as_deref())
111    {
112        Some("suggested") => FixSafety::Suggested,
113        _ => FixSafety::Safe,
114    };
115    Fix {
116        edits,
117        safety,
118        description,
119    }
120}
121
122// ------------------------------------------------------------------
123// Known block names (spec/blocks.toml)
124// ------------------------------------------------------------------
125
126#[derive(Debug, Deserialize)]
127struct BlocksSpecFile {
128    blocks: BTreeMap<String, toml::Value>,
129}
130
131static KNOWN_BLOCK_NAMES: LazyLock<BTreeSet<String>> = LazyLock::new(|| {
132    let spec: BlocksSpecFile = toml::from_str(include_str!("../spec/blocks.toml"))
133        .expect("spec/blocks.toml is embedded at compile time and must parse as valid TOML — covered by spec_compliance tests");
134    spec.blocks.into_keys().collect()
135});
136
137/// All block names registered in `spec/blocks.toml` (any status).
138///
139/// L020 keys off this set — the spec, not the `Block` enum, is the law.
140pub fn known_block_names() -> &'static BTreeSet<String> {
141    &KNOWN_BLOCK_NAMES
142}
143
144/// Parser-accepted names that are deliberately NOT in `spec/blocks.toml`:
145/// sub-directives (`column`) and aliases/renderer families the parser resolves
146/// (`action-items` → tasks, `deck`/`slide`, `deploy_urls`, `info-card`).
147/// L020 must not flag these. The spec_compliance test asserts none of them is
148/// in blocks.toml — once the spec registers one, remove it from this list.
149pub const EXTRA_KNOWN_BLOCK_NAMES: &[&str] = &[
150    "action-items",
151    "column",
152    "deck",
153    "deploy_urls",
154    "info-card",
155    "slide",
156];
157
158// ------------------------------------------------------------------
159// LintRule trait + registry of implementations
160// ------------------------------------------------------------------
161
162/// A single style-layer lint rule.
163pub trait LintRule {
164    /// Stable rule id, e.g. `"L001"`. Must have an entry in `spec/rules.toml`.
165    fn id(&self) -> &'static str;
166    /// Check a parsed document against this rule.
167    ///
168    /// `source` is the CRLF-normalised source text the spans refer to
169    /// (i.e. `doc.source`).
170    fn check(&self, doc: &SurfDoc, source: &str) -> Vec<Diagnostic>;
171}
172
173/// All implemented style-layer rules, in rule-id order.
174pub fn all_rules() -> Vec<Box<dyn LintRule>> {
175    vec![
176        Box::new(SectionBlockUsed),
177        Box::new(TableBlockUsed),
178        Box::new(CurlyAttrBraces),
179        Box::new(DirectiveNotOnOwnLine),
180        Box::new(NestingColonMismatch),
181        Box::new(MissingSummary),
182        Box::new(SummaryNotNearTop),
183        Box::new(UnknownBlockName),
184        Box::new(MissingRequiredFrontMatter),
185        Box::new(FrontMatterEnumCase),
186    ]
187}
188
189// ------------------------------------------------------------------
190// Source-line scanner shared by the source-scan rules
191// ------------------------------------------------------------------
192
193/// A classified source line.
194struct ScanLine<'a> {
195    /// 0-based line index.
196    idx: usize,
197    /// Byte offset of the line start within the source.
198    offset: usize,
199    /// Raw line text (without trailing newline).
200    raw: &'a str,
201    /// `raw.trim()`, precomputed.
202    trimmed: &'a str,
203    /// Inside front matter, a `::code`/`::output` block body, or a fenced
204    /// markdown code block — source-scan rules must skip these lines.
205    literal: bool,
206}
207
208impl ScanLine<'_> {
209    fn span(&self) -> Span {
210        Span {
211            start_line: self.idx + 1,
212            end_line: self.idx + 1,
213            start_offset: self.offset,
214            end_offset: self.offset + self.raw.len(),
215        }
216    }
217
218    /// Byte length of the leading whitespace on this line.
219    fn indent(&self) -> usize {
220        self.raw.len() - self.raw.trim_start().len()
221    }
222
223    /// A sub-line span: `start..end` are byte offsets WITHIN this line's raw
224    /// text (relative to the line start).
225    fn sub_span(&self, start: usize, end: usize) -> Span {
226        Span {
227            start_line: self.idx + 1,
228            end_line: self.idx + 1,
229            start_offset: self.offset + start,
230            end_offset: self.offset + end,
231        }
232    }
233
234    /// Edit replacing this line's content (newline untouched).
235    fn replace_edit(&self, replacement: impl Into<String>) -> TextEdit {
236        TextEdit {
237            span: self.span(),
238            replacement: replacement.into(),
239        }
240    }
241
242    /// Edit deleting this whole line including its trailing newline (if any).
243    fn delete_edit(&self, source_len: usize) -> TextEdit {
244        let end = self.offset + self.raw.len();
245        let end = if end < source_len { end + 1 } else { end };
246        TextEdit {
247            span: Span {
248                start_line: self.idx + 1,
249                end_line: self.idx + 1,
250                start_offset: self.offset,
251                end_offset: end,
252            },
253            replacement: String::new(),
254        }
255    }
256}
257
258/// 0-based index of the closing `---` line, if the document opens with front
259/// matter that is properly closed. Mirrors the parser: an unclosed `---` means
260/// the whole document is body text.
261fn front_matter_close_line(lines: &[&str]) -> Option<usize> {
262    if lines.first().map(|l| l.trim()) != Some("---") {
263        return None;
264    }
265    lines
266        .iter()
267        .enumerate()
268        .skip(1)
269        .find(|(_, l)| l.trim() == "---")
270        .map(|(i, _)| i)
271}
272
273/// Block names whose body content is literal text (directive-looking lines
274/// inside them must not trigger source-scan rules).
275const LITERAL_BLOCK_NAMES: &[&str] = &["code", "output"];
276
277/// Classify every source line for the source-scan rules.
278fn scan_lines(source: &str) -> Vec<ScanLine<'_>> {
279    let lines: Vec<&str> = source.split('\n').collect();
280    let fm_end = front_matter_close_line(&lines);
281    let mut out = Vec::with_capacity(lines.len());
282    let mut offset = 0usize;
283    let mut in_fence = false;
284    // Depth of the innermost open literal block (::code / ::output), if any.
285    let mut literal_depth: Option<usize> = None;
286
287    for (idx, raw) in lines.iter().enumerate() {
288        let trimmed = raw.trim();
289        let in_front_matter = fm_end.is_some_and(|end| idx <= end);
290        let mut literal = in_front_matter;
291
292        if !in_front_matter {
293            if let Some(depth) = literal_depth {
294                if closing_directive_depth(trimmed) == Some(depth) {
295                    // The closer line itself is structural.
296                    literal_depth = None;
297                } else {
298                    literal = true;
299                }
300            } else if in_fence {
301                literal = true;
302                if trimmed.starts_with("```") {
303                    in_fence = false;
304                }
305            } else if trimmed.starts_with("```") {
306                in_fence = true;
307                literal = true;
308            } else if let Some((depth, name, _)) = opening_directive(trimmed)
309                && LITERAL_BLOCK_NAMES.contains(&name.as_str())
310            {
311                literal_depth = Some(depth);
312            }
313        }
314
315        out.push(ScanLine {
316            idx,
317            offset,
318            raw,
319            trimmed,
320            literal,
321        });
322        offset += raw.len() + 1;
323    }
324    out
325}
326
327/// Index of the closer line matching the opener at `scan[opener_idx]` (with
328/// `depth` colons), if any. Mirrors the parser's depth matching: a closer
329/// closes the innermost open block with the same colon count.
330///
331/// Limit: closer-less leaf directives between the opener and its closer can
332/// steal the closer via depth matching (same divergence the parser resolves
333/// with sibling lookahead). The fixpoint loop in [`apply_fixes`] cleans up
334/// any leftover colon-only line via L005 on the next pass.
335fn matching_closer_idx(scan: &[ScanLine<'_>], opener_idx: usize, depth: usize) -> Option<usize> {
336    let mut stack = vec![depth];
337    for (i, line) in scan.iter().enumerate().skip(opener_idx + 1) {
338        if line.literal {
339            continue;
340        }
341        if let Some(d) = closing_directive_depth(line.trimmed) {
342            if let Some(pos) = stack.iter().rposition(|&s| s == d) {
343                stack.truncate(pos);
344                if stack.is_empty() {
345                    return Some(i);
346                }
347            }
348            continue;
349        }
350        if let Some((d, _, _)) = opening_directive(line.trimmed) {
351            stack.push(d);
352        }
353    }
354    None
355}
356
357// ------------------------------------------------------------------
358// L001 / L002 — forbidden blocks with markdown equivalents
359// ------------------------------------------------------------------
360
361/// Heading text for a `::section` opener: the `title` (or `headline`) attr,
362/// with the heading level from a `level` attr clamped to 1..=6 (default 2).
363fn section_heading(attrs_str: &str) -> Option<String> {
364    let attrs = parse_attrs(attrs_str).ok()?;
365    let title = match attrs.get("title").or_else(|| attrs.get("headline"))? {
366        AttrValue::String(s) => s.clone(),
367        AttrValue::Number(n) => n.to_string(),
368        _ => return None,
369    };
370    if title.trim().is_empty() {
371        return None;
372    }
373    let level = match attrs.get("level") {
374        Some(AttrValue::Number(n)) if n.fract() == 0.0 && (1.0..=6.0).contains(n) => *n as usize,
375        _ => 2,
376    };
377    Some(format!("{} {}", "#".repeat(level), title))
378}
379
380/// L001: `::section` block used — prefer a markdown `##` heading.
381///
382/// Fix (safe): replace the opener with `## <title>` (from the `title` /
383/// `headline` attr; `level` attr sets the heading depth) and delete the closer
384/// line. Same-line trailing content composes too (L004 defers section openers
385/// here): `::section Executive Summary` becomes `## Executive Summary`, and a
386/// displaced `::section [title=X]` bracket block is read as attributes.
387/// Without any usable title, opener and closer are simply stripped.
388struct SectionBlockUsed;
389
390/// Heading for a `::section` opener, considering both the attached
391/// `[attrs]` and any same-line trailing content. Trailing content that is
392/// itself a `[...]` block is parsed as displaced attributes; a `{...}` tail
393/// is left for L003's brace rewrite; other text is the literal title.
394fn section_heading_with_tail(attrs_str: &str, tail: &str) -> Option<String> {
395    if !attrs_str.is_empty() {
396        return match (section_heading(attrs_str), tail.is_empty()) {
397            (Some(heading), true) => Some(heading),
398            // Attrs gave the heading; trailing content survives below it.
399            (Some(heading), false) => Some(format!("{heading}\n{tail}")),
400            (None, true) => None,
401            // No usable title attr — the trailing text is the title.
402            (None, false) => Some(format!("## {tail}")),
403        };
404    }
405    if tail.starts_with('[') && crate::parse::find_attr_close(tail) == Some(tail.len() - 1) {
406        return section_heading(tail);
407    }
408    if tail.is_empty() || tail.starts_with('{') {
409        return None;
410    }
411    Some(format!("## {tail}"))
412}
413
414impl LintRule for SectionBlockUsed {
415    fn id(&self) -> &'static str {
416        "L001"
417    }
418
419    fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
420        let scan = scan_lines(source);
421        let mut out = Vec::new();
422        for (i, line) in scan.iter().enumerate() {
423            if line.literal {
424                continue;
425            }
426            let Some((depth, name, attrs_str)) = opening_directive(line.trimmed) else {
427                continue;
428            };
429            if name != "section" {
430                continue;
431            }
432            let tail = line.trimmed
433                [directive_name_start(line.trimmed, depth) + name.len() + attrs_str.len()..]
434                .trim();
435            let mut edits = Vec::new();
436            let description = match section_heading_with_tail(&attrs_str, tail) {
437                Some(heading) => {
438                    let d = format!(
439                        "replace '::section' with '{}'",
440                        heading.split('\n').next().unwrap_or(&heading)
441                    );
442                    edits.push(line.replace_edit(heading));
443                    d
444                }
445                None => {
446                    edits.push(line.delete_edit(source.len()));
447                    "remove '::section' wrapper without a title".to_string()
448                }
449            };
450            if let Some(ci) = matching_closer_idx(&scan, i, depth) {
451                edits.push(scan[ci].delete_edit(source.len()));
452            }
453            out.push(diag_fix(
454                "L001",
455                "'::section' block used — prefer a markdown '##' heading".to_string(),
456                Some(line.span()),
457                Some(rule_fix("L001", description, edits)),
458            ));
459        }
460        out
461    }
462}
463
464/// L002: `::table` block used — prefer a markdown pipe table.
465///
466/// Fix (safe): strip the opener and closer lines, keeping the content.
467struct TableBlockUsed;
468
469impl LintRule for TableBlockUsed {
470    fn id(&self) -> &'static str {
471        "L002"
472    }
473
474    fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
475        let scan = scan_lines(source);
476        let mut out = Vec::new();
477        for (i, line) in scan.iter().enumerate() {
478            if line.literal {
479                continue;
480            }
481            let Some((depth, name, _)) = opening_directive(line.trimmed) else {
482                continue;
483            };
484            if name != "table" {
485                continue;
486            }
487            let mut edits = vec![line.delete_edit(source.len())];
488            if let Some(ci) = matching_closer_idx(&scan, i, depth) {
489                edits.push(scan[ci].delete_edit(source.len()));
490            }
491            out.push(diag_fix(
492                "L002",
493                "'::table' block used — prefer a markdown pipe table".to_string(),
494                Some(line.span()),
495                Some(rule_fix(
496                    "L002",
497                    "remove the '::table' wrapper, keeping the table content".to_string(),
498                    edits,
499                )),
500            ));
501        }
502        out
503    }
504}
505
506// ------------------------------------------------------------------
507// L003 — {curly} attribute braces
508// ------------------------------------------------------------------
509
510/// Byte index of the `}` closing the brace block opened by the leading `{`
511/// of `s`. Quote-aware (honours `\"` escapes) and depth-aware, mirroring
512/// `parse::find_attr_close` for brackets.
513fn find_brace_close(s: &str) -> Option<usize> {
514    let mut depth: i32 = 0;
515    let mut in_quote = false;
516    let mut escaped = false;
517    for (i, b) in s.bytes().enumerate() {
518        if in_quote {
519            if escaped {
520                escaped = false;
521            } else if b == b'\\' {
522                escaped = true;
523            } else if b == b'"' {
524                in_quote = false;
525            }
526            continue;
527        }
528        match b {
529            b'"' => in_quote = true,
530            b'{' => depth += 1,
531            b'}' => {
532                depth -= 1;
533                if depth == 0 {
534                    return Some(i);
535                }
536            }
537            _ => {}
538        }
539    }
540    None
541}
542
543/// L003: `{curly}` attribute braces on a block opener — use `[square]`.
544///
545/// Fix (safe): rewrite the opening `{` and its matching `}` to `[` / `]`,
546/// preserving the inner text byte-for-byte. No fix when the `{` is unmatched.
547struct CurlyAttrBraces;
548
549impl LintRule for CurlyAttrBraces {
550    fn id(&self) -> &'static str {
551        "L003"
552    }
553
554    fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
555        let mut out = Vec::new();
556        for line in scan_lines(source) {
557            if line.literal {
558                continue;
559            }
560            if let Some((depth, name, _)) = opening_directive(line.trimmed) {
561                let name_at = directive_name_start(line.trimmed, depth);
562                let rest = &line.trimmed[name_at + name.len()..];
563                if rest.starts_with('{') {
564                    // Brace positions relative to the raw line.
565                    let open = line.indent() + name_at + name.len();
566                    let fix = find_brace_close(rest).map(|close_rel| {
567                        let close = open + close_rel;
568                        rule_fix(
569                            "L003",
570                            "rewrite {curly} attribute braces to [square] brackets".to_string(),
571                            vec![
572                                TextEdit {
573                                    span: line.sub_span(open, open + 1),
574                                    replacement: "[".to_string(),
575                                },
576                                TextEdit {
577                                    span: line.sub_span(close, close + 1),
578                                    replacement: "]".to_string(),
579                                },
580                            ],
581                        )
582                    });
583                    out.push(diag_fix(
584                        "L003",
585                        format!(
586                            "Block attributes use {{curly}} braces — use [square] brackets: ::{name}[...]"
587                        ),
588                        Some(line.span()),
589                        fix,
590                    ));
591                }
592            }
593        }
594        out
595    }
596}
597
598// ------------------------------------------------------------------
599// L004 — directive not on its own line
600// ------------------------------------------------------------------
601
602/// L004: block opener or closer carries content on the same line.
603///
604/// Fix (safe): replace the whitespace gap between the directive and the
605/// trailing content with a newline, moving the content to its own line.
606///
607/// Limits: a closer at the END of a content line (`text ::`) is not
608/// detected — only lines that START with a directive are classified.
609struct DirectiveNotOnOwnLine;
610
611/// Edit replacing the whitespace run starting at byte `from` (relative to the
612/// raw line) with a newline, so the rest of the line moves to its own line.
613fn break_line_edit(line: &ScanLine<'_>, from: usize) -> TextEdit {
614    let rest = &line.raw[from..];
615    let ws = rest.len() - rest.trim_start().len();
616    TextEdit {
617        span: line.sub_span(from, from + ws),
618        replacement: "\n".to_string(),
619    }
620}
621
622/// L004 fix for an opener with trailing content.
623///
624/// Author intent matters: `::callout [type=info]` is a displaced attribute
625/// block (space before the bracket), so the fix REATTACHES it —
626/// `::callout[type=info]` — instead of demoting it to literal body text.
627/// Anything after a reattached `[...]` block, and every non-bracket tail,
628/// still moves to its own line.
629///
630/// `tail_at` / `tail` are relative to `line.trimmed` (tail starts right after
631/// the directive's own `[attrs]`, if any).
632fn l004_opener_fix(
633    line: &ScanLine<'_>,
634    name: &str,
635    attrs_str: &str,
636    tail_at: usize,
637    tail: &str,
638) -> Fix {
639    let gap_start = line.indent() + tail_at;
640    let ws = tail.len() - tail.trim_start().len();
641    let bracket = tail.trim_start();
642    if attrs_str.is_empty()
643        && ws > 0
644        && bracket.starts_with('[')
645        && let Some(close) = crate::parse::find_attr_close(bracket)
646    {
647        // Delete the whitespace gap so the bracket attaches to the name.
648        let mut edits = vec![TextEdit {
649            span: line.sub_span(gap_start, gap_start + ws),
650            replacement: String::new(),
651        }];
652        // Content after the bracket block still moves to its own line.
653        if !bracket[close + 1..].trim().is_empty() {
654            edits.push(break_line_edit(line, gap_start + ws + close + 1));
655        }
656        return rule_fix(
657            "L004",
658            format!("attach the '[...]' attributes to '::{name}'"),
659            edits,
660        );
661    }
662    rule_fix(
663        "L004",
664        format!("move the content after '::{name}' to its own line"),
665        vec![break_line_edit(line, gap_start)],
666    )
667}
668
669impl LintRule for DirectiveNotOnOwnLine {
670    fn id(&self) -> &'static str {
671        "L004"
672    }
673
674    fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
675        let mut out = Vec::new();
676        for line in scan_lines(source) {
677            if line.literal {
678                continue;
679            }
680            if let Some((depth, name, attrs_str)) = opening_directive(line.trimmed) {
681                let name_at = directive_name_start(line.trimmed, depth);
682                let rest = &line.trimmed[name_at + name.len()..];
683                if rest.starts_with('{') {
684                    // L003 owns curly-brace openers.
685                    continue;
686                }
687                if name == "section" {
688                    // L001 owns ::section openers (it fires on every one and
689                    // its fix composes same-line content into a markdown
690                    // heading); a competing L004 fix would demote the title
691                    // to a bare paragraph.
692                    continue;
693                }
694                let tail_at = name_at + name.len() + attrs_str.len();
695                let tail = &line.trimmed[tail_at..];
696                if !tail.trim().is_empty() {
697                    let fix = l004_opener_fix(&line, &name, &attrs_str, tail_at, tail);
698                    out.push(diag_fix(
699                        "L004",
700                        format!(
701                            "Block directive '::{name}' has content on the same line — openers and closers must be on their own line"
702                        ),
703                        Some(line.span()),
704                        Some(fix),
705                    ));
706                }
707            } else {
708                // Not an opener: a leading colon run followed by whitespace and
709                // content is an attempted closer with content on the same line.
710                let colons = line.trimmed.chars().take_while(|&c| c == ':').count();
711                if colons >= 2 {
712                    let rest = &line.trimmed[colons..];
713                    if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() {
714                        let fix = rule_fix(
715                            "L004",
716                            "move the content after '::' to its own line".to_string(),
717                            vec![break_line_edit(&line, line.indent() + colons)],
718                        );
719                        out.push(diag_fix(
720                            "L004",
721                            "Block directive '::' has content on the same line — openers and closers must be on their own line".to_string(),
722                            Some(line.span()),
723                            Some(fix),
724                        ));
725                    }
726                }
727            }
728        }
729        out
730    }
731}
732
733// ------------------------------------------------------------------
734// L005 — nesting colon mismatch
735// ------------------------------------------------------------------
736
737/// L005: nesting colon count inconsistent with the actual nesting depth.
738///
739/// Conservative by design — it flags only the two unambiguous cases:
740/// 1. an opener with more colons than the current depth allows
741///    (e.g. `:::column` with no open parent), and
742/// 2. a closer whose colon count matches no open block.
743///
744/// Closer-less leaf directives sit at the same depth as their parent
745/// (`::cta` inside `::page`), so under-deep openers are NOT flagged.
746struct NestingColonMismatch;
747
748impl LintRule for NestingColonMismatch {
749    fn id(&self) -> &'static str {
750        "L005"
751    }
752
753    fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
754        let mut out = Vec::new();
755        let mut stack: Vec<usize> = Vec::new();
756        for line in scan_lines(source) {
757            if line.literal {
758                continue;
759            }
760            if let Some(close_depth) = closing_directive_depth(line.trimmed) {
761                if let Some(pos) = stack.iter().rposition(|&d| d == close_depth) {
762                    stack.truncate(pos);
763                } else {
764                    // Fix: close the innermost open block instead; with no
765                    // open block at all, remove the orphan closer line.
766                    let colon_start = line.indent();
767                    let fix = match stack.last() {
768                        Some(&top) => rule_fix(
769                            "L005",
770                            format!(
771                                "rewrite closer to {top} colons to match the innermost open block"
772                            ),
773                            vec![TextEdit {
774                                span: line.sub_span(colon_start, colon_start + close_depth),
775                                replacement: ":".repeat(top),
776                            }],
777                        ),
778                        None => rule_fix(
779                            "L005",
780                            "remove closing directive that matches no open block".to_string(),
781                            vec![line.delete_edit(source.len())],
782                        ),
783                    };
784                    out.push(diag_fix(
785                        "L005",
786                        format!(
787                            "Nesting colon count inconsistent with actual depth: closing directive with {close_depth} colons does not match any open block"
788                        ),
789                        Some(line.span()),
790                        Some(fix),
791                    ));
792                }
793                continue;
794            }
795            if let Some((depth, _, _)) = opening_directive(line.trimmed) {
796                let allowed = stack.last().map_or(2, |&d| d + 1);
797                if depth > allowed {
798                    let colon_start = line.indent();
799                    let fix = rule_fix(
800                        "L005",
801                        format!("reduce opener colons from {depth} to {allowed}"),
802                        vec![TextEdit {
803                            span: line.sub_span(colon_start, colon_start + depth),
804                            replacement: ":".repeat(allowed),
805                        }],
806                    );
807                    out.push(diag_fix(
808                        "L005",
809                        format!(
810                            "Nesting colon count inconsistent with actual depth: block opened with {depth} colons but nesting depth here allows at most {allowed}"
811                        ),
812                        Some(line.span()),
813                        Some(fix),
814                    ));
815                }
816                stack.push(depth);
817            }
818        }
819        out
820    }
821}
822
823// ------------------------------------------------------------------
824// L010 / L011 — ::summary presence and placement
825// ------------------------------------------------------------------
826
827/// Doc types that should carry a `::summary` block.
828const SUMMARY_DOC_TYPES: &[DocType] = &[DocType::Doc, DocType::Plan, DocType::Guide];
829
830/// L011 placement window: the summary must start within this many lines
831/// after the front matter.
832const SUMMARY_TOP_LINES: usize = 30;
833
834fn doc_type_name(doc_type: DocType) -> &'static str {
835    match doc_type {
836        DocType::Doc => "doc",
837        DocType::Plan => "plan",
838        DocType::Guide => "guide",
839        DocType::Conversation => "conversation",
840        DocType::Agent => "agent",
841        DocType::Preference => "preference",
842        DocType::Report => "report",
843        DocType::Proposal => "proposal",
844        DocType::Incident => "incident",
845        DocType::Review => "review",
846        DocType::App => "app",
847        DocType::Manifest => "manifest",
848        DocType::Website => "website",
849        DocType::Web => "web",
850        DocType::Deck => "deck",
851        DocType::Slides => "slides",
852        DocType::Presentation => "presentation",
853        DocType::Paper => "paper",
854    }
855}
856
857/// First top-level `::summary` block in the document, if any.
858fn first_summary(doc: &SurfDoc) -> Option<&Span> {
859    doc.blocks.iter().find_map(|b| match b {
860        Block::Summary { span, .. } => Some(span),
861        _ => None,
862    })
863}
864
865/// Stub body inserted by the L010 fix.
866const SUMMARY_STUB_BODY: &str = "TODO: add a one-paragraph summary.";
867
868/// Build the L010 insertion fix: a `::summary` stub after the front matter,
869/// or after the leading `#` title heading when one immediately follows it.
870fn summary_stub_fix(source: &str) -> Option<Fix> {
871    let lines: Vec<&str> = source.split('\n').collect();
872    // L010 only fires when the front matter parsed, so the closer exists.
873    let fm_end = front_matter_close_line(&lines)?;
874    let mut anchor = fm_end;
875    for (i, raw) in lines.iter().enumerate().skip(fm_end + 1) {
876        if raw.trim().is_empty() {
877            continue;
878        }
879        if raw.trim_start().starts_with("# ") {
880            anchor = i;
881        }
882        break;
883    }
884    // Insertion point: start of the line after the anchor line.
885    let after_anchor: usize = lines[..=anchor].iter().map(|l| l.len() + 1).sum();
886    let (offset, text) = if after_anchor > source.len() {
887        // Anchor is the last line and has no trailing newline.
888        (
889            source.len(),
890            format!("\n\n::summary\n{SUMMARY_STUB_BODY}\n::"),
891        )
892    } else {
893        (
894            after_anchor,
895            format!("\n::summary\n{SUMMARY_STUB_BODY}\n::\n"),
896        )
897    };
898    Some(rule_fix(
899        "L010",
900        "insert a '::summary' stub block near the top of the document".to_string(),
901        vec![TextEdit {
902            span: Span {
903                start_line: anchor + 2,
904                end_line: anchor + 2,
905                start_offset: offset,
906                end_offset: offset,
907            },
908            replacement: text,
909        }],
910    ))
911}
912
913/// L010: missing `::summary` block on doc/plan/guide documents.
914///
915/// Fix (suggested): insert a `::summary` stub after the front matter (or the
916/// leading title heading).
917struct MissingSummary;
918
919impl LintRule for MissingSummary {
920    fn id(&self) -> &'static str {
921        "L010"
922    }
923
924    fn check(&self, doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
925        let Some(doc_type) = doc.front_matter.as_ref().and_then(|fm| fm.doc_type) else {
926            return Vec::new();
927        };
928        if !SUMMARY_DOC_TYPES.contains(&doc_type) || first_summary(doc).is_some() {
929            return Vec::new();
930        }
931        vec![diag_fix(
932            "L010",
933            format!(
934                "Document of type '{}' has no '::summary' block",
935                doc_type_name(doc_type)
936            ),
937            None,
938            summary_stub_fix(source),
939        )]
940    }
941}
942
943/// L011: `::summary` present but not within the first ~30 lines after front
944/// matter.
945struct SummaryNotNearTop;
946
947impl LintRule for SummaryNotNearTop {
948    fn id(&self) -> &'static str {
949        "L011"
950    }
951
952    fn check(&self, doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
953        let Some(span) = first_summary(doc) else {
954            return Vec::new();
955        };
956        let lines: Vec<&str> = source.split('\n').collect();
957        // 1-based line number of the first body line.
958        let body_first_line = front_matter_close_line(&lines).map_or(1, |i| i + 2);
959        // 1-based position of the summary among non-front-matter lines.
960        let body_position = span.start_line.saturating_sub(body_first_line) + 1;
961        if body_position <= SUMMARY_TOP_LINES {
962            return Vec::new();
963        }
964        vec![diag(
965            "L011",
966            format!(
967                "'::summary' block starts at line {} — should appear within the first {} lines after front matter",
968                span.start_line, SUMMARY_TOP_LINES
969            ),
970            Some(*span),
971        )]
972    }
973}
974
975// ------------------------------------------------------------------
976// L020 — unknown block name with did-you-mean
977// ------------------------------------------------------------------
978
979/// Levenshtein edit distance between two strings (by chars).
980fn levenshtein(a: &str, b: &str) -> usize {
981    let a: Vec<char> = a.chars().collect();
982    let b: Vec<char> = b.chars().collect();
983    let mut prev: Vec<usize> = (0..=b.len()).collect();
984    let mut cur = vec![0usize; b.len() + 1];
985    for (i, ca) in a.iter().enumerate() {
986        cur[0] = i + 1;
987        for (j, cb) in b.iter().enumerate() {
988            let cost = usize::from(ca != cb);
989            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
990        }
991        std::mem::swap(&mut prev, &mut cur);
992    }
993    prev[b.len()]
994}
995
996/// Unique spec block name within edit distance 2 of `name`, if any.
997/// Returns `None` on a tie at the minimum distance.
998fn did_you_mean<'a>(name: &str, known: &'a BTreeSet<String>) -> Option<&'a str> {
999    let mut best: Option<(&str, usize)> = None;
1000    let mut tie = false;
1001    for cand in known {
1002        if cand.len().abs_diff(name.len()) > 2 {
1003            continue;
1004        }
1005        let d = levenshtein(name, cand);
1006        if d > 2 {
1007            continue;
1008        }
1009        match best {
1010            None => best = Some((cand, d)),
1011            Some((_, bd)) if d < bd => {
1012                best = Some((cand, d));
1013                tie = false;
1014            }
1015            Some((_, bd)) if d == bd => tie = true,
1016            _ => {}
1017        }
1018    }
1019    if tie { None } else { best.map(|(c, _)| c) }
1020}
1021
1022/// L020: block name not registered in `spec/blocks.toml`.
1023struct UnknownBlockName;
1024
1025impl LintRule for UnknownBlockName {
1026    fn id(&self) -> &'static str {
1027        "L020"
1028    }
1029
1030    fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
1031        let known = known_block_names();
1032        let mut out = Vec::new();
1033        for line in scan_lines(source) {
1034            if line.literal {
1035                continue;
1036            }
1037            if let Some((depth, name, _)) = opening_directive(line.trimmed) {
1038                // L002 owns ::table; aliases/sub-directives are parser-known.
1039                if name == "table"
1040                    || known.contains(&name)
1041                    || EXTRA_KNOWN_BLOCK_NAMES.contains(&name.as_str())
1042                {
1043                    continue;
1044                }
1045                // Fix (suggested): rename to the unique did-you-mean match.
1046                let (suggestion, fix) = match did_you_mean(&name, known) {
1047                    Some(s) => {
1048                        let name_start = line.indent() + depth;
1049                        let fix = rule_fix(
1050                            "L020",
1051                            format!("rename '::{name}' to '::{s}'"),
1052                            vec![TextEdit {
1053                                span: line.sub_span(name_start, name_start + name.len()),
1054                                replacement: s.to_string(),
1055                            }],
1056                        );
1057                        (format!(" — did you mean '::{s}'?"), Some(fix))
1058                    }
1059                    None => (String::new(), None),
1060                };
1061                out.push(diag_fix(
1062                    "L020",
1063                    format!("Unknown block '::{name}'{suggestion}"),
1064                    Some(line.span()),
1065                    fix,
1066                ));
1067            }
1068        }
1069        out
1070    }
1071}
1072
1073// ------------------------------------------------------------------
1074// L030 — per-type required front matter fields
1075// ------------------------------------------------------------------
1076
1077/// Build the L030 fix for a missing `updated` field whose value is derivable
1078/// from an existing top-level `created:` line in the raw front matter.
1079///
1080/// Lint core has no clock, so `created`/`updated` can never be filled with
1081/// "today"; the only derivable value is `updated` mirroring `created`.
1082fn derived_updated_fix(source: &str) -> Option<Fix> {
1083    let lines: Vec<&str> = source.split('\n').collect();
1084    let fm_end = front_matter_close_line(&lines)?;
1085    let mut offset = lines[0].len() + 1; // skip the opening `---`
1086    for raw in lines.iter().take(fm_end).skip(1) {
1087        let line_offset = offset;
1088        offset += raw.len() + 1;
1089        if raw.starts_with(char::is_whitespace) {
1090            continue;
1091        }
1092        let Some((key, value)) = raw.split_once(':') else {
1093            continue;
1094        };
1095        if key.trim() != "created" || value.trim().is_empty() {
1096            continue;
1097        }
1098        // Insert `updated: <same raw value>` on the line after `created:`,
1099        // preserving the original quoting style.
1100        let insert_at = line_offset + raw.len() + 1;
1101        let line_no = source[..insert_at].matches('\n').count() + 1;
1102        return Some(rule_fix(
1103            "L030",
1104            format!("insert 'updated:{value}' derived from the created field"),
1105            vec![TextEdit {
1106                span: Span {
1107                    start_line: line_no,
1108                    end_line: line_no,
1109                    start_offset: insert_at,
1110                    end_offset: insert_at,
1111                },
1112                replacement: format!("updated:{value}\n"),
1113            }],
1114        ));
1115    }
1116    None
1117}
1118
1119/// L030: missing required front matter field per doc type.
1120///
1121/// The global checks (title → V001, type → V002) live in validate.rs and are
1122/// NOT duplicated here; L030 covers only the per-type extras:
1123/// plan → status + created, guide → confidence + updated. `type: doc` has no
1124/// extras beyond V001/V002.
1125///
1126/// Fix (safe, partial): only `updated` is derivable without a clock (mirrors
1127/// an existing `created` value). All other missing fields are detection-only.
1128struct MissingRequiredFrontMatter;
1129
1130impl LintRule for MissingRequiredFrontMatter {
1131    fn id(&self) -> &'static str {
1132        "L030"
1133    }
1134
1135    fn check(&self, doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
1136        let Some(fm) = doc.front_matter.as_ref() else {
1137            return Vec::new();
1138        };
1139        let Some(doc_type) = fm.doc_type else {
1140            return Vec::new();
1141        };
1142        let missing: Vec<&str> = match doc_type {
1143            DocType::Plan => {
1144                let mut m = Vec::new();
1145                if fm.status.is_none() {
1146                    m.push("status");
1147                }
1148                if fm.created.is_none() {
1149                    m.push("created");
1150                }
1151                m
1152            }
1153            DocType::Guide => {
1154                let mut m = Vec::new();
1155                if fm.confidence.is_none() {
1156                    m.push("confidence");
1157                }
1158                if fm.updated.is_none() {
1159                    m.push("updated");
1160                }
1161                m
1162            }
1163            _ => Vec::new(),
1164        };
1165        missing
1166            .into_iter()
1167            .map(|field| {
1168                let fix = if field == "updated" && fm.created.is_some() {
1169                    derived_updated_fix(source)
1170                } else {
1171                    None
1172                };
1173                diag_fix(
1174                    "L030",
1175                    format!(
1176                        "Document of type '{}' is missing required front matter field: {field}",
1177                        doc_type_name(doc_type)
1178                    ),
1179                    None,
1180                    fix,
1181                )
1182            })
1183            .collect()
1184    }
1185}
1186
1187// ------------------------------------------------------------------
1188// L031 — enum value invalid only by case/whitespace
1189// ------------------------------------------------------------------
1190
1191/// Valid values for enum-typed front matter fields, as serde accepts them.
1192const FM_ENUM_FIELDS: &[(&str, &[&str])] = &[
1193    (
1194        "type",
1195        &[
1196            "doc",
1197            "guide",
1198            "conversation",
1199            "plan",
1200            "agent",
1201            "preference",
1202            "report",
1203            "proposal",
1204            "incident",
1205            "review",
1206            "app",
1207            "manifest",
1208            "website",
1209            "deck",
1210            "slides",
1211        ],
1212    ),
1213    ("status", &["draft", "active", "closed", "archived"]),
1214    (
1215        "scope",
1216        &[
1217            "personal",
1218            "workspace-private",
1219            "workspace",
1220            "repo",
1221            "public",
1222        ],
1223    ),
1224    ("confidence", &["low", "medium", "high"]),
1225];
1226
1227/// L031: enum-valued front matter field invalid only by letter case or
1228/// surrounding whitespace (e.g. `status: Active`). Works on the raw front
1229/// matter text, so it fires even when the YAML enum parse failed (P002).
1230struct FrontMatterEnumCase;
1231
1232impl LintRule for FrontMatterEnumCase {
1233    fn id(&self) -> &'static str {
1234        "L031"
1235    }
1236
1237    fn check(&self, _doc: &SurfDoc, source: &str) -> Vec<Diagnostic> {
1238        let lines: Vec<&str> = source.split('\n').collect();
1239        let Some(fm_end) = front_matter_close_line(&lines) else {
1240            return Vec::new();
1241        };
1242        let mut out = Vec::new();
1243        let mut offset = lines[0].len() + 1; // skip the opening `---`
1244        for (idx, raw) in lines.iter().enumerate().take(fm_end).skip(1) {
1245            let line_offset = offset;
1246            offset += raw.len() + 1;
1247            // Top-level `key: value` lines only (no indented/nested keys).
1248            if raw.starts_with(char::is_whitespace) {
1249                continue;
1250            }
1251            let Some((key, value)) = raw.split_once(':') else {
1252                continue;
1253            };
1254            let key = key.trim();
1255            let Some((_, valid)) = FM_ENUM_FIELDS.iter().find(|(k, _)| *k == key) else {
1256                continue;
1257            };
1258            let raw_value = value;
1259            let value = value.trim().trim_matches('"').trim_matches('\'');
1260            if value.is_empty() || valid.contains(&value) {
1261                continue;
1262            }
1263            let normalized = value.trim().to_lowercase();
1264            if let Some(expected) = valid.iter().find(|v| **v == normalized) {
1265                // Fix: line-level text edit replacing the raw value bytes
1266                // (including any quotes) with the normalized enum value.
1267                // Never YAML re-serialization — the rest of the front matter
1268                // is untouched.
1269                let colon_at = raw.len() - raw_value.len();
1270                let ws = raw_value.len() - raw_value.trim_start().len();
1271                let val_start = line_offset + colon_at + ws;
1272                let val_end = line_offset + colon_at + raw_value.trim_end().len();
1273                let fix = rule_fix(
1274                    "L031",
1275                    format!("normalize '{key}' value to '{expected}'"),
1276                    vec![TextEdit {
1277                        span: Span {
1278                            start_line: idx + 1,
1279                            end_line: idx + 1,
1280                            start_offset: val_start,
1281                            end_offset: val_end,
1282                        },
1283                        replacement: (*expected).to_string(),
1284                    }],
1285                );
1286                out.push(diag_fix(
1287                    "L031",
1288                    format!(
1289                        "Front matter field '{key}' has value '{value}' — should be '{expected}'"
1290                    ),
1291                    Some(Span {
1292                        start_line: idx + 1,
1293                        end_line: idx + 1,
1294                        start_offset: line_offset,
1295                        end_offset: line_offset + raw.len(),
1296                    }),
1297                    Some(fix),
1298                ));
1299            }
1300        }
1301        out
1302    }
1303}
1304
1305// ------------------------------------------------------------------
1306// Unified check entry point
1307// ------------------------------------------------------------------
1308
1309/// Result of running all three diagnostic layers over a source string.
1310#[derive(Debug, Clone, Serialize, Deserialize)]
1311pub struct CheckReport {
1312    /// All diagnostics, sorted by span start offset then rule code.
1313    pub diagnostics: Vec<Diagnostic>,
1314    /// Number of `Severity::Error` diagnostics.
1315    pub error_count: usize,
1316    /// Number of `Severity::Warning` diagnostics.
1317    pub warning_count: usize,
1318    /// Number of `Severity::Info` diagnostics.
1319    pub info_count: usize,
1320    /// Number of diagnostics carrying an auto-fix (`fix.is_some()`).
1321    pub fixable_count: usize,
1322}
1323
1324/// Configuration for [`check_with`]: per-rule severity overrides and
1325/// disabled rule ids. Applies to all three layers (P/V/L codes).
1326#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1327pub struct LintConfig {
1328    /// Override the default severity of a rule id.
1329    pub severity_overrides: BTreeMap<String, Severity>,
1330    /// Rule ids whose diagnostics are dropped entirely.
1331    pub disabled_rules: BTreeSet<String>,
1332    /// Extra allowed front matter values per field (e.g. `"type"` →
1333    /// `{"checkpoint"}`): suppresses the P005 unknown-value diagnostic for
1334    /// the listed values. The front matter itself still parses to `None`
1335    /// (the typed schema is unchanged) — this only silences the diagnostic.
1336    #[serde(default)]
1337    pub extra_frontmatter_values: BTreeMap<String, BTreeSet<String>>,
1338}
1339
1340/// Run all three diagnostic layers (parse, validate, lint) over `input`.
1341pub fn check(input: &str) -> CheckReport {
1342    check_with(input, &LintConfig::default())
1343}
1344
1345/// [`check`] with severity overrides and disabled rules applied.
1346pub fn check_with(input: &str, cfg: &LintConfig) -> CheckReport {
1347    let result = crate::parse::parse(input);
1348    let mut diagnostics = result.diagnostics;
1349    attach_parse_fixes(&mut diagnostics, &result.doc.source);
1350    // The front matter failed to parse (P002 broken YAML / P005 unknown
1351    // value) — V001/V002 would fire only because `front_matter` is `None`,
1352    // duplicating the parse diagnostic even when title/type are literally
1353    // present in the raw YAML. Suppress them; the P-code is the signal.
1354    // (Keyed on the raw parse diagnostics, BEFORE config-level suppression,
1355    // so a P005 silenced via extra_frontmatter_values still suppresses the
1356    // cascade.)
1357    let front_matter_unparseable = diagnostics
1358        .iter()
1359        .any(|d| matches!(d.code.as_deref(), Some("P002") | Some("P005")));
1360    let mut schema = result.doc.validate();
1361    if front_matter_unparseable {
1362        schema.retain(|d| !matches!(d.code.as_deref(), Some("V001") | Some("V002")));
1363    }
1364    diagnostics.extend(schema);
1365    // P005 values explicitly allowed by the caller's config are silenced
1366    // (exact reconstruction of the parse-layer message).
1367    if !cfg.extra_frontmatter_values.is_empty() {
1368        diagnostics.retain(|d| {
1369            d.code.as_deref() != Some("P005")
1370                || !cfg.extra_frontmatter_values.iter().any(|(field, values)| {
1371                    values
1372                        .iter()
1373                        .any(|v| d.message == crate::parse::p005_message(field, v))
1374                })
1375        });
1376    }
1377    for rule in all_rules() {
1378        if cfg.disabled_rules.contains(rule.id()) {
1379            continue;
1380        }
1381        diagnostics.extend(rule.check(&result.doc, &result.doc.source));
1382    }
1383
1384    diagnostics.retain(|d| {
1385        d.code
1386            .as_deref()
1387            .is_none_or(|code| !cfg.disabled_rules.contains(code))
1388    });
1389    for d in &mut diagnostics {
1390        if let Some(severity) = d
1391            .code
1392            .as_deref()
1393            .and_then(|c| cfg.severity_overrides.get(c))
1394        {
1395            d.severity = *severity;
1396        }
1397    }
1398
1399    diagnostics.sort_by(|a, b| {
1400        let ka = a.span.map_or(0, |s| s.start_offset);
1401        let kb = b.span.map_or(0, |s| s.start_offset);
1402        ka.cmp(&kb).then_with(|| a.code.cmp(&b.code))
1403    });
1404
1405    let count = |sev: Severity| diagnostics.iter().filter(|d| d.severity == sev).count();
1406    CheckReport {
1407        error_count: count(Severity::Error),
1408        warning_count: count(Severity::Warning),
1409        info_count: count(Severity::Info),
1410        fixable_count: diagnostics.iter().filter(|d| d.fix.is_some()).count(),
1411        diagnostics,
1412    }
1413}
1414
1415// ------------------------------------------------------------------
1416// Parse-layer (P-code) fix attachment
1417// ------------------------------------------------------------------
1418
1419/// Attach deterministic fixes to parse-layer diagnostics where one exists:
1420///
1421/// - **P001** (unclosed block) — only the force-closed-at-EOF case: append a
1422///   closer with the opener's colon count at end of file. Orphans force-closed
1423///   mid-document by an outer closer get no fix (L005 covers the closer side).
1424/// - **P002** (front matter) — only the unclosed-`---` case, and only when the
1425///   lines between the opener and the first blank line already parse as valid
1426///   front matter YAML: insert the closing `---` before that blank line. The
1427///   invalid-YAML case is detection-only.
1428fn attach_parse_fixes(diagnostics: &mut [Diagnostic], source: &str) {
1429    let lines: Vec<&str> = source.split('\n').collect();
1430    // Depth stack still open at EOF in the LINT layer's fence-aware view.
1431    // The parser is not fence-aware, so it can report a directive inside a
1432    // markdown ``` fence as unclosed; appending a closer for it would create
1433    // exactly the orphan closer L005's fix removes (P001↔L005 oscillation).
1434    // EOF-closer fixes are granted innermost-first only while they pop a
1435    // matching depth off this stack, so every closer P001 appends is one the
1436    // style layer agrees is needed.
1437    let mut lint_stack = lint_depth_stack_at_eof(source);
1438    for d in diagnostics {
1439        match d.code.as_deref() {
1440            Some("P001") => d.fix = p001_eof_closer_fix(d, source, &lines, &mut lint_stack),
1441            Some("P002") => d.fix = p002_close_front_matter_fix(d, source, &lines),
1442            _ => {}
1443        }
1444    }
1445}
1446
1447/// Nesting depth stack left open at EOF, computed over the lint layer's
1448/// fence-aware [`scan_lines`] with L005's exact push/pop algorithm.
1449fn lint_depth_stack_at_eof(source: &str) -> Vec<usize> {
1450    let mut stack: Vec<usize> = Vec::new();
1451    for line in scan_lines(source) {
1452        if line.literal {
1453            continue;
1454        }
1455        if let Some(d) = closing_directive_depth(line.trimmed) {
1456            if let Some(pos) = stack.iter().rposition(|&s| s == d) {
1457                stack.truncate(pos);
1458            }
1459            continue;
1460        }
1461        if let Some((d, _, _)) = opening_directive(line.trimmed) {
1462            stack.push(d);
1463        }
1464    }
1465    stack
1466}
1467
1468/// P001 fix: append the missing closer at EOF (force-closed-at-EOF case only,
1469/// and only when the lint-layer depth stack agrees a closer is needed —
1470/// `lint_stack` is popped per granted fix; see [`attach_parse_fixes`]).
1471fn p001_eof_closer_fix(
1472    d: &Diagnostic,
1473    source: &str,
1474    lines: &[&str],
1475    lint_stack: &mut Vec<usize>,
1476) -> Option<Fix> {
1477    let span = d.span?;
1478    // The EOF force-close span always ends at the last byte AND its final
1479    // line is not a closing directive (otherwise this is the mid-document
1480    // orphan case, where the closer at span end belongs to an outer block).
1481    if span.end_offset != source.len() {
1482        return None;
1483    }
1484    let last = lines.get(span.end_line.saturating_sub(1))?;
1485    if closing_directive_depth(last.trim()).is_some() {
1486        return None;
1487    }
1488    let opener = lines.get(span.start_line.saturating_sub(1))?;
1489    let (depth, name, _) = opening_directive(opener.trim())?;
1490    // Style-layer agreement gate: the appended closer must pop the innermost
1491    // open block of the fence-aware view too, or L005 would flag it as an
1492    // orphan and remove it again.
1493    if lint_stack.last() != Some(&depth) {
1494        return None;
1495    }
1496    lint_stack.pop();
1497    let closer = ":".repeat(depth);
1498    let text = if source.is_empty() || source.ends_with('\n') {
1499        format!("{closer}\n")
1500    } else {
1501        format!("\n{closer}\n")
1502    };
1503    let line_no = lines.len();
1504    Some(Fix {
1505        edits: vec![TextEdit {
1506            span: Span {
1507                start_line: line_no,
1508                end_line: line_no,
1509                start_offset: source.len(),
1510                end_offset: source.len(),
1511            },
1512            replacement: text,
1513        }],
1514        safety: FixSafety::Safe,
1515        description: format!("append the missing '{closer}' closer for '::{name}' at end of file"),
1516    })
1517}
1518
1519/// P002 fix: close an unclosed front matter block before the first blank line,
1520/// when the captured lines already form valid front matter YAML.
1521fn p002_close_front_matter_fix(d: &Diagnostic, source: &str, lines: &[&str]) -> Option<Fix> {
1522    // Only the unclosed-delimiter case (span pinned to line 1 by the parser);
1523    // the invalid-YAML case spans the whole front matter block.
1524    if !d.message.contains("never closed") {
1525        return None;
1526    }
1527    let blank = lines
1528        .iter()
1529        .enumerate()
1530        .skip(1)
1531        .find(|(_, l)| l.trim().is_empty())
1532        .map(|(i, _)| i)?;
1533    if blank < 2 {
1534        return None; // nothing between the `---` and the blank line
1535    }
1536    let yaml = lines[1..blank].join("\n");
1537    if serde_yaml::from_str::<FrontMatter>(&yaml).is_err() {
1538        return None;
1539    }
1540    let offset: usize = lines[..blank].iter().map(|l| l.len() + 1).sum();
1541    if offset > source.len() {
1542        return None;
1543    }
1544    Some(Fix {
1545        edits: vec![TextEdit {
1546            span: Span {
1547                start_line: blank + 1,
1548                end_line: blank + 1,
1549                start_offset: offset,
1550                end_offset: offset,
1551            },
1552            replacement: "---\n".to_string(),
1553        }],
1554        safety: FixSafety::Safe,
1555        description: "close the front matter with '---' before the first blank line".to_string(),
1556    })
1557}
1558
1559// ------------------------------------------------------------------
1560// Fix application engine
1561// ------------------------------------------------------------------
1562
1563/// Maximum number of fix → re-check passes in [`apply_fixes`].
1564pub const MAX_FIX_ITERATIONS: usize = 10;
1565
1566/// A fix that was applied by [`apply_fixes`].
1567#[derive(Debug, Clone, Serialize, Deserialize)]
1568pub struct AppliedFix {
1569    /// Rule code of the fixed diagnostic (`P001`, `L003`, …).
1570    pub code: String,
1571    /// The fix's human-readable description.
1572    pub description: String,
1573}
1574
1575/// A fix that was skipped by [`apply_fixes`].
1576#[derive(Debug, Clone, Serialize, Deserialize)]
1577pub struct SkippedFix {
1578    /// Rule code of the diagnostic whose fix was skipped.
1579    pub code: String,
1580    /// The fix's human-readable description.
1581    pub description: String,
1582    /// Why the fix was not applied.
1583    pub reason: String,
1584}
1585
1586/// Result of [`apply_fixes`] / [`apply_fixes_once`].
1587#[derive(Debug, Clone, Serialize, Deserialize)]
1588pub struct FixOutcome {
1589    /// The fixed source. Bytes outside the applied edits are preserved
1590    /// exactly (CRLF input is normalised to LF first, matching `parse`).
1591    pub source: String,
1592    /// Fixes applied, across all passes, in application order.
1593    pub applied: Vec<AppliedFix>,
1594    /// Fixes skipped in the final pass (earlier-pass skips are retried and
1595    /// either applied or re-skipped).
1596    pub skipped: Vec<SkippedFix>,
1597    /// Number of passes that changed the source (0 = nothing to fix;
1598    /// [`MAX_FIX_ITERATIONS`] = fixpoint not reached).
1599    pub iterations: usize,
1600}
1601
1602/// True when two edits cannot be applied in the same pass: their byte ranges
1603/// overlap, or they start at the same offset (ambiguous ordering for
1604/// insertions).
1605fn edits_conflict(a: &TextEdit, b: &TextEdit) -> bool {
1606    let (a, b) = (&a.span, &b.span);
1607    a.start_offset == b.start_offset
1608        || (a.start_offset < b.end_offset && b.start_offset < a.end_offset)
1609}
1610
1611/// True when the edit's byte range is applicable to `source` (in bounds,
1612/// ordered, and on UTF-8 character boundaries).
1613fn edit_span_is_valid(e: &TextEdit, source: &str) -> bool {
1614    let s = &e.span;
1615    s.start_offset <= s.end_offset
1616        && s.end_offset <= source.len()
1617        && source.is_char_boundary(s.start_offset)
1618        && source.is_char_boundary(s.end_offset)
1619}
1620
1621/// One fix pass: run [`check`], take every fix at or below `safety`, drop
1622/// conflicting edits (first fix wins; ties prefer the innermost/latest
1623/// diagnostic so nested EOF closers land inside-out), and splice the accepted
1624/// edits into `source` in descending start-offset order.
1625///
1626/// Returns `None` as the new source when no edit was applied.
1627fn run_fix_pass(
1628    source: &str,
1629    safety: FixSafety,
1630    cfg: &LintConfig,
1631) -> (Option<String>, Vec<AppliedFix>, Vec<SkippedFix>) {
1632    let report = check_with(source, cfg);
1633    let mut candidates: Vec<(&Diagnostic, &Fix)> = report
1634        .diagnostics
1635        .iter()
1636        .filter_map(|d| d.fix.as_ref().map(|f| (d, f)))
1637        .filter(|(_, f)| f.safety <= safety)
1638        .collect();
1639    // Precedence order = application order: highest first-edit offset first;
1640    // ties broken by the innermost diagnostic (largest span start).
1641    candidates.sort_by(|(da, fa), (db, fb)| {
1642        let key = |f: &Fix| {
1643            f.edits
1644                .iter()
1645                .map(|e| e.span.start_offset)
1646                .min()
1647                .unwrap_or(0)
1648        };
1649        key(fb).cmp(&key(fa)).then_with(|| {
1650            let span_start = |d: &Diagnostic| d.span.map_or(0, |s| s.start_offset);
1651            span_start(db).cmp(&span_start(da))
1652        })
1653    });
1654
1655    let mut accepted: Vec<TextEdit> = Vec::new();
1656    let mut applied = Vec::new();
1657    let mut skipped = Vec::new();
1658    'candidate: for (d, f) in candidates {
1659        let code = d.code.clone().unwrap_or_default();
1660        let skip = |reason: &str, skipped: &mut Vec<SkippedFix>| {
1661            skipped.push(SkippedFix {
1662                code: code.clone(),
1663                description: f.description.clone(),
1664                reason: reason.to_string(),
1665            });
1666        };
1667        if f.edits.is_empty() {
1668            skip("fix carries no edits", &mut skipped);
1669            continue;
1670        }
1671        for e in &f.edits {
1672            if !edit_span_is_valid(e, source) {
1673                skip("edit span is out of bounds for this source", &mut skipped);
1674                continue 'candidate;
1675            }
1676        }
1677        for (i, e) in f.edits.iter().enumerate() {
1678            if accepted.iter().any(|a| edits_conflict(a, e))
1679                || f.edits[..i].iter().any(|a| edits_conflict(a, e))
1680            {
1681                skip(
1682                    "overlaps an edit from an already-accepted fix",
1683                    &mut skipped,
1684                );
1685                continue 'candidate;
1686            }
1687        }
1688        accepted.extend(f.edits.iter().cloned());
1689        applied.push(AppliedFix {
1690            code,
1691            description: f.description.clone(),
1692        });
1693    }
1694
1695    if accepted.is_empty() {
1696        return (None, applied, skipped);
1697    }
1698    // Splice descending so earlier offsets stay valid.
1699    accepted.sort_by(|a, b| {
1700        b.span
1701            .start_offset
1702            .cmp(&a.span.start_offset)
1703            .then(b.span.end_offset.cmp(&a.span.end_offset))
1704    });
1705    let mut out = source.to_string();
1706    for e in &accepted {
1707        out.replace_range(e.span.start_offset..e.span.end_offset, &e.replacement);
1708    }
1709    (Some(out), applied, skipped)
1710}
1711
1712/// Apply every auto-fix at or below the requested `safety` tier, re-running
1713/// [`check`] after each pass until no applicable fix remains (fixpoint) or
1714/// [`MAX_FIX_ITERATIONS`] passes were applied.
1715///
1716/// CRLF input is normalised to LF first (the same normalisation `parse`
1717/// applies, so edit offsets line up); all other untouched bytes are preserved
1718/// exactly. Guarantees enforced by property tests: idempotent, converging
1719/// (an applied fix's diagnostic is gone on re-check), non-destructive, and
1720/// deterministic.
1721pub fn apply_fixes(input: &str, safety: FixSafety) -> FixOutcome {
1722    apply_fixes_with(input, safety, &LintConfig::default())
1723}
1724
1725/// [`apply_fixes`] with a [`LintConfig`]: disabled rules contribute no fixes,
1726/// and config-suppressed diagnostics (e.g. P005 extra values) are likewise
1727/// never "fixed". Severity overrides do not change fix behaviour.
1728pub fn apply_fixes_with(input: &str, safety: FixSafety, cfg: &LintConfig) -> FixOutcome {
1729    let mut source = input.replace("\r\n", "\n");
1730    let mut applied = Vec::new();
1731    let mut skipped = Vec::new();
1732    let mut iterations = 0usize;
1733    while iterations < MAX_FIX_ITERATIONS {
1734        let (next, mut pass_applied, pass_skipped) = run_fix_pass(&source, safety, cfg);
1735        skipped = pass_skipped;
1736        match next {
1737            Some(s) => {
1738                iterations += 1;
1739                source = s;
1740                applied.append(&mut pass_applied);
1741            }
1742            None => break,
1743        }
1744    }
1745    FixOutcome {
1746        source,
1747        applied,
1748        skipped,
1749        iterations,
1750    }
1751}
1752
1753/// Single-pass variant of [`apply_fixes`]: applies at most one round of
1754/// non-conflicting fixes without iterating to a fixpoint. Useful for callers
1755/// that want to show intermediate states (e.g. a CLI `--diff` mode).
1756pub fn apply_fixes_once(input: &str, safety: FixSafety) -> FixOutcome {
1757    let source = input.replace("\r\n", "\n");
1758    let (next, applied, skipped) = run_fix_pass(&source, safety, &LintConfig::default());
1759    let iterations = usize::from(next.is_some());
1760    FixOutcome {
1761        source: next.unwrap_or(source),
1762        applied,
1763        skipped,
1764        iterations,
1765    }
1766}
1767
1768// ------------------------------------------------------------------
1769// JSON envelope (shared by the surf-lint CLI and the wasm bindings)
1770// ------------------------------------------------------------------
1771
1772/// Version of the JSON envelope schema emitted by [`report_to_json`] /
1773/// [`reports_to_json`] — and therefore by `surf-lint check --format json`
1774/// and the wasm `check_json` export, which both serialize these values.
1775pub const JSON_SCHEMA_VERSION: u32 = 1;
1776
1777/// Per-file object of the surf-lint JSON envelope.
1778///
1779/// `path` is the file label; pass `None` for in-memory input (e.g. the wasm
1780/// `check_json` export) and the `path` key is omitted entirely. Diagnostics
1781/// are the [`Diagnostic`] serde serialization verbatim: spans carry line
1782/// numbers and byte offsets, fix payloads (description, safety, edits) ride
1783/// along when present, and `span`/`code`/`fix` keys are omitted when `None`.
1784pub fn report_to_json(path: Option<&str>, report: &CheckReport) -> serde_json::Value {
1785    let mut obj = serde_json::Map::new();
1786    if let Some(path) = path {
1787        obj.insert("path".into(), serde_json::Value::String(path.to_owned()));
1788    }
1789    obj.insert(
1790        "diagnostics".into(),
1791        serde_json::to_value(&report.diagnostics).expect("Diagnostic always serializes"),
1792    );
1793    obj.insert("error_count".into(), report.error_count.into());
1794    obj.insert("warning_count".into(), report.warning_count.into());
1795    obj.insert("info_count".into(), report.info_count.into());
1796    obj.insert("fixable_count".into(), report.fixable_count.into());
1797    serde_json::Value::Object(obj)
1798}
1799
1800/// Full surf-lint JSON envelope over `(path, report)` pairs:
1801///
1802/// ```json
1803/// { "schema_version": 1, "files": [ ... ], "summary": { ... } }
1804/// ```
1805///
1806/// Each pair becomes a [`report_to_json`] object in `files`; `summary`
1807/// aggregates the per-file counts (`files` = number of pairs). This is the
1808/// single construction point for the envelope, so the CLI (`--format json`)
1809/// and the wasm `check_json` export emit identical structures.
1810pub fn reports_to_json(files: &[(Option<&str>, &CheckReport)]) -> serde_json::Value {
1811    let mut errors = 0usize;
1812    let mut warnings = 0usize;
1813    let mut infos = 0usize;
1814    let mut fixable = 0usize;
1815    let json_files: Vec<serde_json::Value> = files
1816        .iter()
1817        .map(|(path, report)| {
1818            errors += report.error_count;
1819            warnings += report.warning_count;
1820            infos += report.info_count;
1821            fixable += report.fixable_count;
1822            report_to_json(*path, report)
1823        })
1824        .collect();
1825    serde_json::json!({
1826        "schema_version": JSON_SCHEMA_VERSION,
1827        "files": json_files,
1828        "summary": {
1829            "files": files.len(),
1830            "error_count": errors,
1831            "warning_count": warnings,
1832            "info_count": infos,
1833            "fixable_count": fixable,
1834        },
1835    })
1836}
1837
1838// ------------------------------------------------------------------
1839// Tests
1840// ------------------------------------------------------------------
1841
1842#[cfg(test)]
1843mod tests {
1844    use super::*;
1845    use pretty_assertions::assert_eq;
1846
1847    /// Run a single rule over parsed input.
1848    fn run_rule(rule: &dyn LintRule, input: &str) -> Vec<Diagnostic> {
1849        let result = crate::parse::parse(input);
1850        rule.check(&result.doc, &result.doc.source)
1851    }
1852
1853    fn codes(diags: &[Diagnostic]) -> Vec<&str> {
1854        diags.iter().filter_map(|d| d.code.as_deref()).collect()
1855    }
1856
1857    // --- registry ---
1858
1859    #[test]
1860    fn registry_loads_with_expected_rules() {
1861        let reg = rule_registry();
1862        for id in ["P001", "P002", "P003", "L001", "L005", "L020", "L031"] {
1863            assert!(reg.contains_key(id), "registry missing {id}");
1864        }
1865        assert_eq!(rule_registry_total(), reg.len());
1866    }
1867
1868    #[test]
1869    fn registry_severity_matches_defaults() {
1870        assert_eq!(registry_severity("P002"), Severity::Error);
1871        assert_eq!(registry_severity("P001"), Severity::Warning);
1872        assert_eq!(registry_severity("L011"), Severity::Info);
1873    }
1874
1875    #[test]
1876    fn known_block_names_loaded_from_spec() {
1877        let known = known_block_names();
1878        assert!(known.contains("callout"));
1879        assert!(known.contains("section"));
1880        assert!(!known.contains("table"));
1881    }
1882
1883    // --- L001 / L002 ---
1884
1885    #[test]
1886    fn l001_fires_on_section_block() {
1887        let diags = run_rule(&SectionBlockUsed, "::section[title=Intro]\nText.\n::\n");
1888        assert_eq!(codes(&diags), vec!["L001"]);
1889        assert_eq!(diags[0].span.unwrap().start_line, 1);
1890    }
1891
1892    #[test]
1893    fn l001_silent_on_headings() {
1894        let diags = run_rule(&SectionBlockUsed, "## Intro\n\nText.\n");
1895        assert!(diags.is_empty());
1896    }
1897
1898    #[test]
1899    fn l002_fires_on_table_block() {
1900        let diags = run_rule(&TableBlockUsed, "::table\n| a | b |\n::\n");
1901        assert_eq!(codes(&diags), vec!["L002"]);
1902    }
1903
1904    // --- L003 ---
1905
1906    #[test]
1907    fn l003_fires_on_curly_attrs() {
1908        let diags = run_rule(&CurlyAttrBraces, "::callout{type=info}\nHi\n::\n");
1909        assert_eq!(codes(&diags), vec!["L003"]);
1910        assert!(diags[0].message.contains("[square]"));
1911    }
1912
1913    #[test]
1914    fn l003_silent_on_square_attrs() {
1915        let diags = run_rule(&CurlyAttrBraces, "::callout[type=info]\nHi\n::\n");
1916        assert!(diags.is_empty());
1917    }
1918
1919    #[test]
1920    fn l003_silent_inside_code_block() {
1921        let input = "::code[lang=surf]\n::callout{type=info}\n::\n";
1922        let diags = run_rule(&CurlyAttrBraces, input);
1923        assert!(diags.is_empty(), "code block content is literal: {diags:?}");
1924    }
1925
1926    #[test]
1927    fn l003_silent_inside_markdown_fence() {
1928        let input = "```\n::callout{type=info}\n```\n";
1929        let diags = run_rule(&CurlyAttrBraces, input);
1930        assert!(diags.is_empty(), "fenced content is literal: {diags:?}");
1931    }
1932
1933    // --- L004 ---
1934
1935    #[test]
1936    fn l004_fires_on_opener_with_trailing_content() {
1937        let diags = run_rule(
1938            &DirectiveNotOnOwnLine,
1939            "::callout[type=info] Watch out!\n::\n",
1940        );
1941        assert_eq!(codes(&diags), vec!["L004"]);
1942    }
1943
1944    #[test]
1945    fn l004_fires_on_closer_with_trailing_content() {
1946        // Non-name tail: still a closer with content on the same line.
1947        let diags = run_rule(
1948            &DirectiveNotOnOwnLine,
1949            "::callout[type=info]\nHi\n:: 42 done\n",
1950        );
1951        assert_eq!(codes(&diags), vec!["L004"]);
1952        // A name-shaped tail (`:: done`) is a tolerant opener since
1953        // p3-copy-sweep — owned by the parser (P001 when unclosed), not L004.
1954        let none = run_rule(
1955            &DirectiveNotOnOwnLine,
1956            "::callout[type=info]\nHi\n:: done\n::\n",
1957        );
1958        assert!(none.is_empty(), "tolerant opener misread as closer: {none:?}");
1959    }
1960
1961    #[test]
1962    fn l004_silent_on_clean_directives() {
1963        let diags = run_rule(&DirectiveNotOnOwnLine, "::callout[type=info]\nHi\n::\n");
1964        assert!(diags.is_empty());
1965    }
1966
1967    #[test]
1968    fn l004_defers_curly_openers_to_l003() {
1969        let diags = run_rule(&DirectiveNotOnOwnLine, "::callout{type=info}\nHi\n::\n");
1970        assert!(diags.is_empty());
1971    }
1972
1973    // --- L005 ---
1974
1975    #[test]
1976    fn l005_fires_on_overdeep_opener() {
1977        let diags = run_rule(&NestingColonMismatch, ":::column\nLeft\n:::\n");
1978        assert_eq!(codes(&diags), vec!["L005"]);
1979        assert!(diags[0].message.contains("at most 2"));
1980    }
1981
1982    #[test]
1983    fn l005_fires_on_orphan_closer() {
1984        let diags = run_rule(&NestingColonMismatch, "::callout\nHi\n::\n::\n");
1985        assert_eq!(codes(&diags), vec!["L005"]);
1986        assert!(diags[0].message.contains("does not match any open block"));
1987    }
1988
1989    #[test]
1990    fn l005_silent_on_correct_nesting() {
1991        let input = "::columns\n:::column\nLeft\n:::\n:::column\nRight\n:::\n::\n";
1992        let diags = run_rule(&NestingColonMismatch, input);
1993        assert!(diags.is_empty(), "{diags:?}");
1994    }
1995
1996    #[test]
1997    fn l005_silent_on_same_depth_leaf_children() {
1998        // Leaf directives inside ::page legally sit at the parent's depth.
1999        let input = "::page[route=\"/\"]\n::cta[label=\"Go\" href=\"/x\"]\n::hero-image[src=\"a.jpg\"]\n::\n";
2000        let diags = run_rule(&NestingColonMismatch, input);
2001        assert!(diags.is_empty(), "{diags:?}");
2002    }
2003
2004    // --- L010 / L011 ---
2005
2006    #[test]
2007    fn l010_fires_on_plan_without_summary() {
2008        let input = "---\ntitle: T\ntype: plan\n---\n# Heading\n";
2009        let diags = run_rule(&MissingSummary, input);
2010        assert_eq!(codes(&diags), vec!["L010"]);
2011        assert!(diags[0].message.contains("'plan'"));
2012    }
2013
2014    #[test]
2015    fn l010_silent_when_summary_present() {
2016        let input = "---\ntitle: T\ntype: doc\n---\n::summary\nShort.\n::\n";
2017        let diags = run_rule(&MissingSummary, input);
2018        assert!(diags.is_empty());
2019    }
2020
2021    #[test]
2022    fn l010_silent_on_other_doc_types() {
2023        let input = "---\ntitle: T\ntype: report\n---\n# Heading\n";
2024        let diags = run_rule(&MissingSummary, input);
2025        assert!(diags.is_empty());
2026    }
2027
2028    #[test]
2029    fn l011_fires_on_late_summary() {
2030        let filler = "Filler line.\n".repeat(35);
2031        let input = format!("---\ntitle: T\ntype: doc\n---\n{filler}::summary\nShort.\n::\n");
2032        let diags = run_rule(&SummaryNotNearTop, &input);
2033        assert_eq!(codes(&diags), vec!["L011"]);
2034        assert_eq!(diags[0].severity, Severity::Info);
2035    }
2036
2037    #[test]
2038    fn l011_silent_on_early_summary() {
2039        let input = "---\ntitle: T\ntype: doc\n---\n\n::summary\nShort.\n::\n";
2040        let diags = run_rule(&SummaryNotNearTop, input);
2041        assert!(diags.is_empty());
2042    }
2043
2044    // --- L020 ---
2045
2046    #[test]
2047    fn levenshtein_basics() {
2048        assert_eq!(levenshtein("callout", "callout"), 0);
2049        assert_eq!(levenshtein("callouts", "callout"), 1);
2050        assert_eq!(levenshtein("colout", "callout"), 2);
2051        assert_eq!(levenshtein("table", "tabs"), 2);
2052    }
2053
2054    #[test]
2055    fn l020_fires_with_suggestion() {
2056        let diags = run_rule(&UnknownBlockName, "::callouts[type=info]\nHi\n::\n");
2057        assert_eq!(codes(&diags), vec!["L020"]);
2058        assert!(
2059            diags[0].message.contains("did you mean '::callout'?"),
2060            "{}",
2061            diags[0].message
2062        );
2063    }
2064
2065    #[test]
2066    fn l020_fires_without_suggestion_when_no_close_match() {
2067        let diags = run_rule(&UnknownBlockName, "::zzqqxx\nHi\n::\n");
2068        assert_eq!(codes(&diags), vec!["L020"]);
2069        assert!(!diags[0].message.contains("did you mean"));
2070    }
2071
2072    #[test]
2073    fn l020_silent_on_known_planned_and_alias_names() {
2074        // `countdown` is planned-only in the spec; `column` and
2075        // `action-items` are parser-accepted extras.
2076        let input = "::countdown[date=2026-12-31]\n::\n::columns\n:::column\nx\n:::\n::\n::action-items\n- do it\n::\n";
2077        let diags = run_rule(&UnknownBlockName, input);
2078        assert!(diags.is_empty(), "{diags:?}");
2079    }
2080
2081    #[test]
2082    fn l020_defers_table_to_l002() {
2083        let diags = run_rule(&UnknownBlockName, "::table\n| a |\n::\n");
2084        assert!(diags.is_empty());
2085    }
2086
2087    // --- L030 ---
2088
2089    #[test]
2090    fn l030_fires_on_plan_missing_status_and_created() {
2091        let input = "---\ntitle: T\ntype: plan\n---\nBody.\n";
2092        let diags = run_rule(&MissingRequiredFrontMatter, input);
2093        assert_eq!(codes(&diags), vec!["L030", "L030"]);
2094        assert!(diags.iter().any(|d| d.message.contains("status")));
2095        assert!(diags.iter().any(|d| d.message.contains("created")));
2096    }
2097
2098    #[test]
2099    fn l030_fires_on_guide_missing_confidence_and_updated() {
2100        let input = "---\ntitle: T\ntype: guide\n---\nBody.\n";
2101        let diags = run_rule(&MissingRequiredFrontMatter, input);
2102        assert_eq!(codes(&diags), vec!["L030", "L030"]);
2103        assert!(diags.iter().any(|d| d.message.contains("confidence")));
2104        assert!(diags.iter().any(|d| d.message.contains("updated")));
2105    }
2106
2107    #[test]
2108    fn l030_silent_on_complete_plan_and_plain_doc() {
2109        let plan =
2110            "---\ntitle: T\ntype: plan\nstatus: active\ncreated: \"2026-06-06\"\n---\nBody.\n";
2111        assert!(run_rule(&MissingRequiredFrontMatter, plan).is_empty());
2112        // `type: doc` requires only title+type, already covered by V001/V002.
2113        let doc = "---\ntitle: T\ntype: doc\n---\nBody.\n";
2114        assert!(run_rule(&MissingRequiredFrontMatter, doc).is_empty());
2115    }
2116
2117    // --- L031 ---
2118
2119    #[test]
2120    fn l031_fires_on_case_mismatch() {
2121        let input = "---\ntitle: T\ntype: doc\nstatus: Active\n---\nBody.\n";
2122        let diags = run_rule(&FrontMatterEnumCase, input);
2123        assert_eq!(codes(&diags), vec!["L031"]);
2124        assert!(diags[0].message.contains("'Active'"));
2125        assert!(diags[0].message.contains("'active'"));
2126        assert_eq!(diags[0].span.unwrap().start_line, 4);
2127    }
2128
2129    #[test]
2130    fn l031_fires_on_quoted_case_mismatch_in_type() {
2131        let input = "---\ntitle: T\ntype: \"Plan\"\n---\nBody.\n";
2132        let diags = run_rule(&FrontMatterEnumCase, input);
2133        assert_eq!(codes(&diags), vec!["L031"]);
2134        assert!(diags[0].message.contains("'plan'"));
2135    }
2136
2137    #[test]
2138    fn l031_silent_on_valid_and_unfixable_values() {
2139        // Valid value: no diagnostic.
2140        let valid = "---\ntitle: T\ntype: doc\nstatus: active\n---\n";
2141        assert!(run_rule(&FrontMatterEnumCase, valid).is_empty());
2142        // Genuinely invalid value (not a case issue): V/parse layers own it.
2143        let invalid = "---\ntitle: T\ntype: doc\nstatus: bogus\n---\n";
2144        assert!(run_rule(&FrontMatterEnumCase, invalid).is_empty());
2145    }
2146
2147    // --- check() / check_with() ---
2148
2149    #[test]
2150    fn check_merges_all_three_layers_sorted() {
2151        // Parse layer: unclosed callout (P001). Schema layer: missing front
2152        // matter (V001/V002). Style layer: unknown block (L020).
2153        let input = "::wibble\nHi\n::\n\n::callout[type=info]\nUnclosed";
2154        let report = check(input);
2155        let fired = codes(&report.diagnostics);
2156        assert!(fired.contains(&"P001"), "{fired:?}");
2157        assert!(fired.contains(&"V001"), "{fired:?}");
2158        assert!(fired.contains(&"L020"), "{fired:?}");
2159        // Sorted by span start offset (None-span schema diags first).
2160        let offsets: Vec<usize> = report
2161            .diagnostics
2162            .iter()
2163            .map(|d| d.span.map_or(0, |s| s.start_offset))
2164            .collect();
2165        let mut sorted = offsets.clone();
2166        sorted.sort_unstable();
2167        assert_eq!(offsets, sorted);
2168        assert_eq!(
2169            report.error_count + report.warning_count + report.info_count,
2170            report.diagnostics.len()
2171        );
2172        // The unclosed callout (P001 at EOF) carries a fix.
2173        assert_eq!(
2174            report.fixable_count,
2175            report
2176                .diagnostics
2177                .iter()
2178                .filter(|d| d.fix.is_some())
2179                .count()
2180        );
2181        assert!(report.fixable_count >= 1, "P001 EOF fix expected");
2182    }
2183
2184    #[test]
2185    fn check_clean_doc_is_empty() {
2186        let input =
2187            "---\ntitle: T\ntype: doc\n---\n\n::summary\nShort.\n::\n\n# Heading\n\nBody text.\n";
2188        let report = check(input);
2189        assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
2190        assert_eq!(report.error_count, 0);
2191        assert_eq!(report.warning_count, 0);
2192        assert_eq!(report.info_count, 0);
2193    }
2194
2195    #[test]
2196    fn check_with_disables_rules() {
2197        let input =
2198            "---\ntitle: T\ntype: plan\nstatus: active\ncreated: \"2026-06-06\"\n---\nBody.\n";
2199        assert_eq!(codes(&check(input).diagnostics), vec!["L010"]);
2200        let cfg = LintConfig {
2201            disabled_rules: ["L010".to_string()].into_iter().collect(),
2202            ..LintConfig::default()
2203        };
2204        let report = check_with(input, &cfg);
2205        assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
2206    }
2207
2208    #[test]
2209    fn check_with_overrides_severity() {
2210        let input = "---\ntitle: T\ntype: doc\n---\nBody.\n";
2211        let cfg = LintConfig {
2212            severity_overrides: [("L010".to_string(), Severity::Error)]
2213                .into_iter()
2214                .collect(),
2215            ..LintConfig::default()
2216        };
2217        let report = check_with(input, &cfg);
2218        let l010 = report
2219            .diagnostics
2220            .iter()
2221            .find(|d| d.code.as_deref() == Some("L010"))
2222            .expect("L010 fires");
2223        assert_eq!(l010.severity, Severity::Error);
2224        assert_eq!(report.error_count, 1);
2225    }
2226
2227    // --- V001/V002 cascade suppression (dogfood bug 1) ---
2228
2229    #[test]
2230    fn check_suppresses_v001_v002_cascade_on_p005() {
2231        // Dogfood repro: title and type are literally present; the closed
2232        // `type` enum rejects the value. The user gets ONE front-matter
2233        // diagnostic, not three.
2234        let input = "---\ntitle: T\ntype: checkpoint\n---\nBody.\n";
2235        let report = check(input);
2236        assert_eq!(codes(&report.diagnostics), vec!["P005"], "{report:?}");
2237        assert_eq!(report.error_count, 0);
2238        assert_eq!(report.warning_count, 1);
2239    }
2240
2241    #[test]
2242    fn check_suppresses_v001_v002_cascade_on_p002() {
2243        // Unclosed front matter: exactly one front-matter diagnostic (P002).
2244        let input = "---\ntitle: T\ntype: doc\n\nBody.\n";
2245        let report = check(input);
2246        assert_eq!(codes(&report.diagnostics), vec!["P002"], "{report:?}");
2247    }
2248
2249    #[test]
2250    fn check_keeps_v001_v002_when_front_matter_truly_absent() {
2251        // No front matter at all: V001/V002 are NOT a cascade — keep them.
2252        let report = check("# Just a heading\n\nBody.\n");
2253        let fired = codes(&report.diagnostics);
2254        assert!(fired.contains(&"V001"), "{fired:?}");
2255        assert!(fired.contains(&"V002"), "{fired:?}");
2256    }
2257
2258    // --- P005 vs P002 split (dogfood bug 3 / message accuracy) ---
2259
2260    #[test]
2261    fn p005_fires_for_unknown_enum_value_as_warning() {
2262        let report = check("---\ntitle: T\ntype: checkpoint\n---\nBody.\n");
2263        let p005 = report
2264            .diagnostics
2265            .iter()
2266            .find(|d| d.code.as_deref() == Some("P005"))
2267            .expect("P005 fires");
2268        assert_eq!(p005.severity, Severity::Warning);
2269        assert_eq!(
2270            p005.message,
2271            "Front matter field 'type' has unrecognized value 'checkpoint'"
2272        );
2273        assert!(
2274            report
2275                .diagnostics
2276                .iter()
2277                .all(|d| d.code.as_deref() != Some("P002")),
2278            "P002 must not fire for valid YAML with out-of-enum values"
2279        );
2280    }
2281
2282    #[test]
2283    fn p002_stays_error_for_genuinely_broken_yaml() {
2284        let report = check("---\ntitle: [unclosed\n---\nBody.\n");
2285        let p002 = report
2286            .diagnostics
2287            .iter()
2288            .find(|d| d.code.as_deref() == Some("P002"))
2289            .expect("P002 fires on broken YAML");
2290        assert_eq!(p002.severity, Severity::Error);
2291        assert!(
2292            report
2293                .diagnostics
2294                .iter()
2295                .all(|d| d.code.as_deref() != Some("P005"))
2296        );
2297    }
2298
2299    #[test]
2300    fn check_with_extra_frontmatter_values_suppresses_p005() {
2301        let input = "---\ntitle: T\ntype: checkpoint\n---\nBody.\n";
2302        let cfg = LintConfig {
2303            extra_frontmatter_values: [(
2304                "type".to_string(),
2305                ["checkpoint".to_string()].into_iter().collect(),
2306            )]
2307            .into_iter()
2308            .collect(),
2309            ..LintConfig::default()
2310        };
2311        let report = check_with(input, &cfg);
2312        // P005 silenced AND the V001/V002 cascade stays suppressed.
2313        assert!(report.diagnostics.is_empty(), "{:?}", report.diagnostics);
2314        // A different unrecognized value still fires.
2315        let other = check_with("---\ntitle: T\ntype: spec\n---\nBody.\n", &cfg);
2316        assert_eq!(codes(&other.diagnostics), vec!["P005"]);
2317    }
2318
2319    #[test]
2320    fn check_report_serializes_to_json() {
2321        let report = check("---\ntitle: T\ntype: doc\n---\nBody.\n");
2322        let json = serde_json::to_string(&report).expect("CheckReport serializes");
2323        assert!(json.contains("\"diagnostics\""));
2324        assert!(json.contains("\"fixable_count\""));
2325    }
2326
2327    #[test]
2328    fn all_rules_have_unique_ids() {
2329        let rules = all_rules();
2330        let ids: BTreeSet<&str> = rules.iter().map(|r| r.id()).collect();
2331        assert_eq!(ids.len(), rules.len());
2332    }
2333
2334    // ------------------------------------------------------------------
2335    // Fix engine — per-rule exact-output tests
2336    // ------------------------------------------------------------------
2337
2338    fn fixed_safe(input: &str) -> String {
2339        apply_fixes(input, FixSafety::Safe).source
2340    }
2341
2342    fn fixed_suggested(input: &str) -> String {
2343        apply_fixes(input, FixSafety::Suggested).source
2344    }
2345
2346    #[test]
2347    fn fix_p001_appends_closer_at_eof() {
2348        assert_eq!(
2349            fixed_safe("::callout[type=info]\nUnclosed"),
2350            "::callout[type=info]\nUnclosed\n::\n"
2351        );
2352        assert_eq!(
2353            fixed_safe("::callout[type=info]\nUnclosed\n"),
2354            "::callout[type=info]\nUnclosed\n::\n"
2355        );
2356    }
2357
2358    #[test]
2359    fn fix_p001_nested_closers_land_inside_out() {
2360        let out = apply_fixes("::columns\n:::column\nLeft", FixSafety::Safe);
2361        assert_eq!(out.source, "::columns\n:::column\nLeft\n:::\n::\n");
2362        assert_eq!(
2363            out.iterations, 2,
2364            "one closer per pass (same-offset edits conflict)"
2365        );
2366        assert!(
2367            check(&out.source)
2368                .diagnostics
2369                .iter()
2370                .all(|d| d.code.as_deref() != Some("P001"))
2371        );
2372    }
2373
2374    #[test]
2375    fn fix_p001_skips_mid_document_orphans() {
2376        // The orphan ':::inner' is force-closed by the OUTER closer — no fix
2377        // (the closer line belongs to ::a). L005 has no complaint either:
2378        // the closer legitimately matches ::a.
2379        let input = "::a\n:::inner\nx\n::\n";
2380        let report = check(input);
2381        let p001 = report
2382            .diagnostics
2383            .iter()
2384            .find(|d| d.code.as_deref() == Some("P001"))
2385            .expect("orphan fires P001");
2386        assert!(
2387            p001.fix.is_none(),
2388            "mid-document orphan must not get the EOF fix"
2389        );
2390    }
2391
2392    #[test]
2393    fn fix_p002_closes_front_matter_before_first_blank_line() {
2394        let input = "---\ntitle: T\ntype: doc\n\nBody text.\n";
2395        assert_eq!(
2396            fixed_safe(input),
2397            "---\ntitle: T\ntype: doc\n---\n\nBody text.\n"
2398        );
2399    }
2400
2401    #[test]
2402    fn fix_p002_no_fix_when_captured_lines_are_not_yaml() {
2403        let input = "---\ntitle: T\nnot yaml at all ::: {\n\nBody.\n";
2404        let report = check(input);
2405        let p002 = report
2406            .diagnostics
2407            .iter()
2408            .find(|d| d.code.as_deref() == Some("P002"))
2409            .expect("P002 fires");
2410        assert!(p002.fix.is_none());
2411        assert_eq!(fixed_safe(input), input, "nothing applicable to fix");
2412    }
2413
2414    #[test]
2415    fn fix_l001_replaces_section_with_heading() {
2416        assert_eq!(
2417            fixed_safe("::section[title=\"Background\"]\nText body.\n::\n"),
2418            "## Background\nText body.\n"
2419        );
2420    }
2421
2422    #[test]
2423    fn fix_l001_honours_level_attr() {
2424        assert_eq!(
2425            fixed_safe("::section[title=\"Deep\" level=3]\nText.\n::\n"),
2426            "### Deep\nText.\n"
2427        );
2428    }
2429
2430    #[test]
2431    fn fix_l001_strips_untitled_section() {
2432        assert_eq!(fixed_safe("::section\nText.\n::\n"), "Text.\n");
2433    }
2434
2435    #[test]
2436    fn fix_l002_strips_table_wrapper() {
2437        assert_eq!(
2438            fixed_safe("::table\n| a | b |\n|---|---|\n| 1 | 2 |\n::\n"),
2439            "| a | b |\n|---|---|\n| 1 | 2 |\n"
2440        );
2441    }
2442
2443    #[test]
2444    fn fix_l003_rewrites_curly_braces_to_square() {
2445        assert_eq!(
2446            fixed_safe("::callout{type=info}\nHi\n::\n"),
2447            "::callout[type=info]\nHi\n::\n"
2448        );
2449    }
2450
2451    #[test]
2452    fn fix_l003_no_fix_for_unmatched_brace() {
2453        let input = "::callout{type=info\nHi\n::\n";
2454        let out = apply_fixes(input, FixSafety::Safe);
2455        assert_eq!(out.source, input);
2456        assert!(out.applied.is_empty());
2457    }
2458
2459    #[test]
2460    fn fix_l004_moves_opener_tail_to_own_line() {
2461        assert_eq!(
2462            fixed_safe("::callout[type=info] Watch out!\n::\n"),
2463            "::callout[type=info]\nWatch out!\n::\n"
2464        );
2465    }
2466
2467    #[test]
2468    fn fix_l004_moves_closer_tail_to_own_line() {
2469        // Non-name tail (a name-shaped tail like `:: done` is a tolerant
2470        // opener since p3-copy-sweep and no longer L004's to fix).
2471        assert_eq!(
2472            fixed_safe("::callout[type=info]\nHi\n:: 42 done\n"),
2473            "::callout[type=info]\nHi\n::\n42 done\n"
2474        );
2475    }
2476
2477    #[test]
2478    fn fix_l004_attaches_displaced_attr_block() {
2479        // Dogfood bug 4 (plans/client/2026-02-23-rainbow-gardens-…:476):
2480        // `::callout [type=info]` is author-intended attributes — normalize
2481        // to the attribute form, never demote to literal body text.
2482        assert_eq!(
2483            fixed_safe("::callout [type=info]\nHi\n::\n"),
2484            "::callout[type=info]\nHi\n::\n"
2485        );
2486    }
2487
2488    #[test]
2489    fn fix_l004_attaches_attrs_and_breaks_trailing_content() {
2490        // Bracket block reattaches; the content after it still moves down.
2491        assert_eq!(
2492            fixed_safe("::callout [type=info] Watch out!\n::\n"),
2493            "::callout[type=info]\nWatch out!\n::\n"
2494        );
2495    }
2496
2497    #[test]
2498    fn fix_l004_plain_tail_still_breaks_line() {
2499        // Non-bracket tails keep the original break-to-next-line fix.
2500        assert_eq!(
2501            fixed_safe("::callout[type=info] note [1] here\n::\n"),
2502            "::callout[type=info]\nnote [1] here\n::\n"
2503        );
2504    }
2505
2506    #[test]
2507    fn fix_l001_l004_composition_yields_heading() {
2508        // Dogfood bug 5: `::section <Title>` on one line must compose to a
2509        // markdown heading through apply_fixes, not a bare paragraph.
2510        assert_eq!(
2511            fixed_safe("::section Executive Summary\nBody.\n::\n"),
2512            "## Executive Summary\nBody.\n"
2513        );
2514    }
2515
2516    #[test]
2517    fn fix_l001_composes_displaced_title_attrs() {
2518        // `::section [title=…]` — the displaced bracket block is read as
2519        // attributes, not as a literal title.
2520        assert_eq!(
2521            fixed_safe("::section [title=\"Background\"]\nBody.\n::\n"),
2522            "## Background\nBody.\n"
2523        );
2524    }
2525
2526    #[test]
2527    fn fix_p001_l005_fence_oscillation_converges() {
2528        // Dogfood bug 2 (minimal repro of
2529        // plans/develop/2026-02-20-visitor-chat-implementation-plan.surf):
2530        // the opener lives inside a markdown ``` fence, so the parser (not
2531        // fence-aware) wants a closer at EOF while L005 (fence-aware) calls
2532        // that closer an orphan. Pre-fix this looped to the 10-pass cap.
2533        let input = "```\n::cta[label=x]\n```\n::\n";
2534        let out = apply_fixes(input, FixSafety::Safe);
2535        assert!(
2536            out.iterations <= 2,
2537            "fixpoint within 2 passes, got {} ({:?})",
2538            out.iterations,
2539            out.applied
2540        );
2541        // L005 removed the stray closer; P001 stays detection-only.
2542        assert_eq!(out.source, "```\n::cta[label=x]\n```\n");
2543        // Stable: fixing the output is a no-op.
2544        let again = apply_fixes(&out.source, FixSafety::Safe);
2545        assert_eq!(again.source, out.source);
2546        assert_eq!(again.iterations, 0);
2547    }
2548
2549    #[test]
2550    fn fix_p001_no_eof_closer_for_opener_inside_fence() {
2551        // The same disagreement without a pre-existing closer: P001 fires
2552        // for the fenced opener but must NOT carry the EOF-closer fix.
2553        let input = "```\n::cta[label=x]\n```\n";
2554        let report = check(input);
2555        let p001 = report
2556            .diagnostics
2557            .iter()
2558            .find(|d| d.code.as_deref() == Some("P001"))
2559            .expect("P001 fires (parser is not fence-aware)");
2560        assert!(p001.fix.is_none(), "no closer fix for a fenced opener");
2561        let out = apply_fixes(input, FixSafety::Safe);
2562        assert_eq!(out.source, input);
2563        assert_eq!(out.iterations, 0);
2564    }
2565
2566    #[test]
2567    fn fix_l005_recomputes_overdeep_opener_then_closer() {
2568        // Pass 1 fixes the opener; pass 2 the now-orphaned ':::' closer (plus
2569        // a P001 closer for the then-unclosed block); pass 3 removes the
2570        // redundant closer. The fixpoint is correct and stable.
2571        let out = apply_fixes(":::column\nLeft\n:::\n", FixSafety::Safe);
2572        assert_eq!(out.source, "::column\nLeft\n::\n");
2573        assert_eq!(out.iterations, 3);
2574    }
2575
2576    #[test]
2577    fn fix_l005_removes_orphan_closer_with_nothing_open() {
2578        assert_eq!(fixed_safe("::callout\nHi\n::\n::\n"), "::callout\nHi\n::\n");
2579    }
2580
2581    #[test]
2582    fn fix_l030_derives_updated_from_created() {
2583        let input =
2584            "---\ntitle: T\ntype: guide\nconfidence: high\ncreated: \"2026-06-01\"\n---\nBody.\n";
2585        let fixed = fixed_safe(input);
2586        assert_eq!(
2587            fixed,
2588            "---\ntitle: T\ntype: guide\nconfidence: high\ncreated: \"2026-06-01\"\nupdated: \"2026-06-01\"\n---\nBody.\n"
2589        );
2590    }
2591
2592    #[test]
2593    fn fix_l030_detection_only_when_not_derivable() {
2594        // Plan missing status+created: neither is derivable without a clock.
2595        let input = "---\ntitle: T\ntype: plan\n---\nBody.\n";
2596        let out = apply_fixes(input, FixSafety::Safe);
2597        assert_eq!(out.source, input);
2598        assert!(out.applied.is_empty());
2599        let report = check(input);
2600        assert!(
2601            report
2602                .diagnostics
2603                .iter()
2604                .filter(|d| d.code.as_deref() == Some("L030"))
2605                .all(|d| d.fix.is_none())
2606        );
2607    }
2608
2609    #[test]
2610    fn fix_l031_normalizes_enum_case() {
2611        assert_eq!(
2612            fixed_safe("---\ntitle: T\ntype: doc\nstatus: Active\n---\nBody.\n"),
2613            "---\ntitle: T\ntype: doc\nstatus: active\n---\nBody.\n"
2614        );
2615    }
2616
2617    #[test]
2618    fn fix_l031_strips_quotes_when_normalizing() {
2619        assert_eq!(
2620            fixed_safe(
2621                "---\ntitle: T\ntype: \"Plan\"\nstatus: active\ncreated: \"2026-06-06\"\n---\n::summary\nS.\n::\n"
2622            ),
2623            "---\ntitle: T\ntype: plan\nstatus: active\ncreated: \"2026-06-06\"\n---\n::summary\nS.\n::\n"
2624        );
2625    }
2626
2627    #[test]
2628    fn fix_l010_suggested_inserts_summary_stub_after_heading() {
2629        let input = "---\ntitle: T\ntype: doc\n---\n\n# Doc\n\nBody.\n";
2630        assert_eq!(
2631            fixed_safe(input),
2632            input,
2633            "L010 is suggested-tier — Safe must not touch it"
2634        );
2635        let fixed = fixed_suggested(input);
2636        assert_eq!(
2637            fixed,
2638            "---\ntitle: T\ntype: doc\n---\n\n# Doc\n\n::summary\nTODO: add a one-paragraph summary.\n::\n\nBody.\n"
2639        );
2640        assert!(
2641            check(&fixed)
2642                .diagnostics
2643                .iter()
2644                .all(|d| d.code.as_deref() != Some("L010"))
2645        );
2646    }
2647
2648    #[test]
2649    fn fix_l020_suggested_renames_to_unique_match() {
2650        let input = "::callouts[type=info]\nHi\n::\n";
2651        assert_eq!(fixed_safe(input), input, "L020 is suggested-tier");
2652        assert_eq!(fixed_suggested(input), "::callout[type=info]\nHi\n::\n");
2653    }
2654
2655    #[test]
2656    fn fix_l020_no_fix_without_unique_suggestion() {
2657        let report = check("::zzqqxx\nHi\n::\n");
2658        let l020 = report
2659            .diagnostics
2660            .iter()
2661            .find(|d| d.code.as_deref() == Some("L020"))
2662            .expect("L020 fires");
2663        assert!(l020.fix.is_none());
2664    }
2665
2666    // ------------------------------------------------------------------
2667    // Fix engine — UTF-8 multi-byte splicing
2668    // ------------------------------------------------------------------
2669
2670    #[test]
2671    fn fix_l003_splices_correctly_after_multibyte_lines() {
2672        let input = "# Café ☕ 日本語\n\n::callout{type=info}\n🎉 Bienvenue à 東京!\n::\n";
2673        assert_eq!(
2674            fixed_safe(input),
2675            "# Café ☕ 日本語\n\n::callout[type=info]\n🎉 Bienvenue à 東京!\n::\n"
2676        );
2677    }
2678
2679    #[test]
2680    fn fix_l031_with_multibyte_title_above() {
2681        let input = "---\ntitle: \"日本語のタイトル 🚀\"\ntype: doc\nstatus: Active\n---\nBody.\n";
2682        assert_eq!(
2683            fixed_safe(input),
2684            "---\ntitle: \"日本語のタイトル 🚀\"\ntype: doc\nstatus: Active\n---\nBody.\n"
2685                .replace("status: Active", "status: active")
2686        );
2687    }
2688
2689    #[test]
2690    fn fix_l001_with_emoji_title() {
2691        assert_eq!(
2692            fixed_safe("::section[title=\"🚀 Launch — 発射\"]\nText.\n::\n"),
2693            "## 🚀 Launch — 発射\nText.\n"
2694        );
2695    }
2696
2697    #[test]
2698    fn fix_l004_with_multibyte_tail() {
2699        assert_eq!(
2700            fixed_safe("::callout[type=info] ☕ café first\n::\n"),
2701            "::callout[type=info]\n☕ café first\n::\n"
2702        );
2703    }
2704
2705    // ------------------------------------------------------------------
2706    // Fix engine — mechanics
2707    // ------------------------------------------------------------------
2708
2709    #[test]
2710    fn fixable_count_counts_attached_fixes() {
2711        let report = check("::callout{type=info}\nHi\n::\n");
2712        assert_eq!(report.fixable_count, 1);
2713        let clean = check("---\ntitle: T\ntype: doc\n---\n\n::summary\nS.\n::\n");
2714        assert_eq!(clean.fixable_count, 0);
2715    }
2716
2717    #[test]
2718    fn apply_fixes_clean_input_is_untouched() {
2719        let input = "---\ntitle: T\ntype: doc\n---\n\n::summary\nShort.\n::\n\n# H\n\nBody.\n";
2720        let out = apply_fixes(input, FixSafety::Suggested);
2721        assert_eq!(out.source, input);
2722        assert_eq!(out.iterations, 0);
2723        assert!(out.applied.is_empty());
2724        assert!(out.skipped.is_empty());
2725    }
2726
2727    #[test]
2728    fn apply_fixes_normalises_crlf_like_parse() {
2729        let out = apply_fixes("::callout{type=info}\r\nHi\r\n::\r\n", FixSafety::Safe);
2730        assert_eq!(out.source, "::callout[type=info]\nHi\n::\n");
2731    }
2732
2733    #[test]
2734    fn apply_fixes_once_applies_a_single_pass() {
2735        let input = ":::column\nLeft\n:::\n";
2736        let once = apply_fixes_once(input, FixSafety::Safe);
2737        assert_eq!(once.source, "::column\nLeft\n:::\n");
2738        assert_eq!(once.iterations, 1);
2739        // Iterating apply_fixes_once by hand reaches the same fixpoint.
2740        let mut cur = once.source;
2741        loop {
2742            let next = apply_fixes_once(&cur, FixSafety::Safe);
2743            if next.iterations == 0 {
2744                break;
2745            }
2746            cur = next.source;
2747        }
2748        assert_eq!(cur, apply_fixes(input, FixSafety::Safe).source);
2749        assert_eq!(cur, "::column\nLeft\n::\n");
2750    }
2751
2752    #[test]
2753    fn apply_fixes_reports_applied_codes() {
2754        let out = apply_fixes(
2755            "::callout{type=info}\nHi\n::\n\n::section[title=\"S\"]\nT.\n::\n",
2756            FixSafety::Safe,
2757        );
2758        let codes: BTreeSet<&str> = out.applied.iter().map(|a| a.code.as_str()).collect();
2759        assert_eq!(codes, ["L001", "L003"].into_iter().collect());
2760    }
2761
2762    #[test]
2763    fn edits_conflict_rules() {
2764        let e = |s: usize, t: usize| TextEdit {
2765            span: Span {
2766                start_line: 1,
2767                end_line: 1,
2768                start_offset: s,
2769                end_offset: t,
2770            },
2771            replacement: String::new(),
2772        };
2773        assert!(edits_conflict(&e(0, 5), &e(3, 8)), "overlapping ranges");
2774        assert!(edits_conflict(&e(4, 4), &e(4, 4)), "same-point insertions");
2775        assert!(
2776            edits_conflict(&e(2, 6), &e(2, 2)),
2777            "insertion at replace start"
2778        );
2779        assert!(
2780            !edits_conflict(&e(0, 4), &e(4, 8)),
2781            "adjacent ranges are fine"
2782        );
2783        assert!(
2784            !edits_conflict(&e(4, 4), &e(0, 4)),
2785            "insertion at range end is fine"
2786        );
2787    }
2788
2789    // --- JSON envelope (shared CLI/wasm serialization) ---
2790
2791    /// Fixture with one fixable L001 warning (`::section` block).
2792    const JSON_FIXTURE: &str = "---\ntitle: \"T\"\ntype: report\nstatus: active\n---\n\n# Doc\n\n::section[title=\"Background\"]\nBody text.\n::\n";
2793
2794    #[test]
2795    fn report_to_json_per_file_shape() {
2796        let report = check(JSON_FIXTURE);
2797        assert!(report.fixable_count > 0, "fixture must have a fixable diag");
2798        let file = report_to_json(Some("a.surf"), &report);
2799        assert_eq!(file["path"], "a.surf");
2800        assert_eq!(file["error_count"], report.error_count);
2801        assert_eq!(file["warning_count"], report.warning_count);
2802        assert_eq!(file["info_count"], report.info_count);
2803        assert_eq!(file["fixable_count"], report.fixable_count);
2804        let diags = file["diagnostics"].as_array().expect("diagnostics array");
2805        assert_eq!(diags.len(), report.diagnostics.len());
2806    }
2807
2808    #[test]
2809    fn report_to_json_omits_path_when_none() {
2810        let report = check("# Plain doc\n");
2811        let file = report_to_json(None, &report);
2812        assert!(
2813            file.get("path").is_none(),
2814            "path key must be omitted for in-memory input"
2815        );
2816        assert!(file.get("diagnostics").is_some());
2817        assert!(file.get("error_count").is_some());
2818    }
2819
2820    #[test]
2821    fn report_to_json_carries_fix_payload_and_span() {
2822        let report = check(JSON_FIXTURE);
2823        let file = report_to_json(Some("a.surf"), &report);
2824        let diags = file["diagnostics"].as_array().unwrap();
2825        let l001 = diags
2826            .iter()
2827            .find(|d| d["code"] == "L001")
2828            .expect("L001 diagnostic present");
2829        assert_eq!(l001["severity"], "warning");
2830        let span = &l001["span"];
2831        assert!(span["start_line"].as_u64().unwrap() > 0);
2832        assert!(span.get("start_offset").is_some());
2833        assert!(span.get("end_offset").is_some());
2834        let fix = &l001["fix"];
2835        assert_eq!(fix["safety"], "safe");
2836        assert!(fix.get("description").is_some());
2837        let edits = fix["edits"].as_array().expect("fix edits array");
2838        assert!(!edits.is_empty());
2839        assert!(edits[0].get("span").is_some());
2840        assert!(edits[0].get("replacement").is_some());
2841    }
2842
2843    #[test]
2844    fn reports_to_json_envelope_shape_and_summary() {
2845        let with_warning = check(JSON_FIXTURE);
2846        let clean =
2847            check("---\ntitle: \"T\"\ntype: report\nstatus: active\n---\n\n# Doc\n\nClean body.\n");
2848        let envelope =
2849            reports_to_json(&[(Some("a.surf"), &with_warning), (Some("b.surf"), &clean)]);
2850        assert_eq!(envelope["schema_version"], JSON_SCHEMA_VERSION);
2851        let files = envelope["files"].as_array().expect("files array");
2852        assert_eq!(files.len(), 2);
2853        assert_eq!(files[0]["path"], "a.surf");
2854        assert_eq!(files[1]["path"], "b.surf");
2855        let summary = &envelope["summary"];
2856        assert_eq!(summary["files"], 2);
2857        assert_eq!(
2858            summary["error_count"],
2859            with_warning.error_count + clean.error_count
2860        );
2861        assert_eq!(
2862            summary["warning_count"],
2863            with_warning.warning_count + clean.warning_count
2864        );
2865        assert_eq!(
2866            summary["info_count"],
2867            with_warning.info_count + clean.info_count
2868        );
2869        assert_eq!(
2870            summary["fixable_count"],
2871            with_warning.fixable_count + clean.fixable_count
2872        );
2873    }
2874
2875    #[test]
2876    fn reports_to_json_empty_input() {
2877        let envelope = reports_to_json(&[]);
2878        assert_eq!(envelope["schema_version"], JSON_SCHEMA_VERSION);
2879        assert_eq!(envelope["files"].as_array().unwrap().len(), 0);
2880        assert_eq!(envelope["summary"]["files"], 0);
2881        assert_eq!(envelope["summary"]["error_count"], 0);
2882    }
2883}