Skip to main content

memstead_base/
section_format.rs

1//! Section-body reduction — the markdown half of the section-format
2//! vocabulary (agent-toolbox plan 08).
3//!
4//! Reduces a section body to its **top-level block sequence** with a
5//! real CommonMark parser (`pulldown-cmark`, no default features,
6//! GFM tables via the runtime `Options` flag). A line-scanner
7//! disagrees with CommonMark on exactly the constructs agents produce
8//! — lazy continuation lines, mixed bullet markers (`-` then `*` is
9//! *two* lists), indented code blocks containing `- `, GFM tables
10//! degrading to paragraphs on a malformed delimiter row — and a
11//! validator that disagrees with the renderer every agent uses sends
12//! repair loops that cannot converge. The parser is the referee.
13//!
14//! The reduction carries, per block kind, exactly the material the
15//! declaration surface checks: list items (text a renderer shows —
16//! lazy continuations joined by a single space), paragraph source
17//! lines, table header + row cells. The expression matching itself
18//! lives in `memstead_schema::content_expr` — this module only
19//! observes.
20
21use memstead_schema::content_expr::ObservedBlock;
22use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
23
24/// One top-level block of a section body: what it is, where it
25/// starts, and the per-kind material the format checks consume.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ReducedBlock {
28    pub observed: ObservedBlock,
29    /// 1-based source line of the block's first byte.
30    pub line: usize,
31    pub detail: BlockDetail,
32}
33
34/// Per-kind check material.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum BlockDetail {
37    None,
38    /// Each item: `(line, text)` — the item's inline text with lazy
39    /// continuation / soft breaks joined by a single space (the text
40    /// a renderer shows; continuation never changes an `item_pattern`
41    /// match). Nested blocks inside an item are not part of its text.
42    List {
43        items: Vec<(usize, String)>,
44    },
45    /// Each source line of the paragraph: `(line, text)`.
46    Paragraph {
47        lines: Vec<(usize, String)>,
48    },
49    /// Header cell texts plus each row's entry. `cells` come from the
50    /// parser (already padded/truncated to header width — GFM
51    /// normalizes silently); `raw_cell_count` is the REAL
52    /// pipe-delimited cell count of the source row line, so the
53    /// column contract can refuse what GFM would paper over.
54    Table {
55        header: Vec<String>,
56        rows: Vec<TableRow>,
57    },
58}
59
60/// One body row of a reduced table.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct TableRow {
63    pub line: usize,
64    /// Cell texts as the parser emits them (padded/truncated to the
65    /// header width).
66    pub cells: Vec<String>,
67    /// The source row's real pipe-delimited cell count.
68    pub raw_cell_count: usize,
69}
70
71/// A setext heading of depth 1–2 found anywhere in the body. The
72/// byte-class line guard (`^# ` / `^## `) cannot see these — only the
73/// real parser can — so the reduction reports them for the
74/// format-checked-section refusal.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct SetextReservedHeading {
77    pub line: usize,
78    pub depth: u8,
79}
80
81/// The reduced view of one section body.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ReducedSection {
84    pub blocks: Vec<ReducedBlock>,
85    /// Setext h1/h2 occurrences (ATX `#`/`##` are already refused by
86    /// the byte-class guard before any reduction runs).
87    pub setext_reserved: Vec<SetextReservedHeading>,
88}
89
90impl ReducedSection {
91    /// The observed block sequence, for expression matching.
92    pub fn observed(&self) -> Vec<ObservedBlock> {
93        self.blocks.iter().map(|b| b.observed.clone()).collect()
94    }
95}
96
97/// 1-based line number of a byte offset.
98fn line_of(source: &str, offset: usize) -> usize {
99    source.as_bytes()[..offset.min(source.len())]
100        .iter()
101        .filter(|&&b| b == b'\n')
102        .count()
103        + 1
104}
105
106fn heading_depth(level: HeadingLevel) -> u8 {
107    match level {
108        HeadingLevel::H1 => 1,
109        HeadingLevel::H2 => 2,
110        HeadingLevel::H3 => 3,
111        HeadingLevel::H4 => 4,
112        HeadingLevel::H5 => 5,
113        HeadingLevel::H6 => 6,
114    }
115}
116
117/// Reduce a section body to its top-level block sequence.
118pub fn reduce_section(source: &str) -> ReducedSection {
119    // The engine's one CommonMark dialect — never a second inline
120    // construction. A flag added here and not there (or the reverse)
121    // silently re-opens the two-referee problem.
122    let options = crate::markdown::parser_options();
123
124    let mut blocks: Vec<ReducedBlock> = Vec::new();
125    let mut setext_reserved: Vec<SetextReservedHeading> = Vec::new();
126    // Nesting depth of container tags; a Start at depth 0 opens a
127    // top-level block.
128    let mut depth: usize = 0;
129    // Collector state for the currently-open top-level block.
130    let mut current: Option<ReducedBlock> = None;
131    // While inside a top-level list item: the item's start line, the
132    // merged source range of its own inline content (markers
133    // preserved — item_patterns are written against source shapes),
134    // how many block-level containers are open inside it, and whether
135    // its own (first) paragraph was seen.
136    struct ItemState {
137        line: usize,
138        span: Option<(usize, usize)>,
139        nested: usize,
140        own_paragraph_seen: bool,
141    }
142    let mut item: Option<ItemState> = None;
143    // While inside a table: header-cell mode, current row state.
144    let mut in_table_head = false;
145    let mut current_cell: Option<String> = None;
146    let mut current_row: Option<(usize, Vec<String>)> = None;
147    // Paragraph capture is source-based: remember the range start.
148    let mut para_range_start: usize = 0;
149
150    /// Is this tag a BLOCK container? Inline containers (emphasis,
151    /// links, …) are transparent for item-text collection.
152    fn is_block_tag(tag: &Tag) -> bool {
153        matches!(
154            tag,
155            Tag::Paragraph
156                | Tag::List(_)
157                | Tag::Item
158                | Tag::Table(_)
159                | Tag::CodeBlock(_)
160                | Tag::BlockQuote(_)
161                | Tag::Heading { .. }
162                | Tag::HtmlBlock
163                | Tag::FootnoteDefinition(_)
164        )
165    }
166
167    for (event, range) in Parser::new_ext(source, options).into_offset_iter() {
168        match event {
169            Event::Start(tag) => {
170                let line = line_of(source, range.start);
171                if depth == 0 {
172                    let observed = match &tag {
173                        Tag::Paragraph => {
174                            para_range_start = range.start;
175                            Some(ObservedBlock::Paragraph)
176                        }
177                        Tag::List(ordering) => Some(ObservedBlock::List {
178                            ordered: ordering.is_some(),
179                        }),
180                        Tag::Table(_) => Some(ObservedBlock::Table),
181                        Tag::CodeBlock(kind) => Some(ObservedBlock::Code {
182                            lang: match kind {
183                                CodeBlockKind::Fenced(info) => {
184                                    info.split_whitespace().next().unwrap_or("").to_string()
185                                }
186                                CodeBlockKind::Indented => String::new(),
187                            },
188                        }),
189                        Tag::BlockQuote(_) => Some(ObservedBlock::Blockquote),
190                        Tag::Heading { level, .. } => Some(ObservedBlock::Heading {
191                            depth: heading_depth(*level),
192                        }),
193                        Tag::HtmlBlock => Some(ObservedBlock::Html),
194                        _ => None,
195                    };
196                    if let Some(observed) = observed {
197                        let detail = match &observed {
198                            ObservedBlock::List { .. } => BlockDetail::List { items: Vec::new() },
199                            ObservedBlock::Paragraph => {
200                                BlockDetail::Paragraph { lines: Vec::new() }
201                            }
202                            ObservedBlock::Table => BlockDetail::Table {
203                                header: Vec::new(),
204                                rows: Vec::new(),
205                            },
206                            _ => BlockDetail::None,
207                        };
208                        current = Some(ReducedBlock {
209                            observed,
210                            line,
211                            detail,
212                        });
213                    }
214                }
215                // Setext h1/h2 detection anywhere in the body: a
216                // setext heading's source slice does not start with
217                // '#'.
218                if let Tag::Heading { level, .. } = &tag {
219                    let d = heading_depth(*level);
220                    if d <= 2 {
221                        let slice = &source[range.start..range.end.min(source.len())];
222                        if !slice.trim_start().starts_with('#') {
223                            setext_reserved.push(SetextReservedHeading { line, depth: d });
224                        }
225                    }
226                }
227                match &tag {
228                    Tag::Item if depth == 1 => {
229                        item = Some(ItemState {
230                            line,
231                            span: None,
232                            nested: 0,
233                            own_paragraph_seen: false,
234                        });
235                    }
236                    Tag::TableHead if depth == 1 => in_table_head = true,
237                    Tag::TableRow if depth == 1 => {
238                        current_row = Some((line, Vec::new()));
239                    }
240                    Tag::TableCell => current_cell = Some(String::new()),
241                    _ => {
242                        if let Some(st) = item.as_mut()
243                            && !is_block_tag(&tag)
244                            && st.nested == 0
245                            && current_cell.is_none()
246                        {
247                            // Inline container (emphasis, link, …):
248                            // its range covers marker + content, so
249                            // merging keeps the source markers in the
250                            // item text.
251                            merge_span(&mut st.span, range.start, range.end);
252                        }
253                        if let Some(st) = item.as_mut()
254                            && is_block_tag(&tag)
255                        {
256                            // The item's own first paragraph is
257                            // transparent; every other block-level
258                            // start inside the item is nesting.
259                            if matches!(tag, Tag::Paragraph)
260                                && st.nested == 0
261                                && !st.own_paragraph_seen
262                            {
263                                st.own_paragraph_seen = true;
264                            } else {
265                                st.nested += 1;
266                            }
267                        }
268                    }
269                }
270                depth += 1;
271            }
272            Event::End(tag_end) => {
273                depth -= 1;
274                match tag_end {
275                    TagEnd::Item if depth == 1 => {
276                        if let (Some(st), Some(block)) = (item.take(), current.as_mut())
277                            && let BlockDetail::List { items } = &mut block.detail
278                        {
279                            let text = st
280                                .span
281                                .map(|(a, b)| {
282                                    source[a..b.min(source.len())]
283                                        .lines()
284                                        .map(str::trim)
285                                        .filter(|l| !l.is_empty())
286                                        .collect::<Vec<_>>()
287                                        .join(" ")
288                                })
289                                .unwrap_or_default();
290                            items.push((st.line, text));
291                        }
292                    }
293                    TagEnd::TableHead if depth == 1 => in_table_head = false,
294                    TagEnd::TableRow if depth == 1 => {
295                        if let (Some((line, cells)), Some(block)) =
296                            (current_row.take(), current.as_mut())
297                            && let BlockDetail::Table { rows, .. } = &mut block.detail
298                        {
299                            let raw = raw_cell_count(source, line);
300                            rows.push(TableRow {
301                                line,
302                                cells,
303                                raw_cell_count: raw,
304                            });
305                        }
306                    }
307                    TagEnd::TableCell => {
308                        if let Some(cell) = current_cell.take() {
309                            let cell = cell.trim().to_string();
310                            if in_table_head {
311                                if let Some(block) = current.as_mut()
312                                    && let BlockDetail::Table { header, .. } = &mut block.detail
313                                {
314                                    header.push(cell);
315                                }
316                            } else if let Some((_, cells)) = current_row.as_mut() {
317                                cells.push(cell);
318                            }
319                        }
320                    }
321                    TagEnd::Paragraph => {
322                        if depth == 0
323                            && let Some(block) = current.as_mut()
324                            && let BlockDetail::Paragraph { lines } = &mut block.detail
325                        {
326                            let end = range.end.min(source.len());
327                            let slice = &source[para_range_start..end];
328                            let first_line = line_of(source, para_range_start);
329                            for (line_no, l) in (first_line..).zip(slice.lines()) {
330                                let t = l.trim();
331                                if !t.is_empty() {
332                                    lines.push((line_no, t.to_string()));
333                                }
334                            }
335                        }
336                        if let Some(st) = item.as_mut()
337                            && st.nested > 0
338                        {
339                            st.nested -= 1;
340                        }
341                    }
342                    TagEnd::List(_)
343                    | TagEnd::Item
344                    | TagEnd::Table
345                    | TagEnd::CodeBlock
346                    | TagEnd::BlockQuote(_)
347                    | TagEnd::Heading(_)
348                    | TagEnd::HtmlBlock
349                    | TagEnd::FootnoteDefinition => {
350                        if let Some(st) = item.as_mut()
351                            && st.nested > 0
352                        {
353                            st.nested -= 1;
354                        }
355                    }
356                    _ => {}
357                }
358                if depth == 0
359                    && let Some(block) = current.take()
360                {
361                    blocks.push(block);
362                }
363            }
364            Event::Rule if depth == 0 => {
365                blocks.push(ReducedBlock {
366                    observed: ObservedBlock::ThematicBreak,
367                    line: line_of(source, range.start),
368                    detail: BlockDetail::None,
369                });
370            }
371            Event::Text(t) | Event::Code(t) | Event::InlineHtml(t) => {
372                if let Some(cell) = current_cell.as_mut() {
373                    cell.push_str(&t);
374                } else if let Some(st) = item.as_mut()
375                    && st.nested == 0
376                {
377                    merge_span(&mut st.span, range.start, range.end);
378                }
379            }
380            Event::SoftBreak | Event::HardBreak => {
381                if let Some(cell) = current_cell.as_mut() {
382                    cell.push(' ');
383                } else if let Some(st) = item.as_mut()
384                    && st.nested == 0
385                {
386                    merge_span(&mut st.span, range.start, range.end);
387                }
388            }
389            _ => {}
390        }
391    }
392
393    ReducedSection {
394        blocks,
395        setext_reserved,
396    }
397}
398
399/// Merge an event's byte range into the item's inline span. The span
400/// is later sliced from the SOURCE, so inline markers (`**`, `` ` ``,
401/// `[…](…)`) survive exactly as the author wrote them — item patterns
402/// are written against source shapes. Inline-container markers sit
403/// between their children's ranges, so min/max merging covers them.
404fn merge_span(span: &mut Option<(usize, usize)>, start: usize, end: usize) {
405    *span = Some(match span {
406        None => (start, end),
407        Some((a, b)) => ((*a).min(start), (*b).max(end)),
408    });
409}
410
411/// The real pipe-delimited cell count of a table row's source line —
412/// GFM pads/truncates silently at parse time, so the parser's cell
413/// events cannot answer "did the author write the right number of
414/// cells". Counts unescaped `|` delimiters on the 1-based `line`.
415fn raw_cell_count(source: &str, line: usize) -> usize {
416    let Some(row) = source.lines().nth(line.saturating_sub(1)) else {
417        return 0;
418    };
419    let trimmed = row.trim();
420    let inner = trimmed
421        .strip_prefix('|')
422        .unwrap_or(trimmed)
423        .strip_suffix('|')
424        .unwrap_or(trimmed);
425    let mut count = 1;
426    let mut escaped = false;
427    for c in inner.chars() {
428        match c {
429            '\\' if !escaped => escaped = true,
430            '|' if !escaped => {
431                count += 1;
432                escaped = false;
433            }
434            _ => escaped = false,
435        }
436    }
437    count
438}
439
440// ---------------------------------------------------------------------------
441// Format evaluation
442// ---------------------------------------------------------------------------
443
444/// One violation of a section's declared format. The serde shape is
445/// the wire `details` payload of the corresponding refusal code —
446/// `SECTION_CONTENT_MISMATCH` / `SECTION_ITEM_PATTERN_MISMATCH` /
447/// `INVALID_TABLE_COLUMNS` — plus the reserved-setext case, which
448/// rides the pre-existing `SECTION_CONTENT_INVALID` family.
449#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
450#[serde(tag = "kind", rename_all = "snake_case")]
451pub enum SectionFormatViolation {
452    ContentMismatch {
453        section: String,
454        /// The declared expression, verbatim.
455        expected: String,
456        /// The observed top-level block sequence (display forms).
457        found: Vec<String>,
458        /// 1-based source line of the offending block (the line after
459        /// the last block when the body ended too early).
460        failed_at: usize,
461        /// Display forms of the terminals legal at that position.
462        expected_next: Vec<String>,
463        #[serde(skip_serializing_if = "Option::is_none")]
464        example: Option<String>,
465    },
466    ItemPatternMismatch {
467        section: String,
468        /// 0-based index of the offending unit (list item / paragraph
469        /// line) within its kind.
470        item_index: usize,
471        /// 1-based source line of the unit.
472        line: usize,
473        /// The unit's text as matched (items: lazy continuation
474        /// joined; paragraphs: the source line).
475        text: String,
476        /// The declared pattern, verbatim (anchoring is implicit).
477        pattern: String,
478        /// The pattern's named capture groups — the parts a
479        /// conforming unit would carry.
480        groups: Vec<String>,
481        #[serde(skip_serializing_if = "Option::is_none")]
482        example: Option<String>,
483    },
484    TableColumns {
485        section: String,
486        /// What went wrong: `header` (names/order mismatch),
487        /// `cell_count` (row width vs declared columns), or
488        /// `cell_pattern` (a cell failing its column's regex).
489        reason: String,
490        expected_columns: Vec<String>,
491        #[serde(skip_serializing_if = "Vec::is_empty")]
492        found_columns: Vec<String>,
493        #[serde(skip_serializing_if = "Option::is_none")]
494        row_line: Option<usize>,
495        #[serde(skip_serializing_if = "Option::is_none")]
496        expected_cells: Option<usize>,
497        #[serde(skip_serializing_if = "Option::is_none")]
498        found_cells: Option<usize>,
499        #[serde(skip_serializing_if = "Option::is_none")]
500        column: Option<String>,
501        #[serde(skip_serializing_if = "Option::is_none")]
502        pattern: Option<String>,
503        #[serde(skip_serializing_if = "Option::is_none")]
504        cell: Option<String>,
505        #[serde(skip_serializing_if = "Option::is_none")]
506        example: Option<String>,
507    },
508    /// A setext h1/h2 inside a format-checked section — the reserved
509    /// levels the byte-class line guard cannot see.
510    SetextReserved {
511        section: String,
512        line: usize,
513        depth: u8,
514    },
515}
516
517impl SectionFormatViolation {
518    /// The wire code of this violation's refusal.
519    pub fn code(&self) -> &'static str {
520        match self {
521            Self::ContentMismatch { .. } => "SECTION_CONTENT_MISMATCH",
522            Self::ItemPatternMismatch { .. } => "SECTION_ITEM_PATTERN_MISMATCH",
523            Self::TableColumns { .. } => "INVALID_TABLE_COLUMNS",
524            Self::SetextReserved { .. } => "SECTION_CONTENT_INVALID",
525        }
526    }
527
528    /// The declared conforming example, when the violation carries one.
529    pub fn example(&self) -> Option<&str> {
530        match self {
531            Self::ContentMismatch { example, .. }
532            | Self::ItemPatternMismatch { example, .. }
533            | Self::TableColumns { example, .. } => example.as_deref(),
534            Self::SetextReserved { .. } => None,
535        }
536    }
537
538    /// One-line human rendering for refusal message text.
539    pub fn describe(&self) -> String {
540        match self {
541            Self::ContentMismatch {
542                section,
543                expected,
544                found,
545                failed_at,
546                expected_next,
547                ..
548            } => format!(
549                "section '{section}' does not match its declared shape `{expected}` — found [{}], expected {} at line {failed_at}",
550                found.join(", "),
551                if expected_next.is_empty() {
552                    "end of section".to_string()
553                } else {
554                    expected_next.join(" | ")
555                },
556            ),
557            Self::ItemPatternMismatch {
558                section,
559                line,
560                text,
561                pattern,
562                ..
563            } => format!(
564                "section '{section}' line {line} does not match the declared item pattern `{pattern}`: {text}"
565            ),
566            Self::TableColumns {
567                section, reason, ..
568            } => format!("section '{section}' violates its table contract ({reason})"),
569            Self::SetextReserved {
570                section,
571                line,
572                depth,
573            } => format!(
574                "section '{section}' line {line} is a setext h{depth} heading — h1/h2 are the entity's own levels"
575            ),
576        }
577    }
578}
579
580/// Evaluate one section body against its declared format. Returns
581/// every violation in document order (the write path refuses with the
582/// first; health reports all). A section declaring no `content` — or
583/// one whose expression failed to compile, which the loader refuses
584/// anyway — produces no violations (free-form).
585pub fn check_section_format(
586    def: &memstead_schema::SectionDef,
587    body: &str,
588) -> Vec<SectionFormatViolation> {
589    let Some(expr) = def.compiled_content.as_ref() else {
590        return Vec::new();
591    };
592    let section = def.key.as_str();
593    let reduced = reduce_section(body);
594    let mut out: Vec<SectionFormatViolation> = Vec::new();
595
596    for setext in &reduced.setext_reserved {
597        out.push(SectionFormatViolation::SetextReserved {
598            section: section.to_string(),
599            line: setext.line,
600            depth: setext.depth,
601        });
602    }
603
604    let observed = reduced.observed();
605    if let Err(failure) = expr.match_blocks(&observed) {
606        let failed_at_line = reduced
607            .blocks
608            .get(failure.failed_at)
609            .map(|b| b.line)
610            .unwrap_or_else(|| reduced.blocks.last().map(|b| b.line + 1).unwrap_or(1));
611        out.push(SectionFormatViolation::ContentMismatch {
612            section: section.to_string(),
613            expected: expr.source().to_string(),
614            found: observed.iter().map(|b| b.display()).collect(),
615            failed_at: failed_at_line,
616            expected_next: failure.expected_next,
617            example: def.example.clone(),
618        });
619    }
620
621    if let Some(pattern_src) = &def.item_pattern
622        // The loader guarantees the pattern compiles and the content
623        // expression names exactly one of list/paragraph.
624        && let Ok(pattern) = regex::Regex::new(&format!("^(?:{pattern_src})$"))
625    {
626        let groups: Vec<String> = pattern
627            .capture_names()
628            .flatten()
629            .map(str::to_string)
630            .collect();
631        let targets_lists = expr.mentioned_names().contains(&"list");
632        let mut unit_index = 0usize;
633        for block in &reduced.blocks {
634            match &block.detail {
635                BlockDetail::List { items } if targets_lists => {
636                    for (line, text) in items {
637                        if !pattern.is_match(text) {
638                            out.push(SectionFormatViolation::ItemPatternMismatch {
639                                section: section.to_string(),
640                                item_index: unit_index,
641                                line: *line,
642                                text: text.clone(),
643                                pattern: pattern_src.clone(),
644                                groups: groups.clone(),
645                                example: def.example.clone(),
646                            });
647                        }
648                        unit_index += 1;
649                    }
650                }
651                BlockDetail::Paragraph { lines } if !targets_lists => {
652                    for (line, text) in lines {
653                        if !pattern.is_match(text) {
654                            out.push(SectionFormatViolation::ItemPatternMismatch {
655                                section: section.to_string(),
656                                item_index: unit_index,
657                                line: *line,
658                                text: text.clone(),
659                                pattern: pattern_src.clone(),
660                                groups: groups.clone(),
661                                example: def.example.clone(),
662                            });
663                        }
664                        unit_index += 1;
665                    }
666                }
667                _ => {}
668            }
669        }
670    }
671
672    if let Some(table_format) = &def.table {
673        for block in &reduced.blocks {
674            let BlockDetail::Table { header, rows } = &block.detail else {
675                continue;
676            };
677            if header != &table_format.columns {
678                out.push(SectionFormatViolation::TableColumns {
679                    section: section.to_string(),
680                    reason: "header".to_string(),
681                    expected_columns: table_format.columns.clone(),
682                    found_columns: header.clone(),
683                    row_line: None,
684                    expected_cells: None,
685                    found_cells: None,
686                    column: None,
687                    pattern: None,
688                    cell: None,
689                    example: def.example.clone(),
690                });
691                // A wrong header makes per-cell checks noise.
692                continue;
693            }
694            for row in rows {
695                if row.raw_cell_count != table_format.columns.len() {
696                    out.push(SectionFormatViolation::TableColumns {
697                        section: section.to_string(),
698                        reason: "cell_count".to_string(),
699                        expected_columns: table_format.columns.clone(),
700                        found_columns: Vec::new(),
701                        row_line: Some(row.line),
702                        expected_cells: Some(table_format.columns.len()),
703                        found_cells: Some(row.raw_cell_count),
704                        column: None,
705                        pattern: None,
706                        cell: None,
707                        example: def.example.clone(),
708                    });
709                    continue;
710                }
711                for (column, pattern_src) in &table_format.column_patterns {
712                    let Some(col_idx) = table_format.columns.iter().position(|c| c == column)
713                    else {
714                        continue;
715                    };
716                    let Some(cell) = row.cells.get(col_idx) else {
717                        continue;
718                    };
719                    let Ok(pattern) = regex::Regex::new(&format!("^(?:{pattern_src})$")) else {
720                        continue;
721                    };
722                    if !pattern.is_match(cell) {
723                        out.push(SectionFormatViolation::TableColumns {
724                            section: section.to_string(),
725                            reason: "cell_pattern".to_string(),
726                            expected_columns: table_format.columns.clone(),
727                            found_columns: Vec::new(),
728                            row_line: Some(row.line),
729                            expected_cells: None,
730                            found_cells: None,
731                            column: Some(column.clone()),
732                            pattern: Some(pattern_src.clone()),
733                            cell: Some(cell.clone()),
734                            example: def.example.clone(),
735                        });
736                    }
737                }
738            }
739        }
740    }
741
742    out.sort_by_key(|v| match v {
743        SectionFormatViolation::ContentMismatch { failed_at, .. } => *failed_at,
744        SectionFormatViolation::ItemPatternMismatch { line, .. } => *line,
745        SectionFormatViolation::TableColumns { row_line, .. } => row_line.unwrap_or(0),
746        SectionFormatViolation::SetextReserved { line, .. } => *line,
747    });
748    out
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754
755    fn observed(source: &str) -> Vec<ObservedBlock> {
756        reduce_section(source).observed()
757    }
758
759    #[test]
760    fn simple_bullet_list_is_one_block() {
761        assert_eq!(
762            observed("- one\n- two\n"),
763            vec![ObservedBlock::List { ordered: false }]
764        );
765        assert_eq!(
766            observed("1. one\n2. two\n"),
767            vec![ObservedBlock::List { ordered: true }]
768        );
769    }
770
771    /// Divergence pin (plan criterion 3): a lazy-continuation list is
772    /// ONE list, and the continuation joins the item text with a
773    /// single space.
774    #[test]
775    fn lazy_continuation_stays_one_list_and_joins_item_text() {
776        let src = "- **Kickoff** — Projektstart\n  mit allen Beteiligten — 2026-09-01\n- **Zwei** — kurz — 2026-09-02\n";
777        let reduced = reduce_section(src);
778        assert_eq!(
779            reduced.observed(),
780            vec![ObservedBlock::List { ordered: false }]
781        );
782        let BlockDetail::List { items } = &reduced.blocks[0].detail else {
783            panic!("list detail");
784        };
785        assert_eq!(items.len(), 2);
786        assert_eq!(
787            items[0].1,
788            "**Kickoff** — Projektstart mit allen Beteiligten — 2026-09-01",
789        );
790        assert_eq!(items[0].0, 1, "item line");
791        assert_eq!(items[1].0, 3);
792
793        // Truly lazy continuation (no indent) — same result.
794        let lazy = "- alpha\nbeta\n";
795        let reduced = reduce_section(lazy);
796        let BlockDetail::List { items } = &reduced.blocks[0].detail else {
797            panic!("list detail");
798        };
799        assert_eq!(items[0].1, "alpha beta");
800    }
801
802    /// Divergence pin: mixed `-` / `*` markers are TWO lists per
803    /// CommonMark — a scanner would see one.
804    #[test]
805    fn mixed_markers_are_two_lists() {
806        assert_eq!(
807            observed("- one\n* two\n"),
808            vec![
809                ObservedBlock::List { ordered: false },
810                ObservedBlock::List { ordered: false },
811            ]
812        );
813    }
814
815    /// Divergence pin: fenced or indented code containing `- ` lines
816    /// is code, not a list.
817    #[test]
818    fn code_blocks_containing_dashes_are_not_lists() {
819        assert_eq!(
820            observed("```\n- not a list\n```\n"),
821            vec![ObservedBlock::Code {
822                lang: String::new()
823            }]
824        );
825        assert_eq!(
826            observed("    - not a list\n"),
827            vec![ObservedBlock::Code {
828                lang: String::new()
829            }]
830        );
831        assert_eq!(
832            observed("```rust\nfn x() {}\n```\n"),
833            vec![ObservedBlock::Code {
834                lang: "rust".to_string()
835            }]
836        );
837    }
838
839    /// Divergence pin: a malformed GFM delimiter row degrades the
840    /// table to paragraphs — the parser decides, not a `|` scan.
841    #[test]
842    fn malformed_delimiter_row_is_not_a_table() {
843        let good = "| Name | Datum |\n| --- | --- |\n| a | b |\n";
844        assert_eq!(observed(good), vec![ObservedBlock::Table]);
845
846        let bad = "| Name | Datum |\n| -x- | --- |\n| a | b |\n";
847        assert!(
848            !observed(bad).contains(&ObservedBlock::Table),
849            "malformed delimiter row must not parse as a table: {:?}",
850            observed(bad)
851        );
852    }
853
854    #[test]
855    fn table_reduction_carries_header_and_row_cells() {
856        let src = "| Name | Beschreibung | Datum |\n| --- | --- | --- |\n| Kickoff | Start | 2026-09-01 |\n| Zwei | Kurz | 2026-09-02 |\n";
857        let reduced = reduce_section(src);
858        let BlockDetail::Table { header, rows } = &reduced.blocks[0].detail else {
859            panic!("table detail");
860        };
861        assert_eq!(header, &["Name", "Beschreibung", "Datum"]);
862        assert_eq!(rows.len(), 2);
863        assert_eq!(rows[0].cells, vec!["Kickoff", "Start", "2026-09-01"]);
864        assert_eq!(rows[0].raw_cell_count, 3);
865        assert_eq!(rows[1].line, 4, "row line number");
866    }
867
868    /// GFM pads/truncates mismatched row widths silently — the
869    /// reduction must preserve the REAL cell count so the column
870    /// contract can refuse it.
871    #[test]
872    fn table_rows_keep_their_real_cell_count() {
873        let src = "| A | B |\n| --- | --- |\n| only |\n| x | y | z |\n";
874        let reduced = reduce_section(src);
875        let BlockDetail::Table { rows, .. } = &reduced.blocks[0].detail else {
876            panic!("table detail");
877        };
878        // The parser pads/truncates to header width; the RAW counts
879        // preserve what the author actually wrote.
880        assert_eq!(rows[0].raw_cell_count, 1, "short row really has 1 cell");
881        assert_eq!(rows[1].raw_cell_count, 3, "long row really has 3 cells");
882    }
883
884    #[test]
885    fn paragraph_lines_carry_source_lines() {
886        let src = "erste zeile\nzweite zeile\n\nnächster absatz\n";
887        let reduced = reduce_section(src);
888        assert_eq!(
889            reduced.observed(),
890            vec![ObservedBlock::Paragraph, ObservedBlock::Paragraph]
891        );
892        let BlockDetail::Paragraph { lines } = &reduced.blocks[0].detail else {
893            panic!("paragraph detail");
894        };
895        assert_eq!(
896            lines,
897            &[
898                (1, "erste zeile".to_string()),
899                (2, "zweite zeile".to_string())
900            ]
901        );
902        let BlockDetail::Paragraph { lines } = &reduced.blocks[1].detail else {
903            panic!("paragraph detail");
904        };
905        assert_eq!(lines, &[(4, "nächster absatz".to_string())]);
906    }
907
908    #[test]
909    fn setext_headings_are_reported() {
910        let src = "Titel\n=====\n\ntext\n\nUnter\n-----\n";
911        let reduced = reduce_section(src);
912        assert_eq!(
913            reduced.setext_reserved,
914            vec![
915                SetextReservedHeading { line: 1, depth: 1 },
916                SetextReservedHeading { line: 6, depth: 2 },
917            ]
918        );
919        // ATX h3 is a heading block, not a setext report.
920        let reduced = reduce_section("### Phase 1\n- x\n");
921        assert!(reduced.setext_reserved.is_empty());
922        assert_eq!(
923            reduced.observed(),
924            vec![
925                ObservedBlock::Heading { depth: 3 },
926                ObservedBlock::List { ordered: false },
927            ]
928        );
929    }
930
931    #[test]
932    fn nested_list_text_stays_out_of_parent_item() {
933        let src = "- parent\n  - child\n- second\n";
934        let reduced = reduce_section(src);
935        assert_eq!(
936            reduced.observed(),
937            vec![ObservedBlock::List { ordered: false }]
938        );
939        let BlockDetail::List { items } = &reduced.blocks[0].detail else {
940            panic!("list detail");
941        };
942        assert_eq!(
943            items.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>(),
944            vec!["parent", "second"]
945        );
946    }
947
948    #[test]
949    fn blockquote_html_and_rule_reduce() {
950        assert_eq!(observed("> quoted\n"), vec![ObservedBlock::Blockquote]);
951        assert_eq!(observed("---\n"), vec![ObservedBlock::ThematicBreak]);
952        assert_eq!(observed("<div>\nx\n</div>\n"), vec![ObservedBlock::Html]);
953    }
954
955    /// End-to-end with the expression layer: the plan's own example
956    /// declaration accepts its own example snippet.
957    #[test]
958    fn plan_example_roundtrip() {
959        use memstead_schema::content_expr::ContentExpr;
960        let expr = ContentExpr::parse("(heading(3) list(bullet))+").unwrap();
961        let body = "### Phase 1\n- **Kickoff** — Projektstart mit allen Beteiligten — 2026-09-01\n";
962        assert!(expr.match_blocks(&reduce_section(body).observed()).is_ok());
963
964        let wrong = "### Phase 1\n\nkein listenpunkt\n";
965        let err = expr
966            .match_blocks(&reduce_section(wrong).observed())
967            .unwrap_err();
968        assert_eq!(err.failed_at, 1);
969        assert_eq!(err.expected_next, vec!["list(bullet)".to_string()]);
970    }
971}
972
973#[cfg(test)]
974mod check_tests {
975    use super::*;
976    use memstead_schema::{ConstraintSeverity, SectionDef, TableFormat};
977
978    fn def(
979        content: &str,
980        item_pattern: Option<&str>,
981        table: Option<TableFormat>,
982        example: Option<&str>,
983    ) -> SectionDef {
984        SectionDef {
985            key: "body".to_string(),
986            heading: "Body".to_string(),
987            required: true,
988            load_bearing: None,
989            search_weight: 1.0,
990            catch_all: true,
991            write_rules: vec![],
992            description: None,
993            content: Some(content.to_string()),
994            item_pattern: item_pattern.map(str::to_string),
995            table,
996            example: example.map(str::to_string),
997            format_severity: ConstraintSeverity::Block,
998            compiled_content: Some(
999                memstead_schema::content_expr::ContentExpr::parse(content).unwrap(),
1000            ),
1001            format_problems: Vec::new(),
1002        }
1003    }
1004
1005    #[test]
1006    fn content_mismatch_carries_position_expectation_and_example() {
1007        let d = def(
1008            "(heading(3) list(bullet))+",
1009            None,
1010            None,
1011            Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
1012        );
1013        let violations = check_section_format(&d, "### Phase 1\n\nprose statt liste\n");
1014        assert_eq!(violations.len(), 1);
1015        let SectionFormatViolation::ContentMismatch {
1016            failed_at,
1017            expected_next,
1018            found,
1019            example,
1020            ..
1021        } = &violations[0]
1022        else {
1023            panic!("expected content mismatch: {violations:?}");
1024        };
1025        assert_eq!(*failed_at, 3, "line of the offending paragraph");
1026        assert_eq!(expected_next, &vec!["list(bullet)".to_string()]);
1027        assert_eq!(
1028            found,
1029            &vec!["heading(3)".to_string(), "paragraph".to_string()]
1030        );
1031        assert!(example.as_deref().unwrap().contains("Kickoff"));
1032        assert_eq!(violations[0].code(), "SECTION_CONTENT_MISMATCH");
1033
1034        // Conforming body: no violations.
1035        assert!(check_section_format(&d, "### Phase 1\n- **Kickoff** — 2026-09-01\n").is_empty());
1036    }
1037
1038    #[test]
1039    fn item_pattern_flags_each_nonconforming_item_with_groups() {
1040        let d = def(
1041            "list(bullet)",
1042            Some(r"\*\*(?<name>[^*]+)\*\* — (?<datum>\d{4}-\d{2}-\d{2})"),
1043            None,
1044            None,
1045        );
1046        let ok = "- **Kickoff** — 2026-09-01\n- **Zwei** — 2026-09-02\n";
1047        assert!(check_section_format(&d, ok).is_empty());
1048
1049        // Lazy continuation still matches (joined by a single space).
1050        let lazy = "- **Kickoff** —\n  2026-09-01\n";
1051        assert!(
1052            check_section_format(&d, lazy).is_empty(),
1053            "continuation never changes the match: {:?}",
1054            check_section_format(&d, lazy)
1055        );
1056
1057        let bad = "- **Kickoff** — 2026-09-01\n- kein format\n";
1058        let violations = check_section_format(&d, bad);
1059        assert_eq!(violations.len(), 1);
1060        let SectionFormatViolation::ItemPatternMismatch {
1061            item_index,
1062            line,
1063            text,
1064            groups,
1065            ..
1066        } = &violations[0]
1067        else {
1068            panic!("expected item mismatch: {violations:?}");
1069        };
1070        assert_eq!(*item_index, 1);
1071        assert_eq!(*line, 2);
1072        assert_eq!(text, "kein format");
1073        assert_eq!(groups, &vec!["name".to_string(), "datum".to_string()]);
1074        assert_eq!(violations[0].code(), "SECTION_ITEM_PATTERN_MISMATCH");
1075    }
1076
1077    #[test]
1078    fn paragraph_pattern_checks_each_source_line() {
1079        // The anker two-halves citation shape — the left half may
1080        // contain spaces (the `bverfg:1 BvR 2649/21` case must pass).
1081        let d = def(
1082            "paragraph+",
1083            Some(r"(?<quelle>\S[^|]*?) \| (?<aussage>.+)"),
1084            None,
1085            None,
1086        );
1087        let ok = "bverfg:1 BvR 2649/21 Rn. 183 | Der Staat schuldet Schutz.\ngg:art-20a | Schutzauftrag.\n";
1088        assert!(
1089            check_section_format(&d, ok).is_empty(),
1090            "{:?}",
1091            check_section_format(&d, ok)
1092        );
1093        // The observed silent-deviation shape: missing ` | ` separator.
1094        let bad = "bverfg:1 BvR 2649/21 Rn. 183 — Der Staat schuldet Schutz.\n";
1095        let violations = check_section_format(&d, bad);
1096        assert_eq!(violations.len(), 1);
1097        assert!(matches!(
1098            &violations[0],
1099            SectionFormatViolation::ItemPatternMismatch { line: 1, .. }
1100        ));
1101    }
1102
1103    #[test]
1104    fn table_contract_enforces_columns_counts_and_cell_patterns() {
1105        let table = TableFormat {
1106            columns: vec!["Name".into(), "Datum".into()],
1107            column_patterns: [("Datum".to_string(), r"\d{4}-\d{2}-\d{2}".to_string())]
1108                .into_iter()
1109                .collect(),
1110        };
1111        let d = def("table", None, Some(table), None);
1112
1113        let ok = "| Name | Datum |\n| --- | --- |\n| Kickoff | 2026-09-01 |\n";
1114        assert!(check_section_format(&d, ok).is_empty());
1115
1116        // Wrong header order.
1117        let wrong_header = "| Datum | Name |\n| --- | --- |\n| 2026-09-01 | Kickoff |\n";
1118        let violations = check_section_format(&d, wrong_header);
1119        assert!(matches!(
1120            &violations[0],
1121            SectionFormatViolation::TableColumns { reason, .. } if reason == "header"
1122        ));
1123
1124        // Short row — GFM would silently pad it.
1125        let short = "| Name | Datum |\n| --- | --- |\n| nur-eine |\n";
1126        let violations = check_section_format(&d, short);
1127        let SectionFormatViolation::TableColumns {
1128            reason,
1129            expected_cells,
1130            found_cells,
1131            row_line,
1132            ..
1133        } = &violations[0]
1134        else {
1135            panic!("expected table violation: {violations:?}");
1136        };
1137        assert_eq!(reason, "cell_count");
1138        assert_eq!(*expected_cells, Some(2));
1139        assert_eq!(*found_cells, Some(1));
1140        assert_eq!(*row_line, Some(3));
1141        assert_eq!(violations[0].code(), "INVALID_TABLE_COLUMNS");
1142
1143        // Cell pattern violation names column, row, pattern.
1144        let bad_cell = "| Name | Datum |\n| --- | --- |\n| Kickoff | morgen |\n";
1145        let violations = check_section_format(&d, bad_cell);
1146        let SectionFormatViolation::TableColumns {
1147            reason,
1148            column,
1149            pattern,
1150            cell,
1151            row_line,
1152            ..
1153        } = &violations[0]
1154        else {
1155            panic!("expected cell violation: {violations:?}");
1156        };
1157        assert_eq!(reason, "cell_pattern");
1158        assert_eq!(column.as_deref(), Some("Datum"));
1159        assert!(pattern.as_deref().unwrap().contains("d{4}"));
1160        assert_eq!(cell.as_deref(), Some("morgen"));
1161        assert_eq!(*row_line, Some(3));
1162    }
1163
1164    /// The plenum coordinate grammar (plan criterion 9): the
1165    /// seven-times-duplicated two-halves Belegzeile — machine
1166    /// coordinate `<quelle>:<dokument>:<von>-<bis>:<hash12>:<hash12>`,
1167    /// ` | `, then the public Fundstelle — expressed as a declaration,
1168    /// without a line of project Python.
1169    #[test]
1170    fn plenum_coordinate_grammar_is_declarable() {
1171        let d = def(
1172            "paragraph+",
1173            Some(
1174                r"(?<quelle>[a-z]+):(?<dokument>[^:|]+):(?<von>\d+)-(?<bis>\d+):(?<dokument_hash>[0-9a-f]{12}):(?<span_hash>[0-9a-f]{12}) \| (?<fundstelle>.+)",
1175            ),
1176            None,
1177            None,
1178        );
1179        let ok = "btp:20/13/073:4559-4985:09b80726ef42:0a582b1c5530 | 2022-01-26 · Tino Chrupalla · https://dserver.bundestag.de/btp/20/20013.pdf
1180";
1181        assert!(
1182            check_section_format(&d, ok).is_empty(),
1183            "{:?}",
1184            check_section_format(&d, ok)
1185        );
1186        // Missing span hash — the checker's regex class, declared.
1187        let bad =
1188            "btp:20/13/073:4559-4985:09b80726ef42 | 2022-01-26 · Chrupalla · https://example.org
1189";
1190        assert_eq!(check_section_format(&d, bad).len(), 1);
1191        // Missing the two-halves separator.
1192        let bad = "btp:20/13/073:4559-4985:09b80726ef42:0a582b1c5530 2022-01-26
1193";
1194        assert_eq!(check_section_format(&d, bad).len(), 1);
1195    }
1196
1197    #[test]
1198    fn setext_reserved_headings_refuse_in_checked_sections() {
1199        let d = def("paragraph+", None, None, None);
1200        let violations = check_section_format(&d, "Titel\n=====\n\ntext\n");
1201        assert!(
1202            violations
1203                .iter()
1204                .any(|v| matches!(v, SectionFormatViolation::SetextReserved { depth: 1, .. })),
1205            "{violations:?}"
1206        );
1207        assert_eq!(
1208            violations
1209                .iter()
1210                .find(|v| matches!(v, SectionFormatViolation::SetextReserved { .. }))
1211                .unwrap()
1212                .code(),
1213            "SECTION_CONTENT_INVALID"
1214        );
1215    }
1216
1217    #[test]
1218    fn free_form_section_produces_no_violations() {
1219        let d = SectionDef {
1220            key: "body".to_string(),
1221            heading: "Body".to_string(),
1222            required: true,
1223            load_bearing: None,
1224            search_weight: 1.0,
1225            catch_all: true,
1226            write_rules: vec![],
1227            description: None,
1228            content: None,
1229            item_pattern: None,
1230            table: None,
1231            example: None,
1232            format_severity: ConstraintSeverity::Block,
1233            compiled_content: None,
1234            format_problems: Vec::new(),
1235        };
1236        assert!(check_section_format(&d, "anything\n=====\n\n- mixed\n* markers\n").is_empty());
1237    }
1238}