Skip to main content

markdown_contract/
validate.rs

1//! The validate entry points — the one-pass over the planes that produces the merged,
2//! deterministically sorted `Vec<Finding>`.
3//!
4//! One pass, mirroring the TS `validate.ts`:
5//!
6//! 1. parse `source` (or accept a pre-parsed [`DocTree`]);
7//! 2. the contract-independent outline check ([`scan_heading_depth_jumps`]);
8//! 3. the STRUCTURE plane ([`match_structure`]) — its kind-gate gates the content leaf,
9//!    and node-local rules (including section-scoped text constraints) run with it;
10//! 4. the CONTENT plane ([`match_content`]) — the frontmatter schema plus each declared
11//!    section's content leaf over a present, correct-kind block;
12//! 5. the doc-scoped RULE plane ([`DocRule`](crate::contract::DocRule)s — including
13//!    document-scoped text constraints);
14//! 6. merge + deterministically SORT ([`sort_findings`]).
15//!
16//! The sort reproduces the TS comparator exactly: ascending `pos.line` (no-pos sorts
17//! first, as line 0), then `pos.col` (no-col as 0), then plane rank (frontmatter →
18//! structure → content → text → rule), then stable emission order.
19
20use crate::content::match_content;
21use crate::contract::Contract;
22use crate::finding::Finding;
23use crate::parse::parse_document;
24use crate::registry::{Ctx, Registry, takes_hint};
25use crate::structure::{match_structure, scan_heading_depth_jumps};
26use crate::tree::DocTree;
27
28// ── Deterministic ordering ───────────────────────────────────────────────
29
30/// The merge planes, in their tie-break order. Any id outside the named planes (a
31/// contract-chosen rule namespace like `task/...` or `summary/...`) is the `rule` plane.
32const PLANE_ORDER: [&str; 5] = ["frontmatter", "structure", "content", "text", "rule"];
33
34/// The plane a finding belongs to, derived from its id prefix.
35fn plane_rank(id: &str) -> usize {
36    let area = id.split('/').next().unwrap_or("");
37    PLANE_ORDER
38        .iter()
39        .position(|p| *p == area)
40        .unwrap_or(PLANE_ORDER.len() - 1)
41}
42
43/// Sort findings deterministically so goldens pin: line asc (no-pos first,
44/// as 0), then col (no-col as 0), then plane order, then stable emission order. Rust's
45/// stable sort supplies the same emission-index tie-break the TS comparator encodes.
46pub fn sort_findings(mut findings: Vec<Finding>) -> Vec<Finding> {
47    findings.sort_by_key(|f| {
48        let line = f.pos.map_or(0, |p| p.line);
49        let col = f.pos.and_then(|p| p.col).unwrap_or(0);
50        (line, col, plane_rank(&f.id))
51    });
52    findings
53}
54
55// ── The one-pass orchestration ────────────────────────────────────────────────────────
56
57/// Validate a pre-parsed tree against a contract: outline scan, structure plane (with
58/// node-local rules), content plane (frontmatter schema + content leaves), doc-scoped
59/// rules, deterministic sort.
60pub fn validate_tree(tree: &DocTree, contract: &Contract, path: &str) -> Vec<Finding> {
61    let registry = Registry::default();
62    let ctx = Ctx::new(path, &registry);
63
64    // Contract-independent outline check: a sub-heading that skips a level (H2→H4)
65    // warns, whether or not the grammar declares those sections.
66    let mut findings = scan_heading_depth_jumps(&tree.root, &ctx);
67
68    // Structure plane — must run before content (its kind-gate gates the content leaf).
69    if let Some(body) = &contract.body {
70        findings.extend(match_structure(&tree.root, body, &ctx));
71    }
72
73    // Content plane: the frontmatter schema plus each section's content leaf over a
74    // present, correct-kind block (guarded inside so it never re-reports the kind-gate).
75    findings.extend(match_content(tree, contract, &ctx));
76
77    // Rule plane: doc-scoped rules over the projected tree.
78    for r in &contract.rules {
79        findings.extend(r.run(tree, &ctx));
80    }
81
82    // The outermost hint fallback: a finding with no nearer description in scope takes
83    // the contract root's, unless its id never takes a hint.
84    if let Some(root_hint) = &contract.description {
85        for finding in &mut findings {
86            if finding.hint.is_none() && takes_hint(&finding.id) {
87                finding.hint = Some(root_hint.clone());
88            }
89        }
90    }
91
92    sort_findings(findings)
93}
94
95/// The string-in convenience: parse (frontmatter split + projection), then validate
96/// every plane in the TS orchestration order.
97pub fn validate(source: &str, contract: &Contract, path: &str) -> Vec<Finding> {
98    validate_tree(&parse_document(source), contract, path)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::contract::{Contract, LevelOpts, doc_rule, section, sections};
105    use crate::finding::{FindingLevel, SourcePos};
106    use crate::registry::FindingSpec;
107
108    fn f(id: &str, line: Option<u32>, col: Option<u32>) -> Finding {
109        Finding {
110            id: id.into(),
111            level: FindingLevel::Error,
112            path: "doc.md".into(),
113            pos: line.map(|l| SourcePos { line: l, col }),
114            message: String::new(),
115            hint: None,
116            fix: None,
117        }
118    }
119
120    fn ids_in_order(findings: &[Finding]) -> Vec<&str> {
121        findings.iter().map(|x| x.id.as_str()).collect()
122    }
123
124    // Contract first: the four comparator clauses, one case each.
125    #[test]
126    fn sorts_by_line_with_no_pos_first() {
127        let out = sort_findings(vec![
128            f("structure/a", Some(9), None),
129            f("structure/b", None, None),
130            f("structure/c", Some(1), None),
131        ]);
132        assert_eq!(
133            ids_in_order(&out),
134            vec!["structure/b", "structure/c", "structure/a"]
135        );
136    }
137
138    #[test]
139    fn line_ties_break_by_col_with_no_col_first() {
140        let out = sort_findings(vec![
141            f("structure/a", Some(3), Some(7)),
142            f("structure/b", Some(3), None),
143            f("structure/c", Some(3), Some(2)),
144        ]);
145        assert_eq!(
146            ids_in_order(&out),
147            vec!["structure/b", "structure/c", "structure/a"]
148        );
149    }
150
151    #[test]
152    fn full_ties_break_by_plane_order() {
153        let out = sort_findings(vec![
154            f("task/custom", Some(3), Some(1)), // rule plane (unnamed prefix)
155            f("text/requires", Some(3), Some(1)),
156            f("content/max-words", Some(3), Some(1)),
157            f("structure/block-kind", Some(3), Some(1)),
158            f("frontmatter/enum", Some(3), Some(1)),
159        ]);
160        assert_eq!(
161            ids_in_order(&out),
162            vec![
163                "frontmatter/enum",
164                "structure/block-kind",
165                "content/max-words",
166                "text/requires",
167                "task/custom"
168            ]
169        );
170    }
171
172    #[test]
173    fn emission_order_is_the_final_stable_tie_break() {
174        let out = sort_findings(vec![
175            f("structure/first", Some(3), Some(1)),
176            f("structure/second", Some(3), Some(1)),
177        ]);
178        assert_eq!(
179            ids_in_order(&out),
180            vec!["structure/first", "structure/second"]
181        );
182    }
183
184    // The convenience door: source in, sorted findings out; doc rules run after structure.
185    #[test]
186    fn validate_runs_structure_and_doc_rules_in_one_pass() {
187        let contract = Contract::new()
188            .body(sections(LevelOpts::default(), vec![section("Overview")]))
189            .doc_rule(doc_rule("task/post-mortem-when-worked", |tree, ctx| {
190                vec![
191                    ctx.finding(
192                        FindingSpec::new("task/post-mortem-when-worked", "post-mortem required")
193                            .pos(tree.root.sections[0].pos),
194                    ),
195                ]
196            }));
197        let out = validate("## Summary\n\nprose\n", &contract, "task.md");
198        // Same line 1: structure sorts before the rule plane.
199        assert_eq!(
200            out.iter().map(|x| x.id.as_str()).collect::<Vec<_>>(),
201            vec!["structure/section-missing", "task/post-mortem-when-worked"]
202        );
203        assert_eq!(out[0].path, "task.md");
204    }
205
206    #[test]
207    fn a_contract_with_no_body_only_scans_the_outline() {
208        let out = validate("## A\n\n#### Deep\n\nx\n", &Contract::new(), "n.md");
209        assert_eq!(
210            out.iter()
211                .map(|x| (x.id.as_str(), x.level))
212                .collect::<Vec<_>>(),
213            vec![("structure/heading-depth-jump", FindingLevel::Warn)]
214        );
215    }
216
217    // A YAML error is outside every description's scope, so the contract's description
218    // is not its hint. Other findings still take it.
219    #[test]
220    fn a_yaml_error_takes_no_hint_from_the_contract_description() {
221        let contract = crate::declarative::load_contract(
222            "mcVersion: 2\nkind: contract\ndescription: d\nbody:\n  sections:\n    - section: A\n",
223        )
224        .unwrap();
225        let out = validate("---\ntitle: 'x\n---\n", &contract, "n.md");
226        let hint = |id: &str| out.iter().find(|f| f.id == id).map(|f| f.hint.clone());
227        assert_eq!(hint("frontmatter/yaml"), Some(None));
228        assert_eq!(
229            hint("structure/section-missing"),
230            Some(Some("d".to_string()))
231        );
232    }
233}