Skip to main content

memstead_base/validator/
strict.rs

1//! Per-entity strict checks over raw markdown bytes.
2//!
3//! Complements the tolerant `entity::parser::parse_markdown` — every
4//! invariant the tolerant parser papers over (missing title, missing
5//! frontmatter, unknown keys, unbalanced brackets) is checked here
6//! against the raw bytes before the archive is accepted.
7
8use std::sync::OnceLock;
9
10use memstead_schema::{FieldType, TypeDefinition};
11use regex::Regex;
12
13use super::ValidationError;
14use crate::entity::id::wiki_link_to_id;
15use crate::entity::parser::{Frontmatter, mask_code_blocks, split_sections};
16use crate::entity::{Entity, MetadataValue};
17
18/// Run every strict check against one entity. `raw_bytes` is the
19/// archive's markdown bytes for this entity (pre-canonicalization, so
20/// line endings may still be CRLF and a BOM may still be leading).
21pub fn validate_strict(
22    raw_bytes: &str,
23    entity: &Entity,
24    schema: &TypeDefinition,
25    path: &str,
26) -> Result<(), ValidationError> {
27    // No byte-order-mark strip here: `split_frontmatter_core` owns it, and a
28    // second strip is how the two paths came to disagree about where a
29    // document begins.
30    let (meta_block, body) = split_frontmatter_strict(raw_bytes, path)?;
31    check_metadata(meta_block, entity, schema, path)?;
32    check_title_presence(body, path)?;
33    check_sections_present(entity, schema, path)?;
34    check_unknown_sections(body, schema, path)?;
35    check_relationships_syntax(body, path)?;
36    check_relationship_types(entity, path)?;
37    check_wiki_links(body, path)?;
38
39    Ok(())
40}
41
42/// Verify the frontmatter opens with `---` on the first line and
43/// closes with `\n---`. Returns (metadata block text, body text).
44/// Body excludes the closing `\n---` line and the newline after it.
45pub(crate) fn split_frontmatter_strict<'a>(
46    raw: &'a str,
47    path: &str,
48) -> Result<(&'a str, &'a str), ValidationError> {
49    // The refusing wrapper. The arithmetic is `split_frontmatter_core`'s; what
50    // is strict here is only that each failure becomes a typed error instead
51    // of a degradation, and those refusals are wire-visible at archive
52    // ingress.
53    match crate::entity::parser::split_frontmatter_core(raw).1 {
54        Frontmatter::Present { meta, body } => Ok((meta, body)),
55        Frontmatter::NoOpeningDelimiter => Err(ValidationError::MissingFrontmatter {
56            path: path.to_string(),
57        }),
58        Frontmatter::Unclosed => Err(ValidationError::InvalidFrontmatter {
59            path: path.to_string(),
60            reason: "frontmatter block is not closed with `\\n---`".to_string(),
61        }),
62    }
63}
64
65fn check_metadata(
66    meta_block: &str,
67    entity: &Entity,
68    schema: &TypeDefinition,
69    path: &str,
70) -> Result<(), ValidationError> {
71    // 1. Unknown keys against the raw YAML (the tolerant parser accepts
72    //    them but strict ingress rejects anything not declared by the
73    //    type. `type:` itself is injected by the schema definition via
74    //    `meta_type()`, so it's already in `metadata_fields`.
75    let known_keys: Vec<&str> = schema
76        .metadata_fields
77        .iter()
78        .map(|f| f.key.as_str())
79        .collect();
80    for line in meta_block.lines() {
81        let trimmed = line.trim();
82        if trimmed.is_empty() || trimmed.starts_with('#') {
83            continue;
84        }
85        let Some(colon) = trimmed.find(':') else {
86            continue;
87        };
88        let key = trimmed[..colon].trim();
89        if !known_keys.contains(&key) {
90            return Err(ValidationError::UnknownFrontmatterKey {
91                path: path.to_string(),
92                key: key.to_string(),
93            });
94        }
95    }
96
97    // 2. Required fields present, 3. types match, 4. enum violations.
98    for field in &schema.metadata_fields {
99        let is_required = field.is_required();
100        let value = entity.metadata.get(field.key.as_str());
101        match (is_required, value) {
102            (true, None) => {
103                return Err(ValidationError::MissingRequiredField {
104                    path: path.to_string(),
105                    field: field.key.to_string(),
106                });
107            }
108            (_, Some(v)) => {
109                if !value_matches_type(v, field.field_type) {
110                    return Err(ValidationError::FieldTypeMismatch {
111                        path: path.to_string(),
112                        field: field.key.to_string(),
113                        expected: format!("{:?}", field.field_type),
114                    });
115                }
116                if let Some(ref allowed) = field.enum_values {
117                    let got = v.to_frontmatter_string();
118                    if !allowed.iter().any(|a| a == &got) {
119                        return Err(ValidationError::EnumViolation {
120                            path: path.to_string(),
121                            field: field.key.to_string(),
122                            got,
123                        });
124                    }
125                }
126            }
127            (false, None) => {}
128        }
129    }
130    Ok(())
131}
132
133fn value_matches_type(value: &MetadataValue, expected: FieldType) -> bool {
134    match (value, expected) {
135        (MetadataValue::Bool(_), FieldType::Boolean) => true,
136        (MetadataValue::Integer(_) | MetadataValue::Float(_), FieldType::Number) => true,
137        (MetadataValue::String(s), FieldType::Date) => {
138            // YYYY-MM-DD or the ISO-8601 datetime form `YYYY-MM-DDTHH:MM:SSZ`.
139            // Shared with the CRUD write path so import-ingress and
140            // create/update accept exactly the same date values.
141            crate::runtime_validator::is_date_shaped(s)
142        }
143        (MetadataValue::String(_), FieldType::String) => true,
144        // CSV array fields have `field_type: String` in the schema but
145        // arrive as strings regardless — accept.
146        (MetadataValue::String(_), _) => false,
147        _ => false,
148    }
149}
150
151/// Check that `# Title` appears on one of the first three non-empty
152/// lines of the body. The tolerant parser falls back to the filename
153/// slug when no `# ` heading is found — the point of this check is to
154/// make that fallback unreachable at ingress.
155///
156/// Scans the masked body, because the parser's title extraction does:
157/// a `# ` line inside a code block is not a title on either side of
158/// the seam. Line *counting* stays on the original so a leading code
159/// block still consumes the three-line window.
160fn check_title_presence(body: &str, path: &str) -> Result<(), ValidationError> {
161    let masked_body = mask_code_blocks(body);
162    let mut lines_seen = 0;
163    for (line, masked) in body.lines().zip(masked_body.lines()) {
164        if line.trim().is_empty() {
165            continue;
166        }
167        lines_seen += 1;
168        if lines_seen > 3 {
169            break;
170        }
171        if let Some(rest) = masked.strip_prefix("# ")
172            && !rest.trim().is_empty()
173        {
174            return Ok(());
175        }
176    }
177    Err(ValidationError::MissingTitle {
178        path: path.to_string(),
179    })
180}
181
182fn check_sections_present(
183    entity: &Entity,
184    schema: &TypeDefinition,
185    path: &str,
186) -> Result<(), ValidationError> {
187    for section in &schema.sections {
188        if !section.required || section.catch_all {
189            continue;
190        }
191        let present = entity
192            .sections
193            .get(section.key.as_str())
194            .is_some_and(|v| !v.trim().is_empty());
195        if !present {
196            return Err(ValidationError::MissingRequiredSection {
197                path: path.to_string(),
198                section: section.heading.clone(),
199            });
200        }
201    }
202    Ok(())
203}
204
205fn check_unknown_sections(
206    body: &str,
207    schema: &TypeDefinition,
208    path: &str,
209) -> Result<(), ValidationError> {
210    if schema.sections.iter().any(|s| s.catch_all) {
211        return Ok(());
212    }
213    let known_headings: Vec<&str> = schema
214        .sections
215        .iter()
216        .map(|s| s.heading.as_str())
217        .chain(std::iter::once("Relationships"))
218        .collect();
219
220    // Section boundaries come from the one splitter — the validator
221    // must not carry a second definition of what opens a section.
222    let (_, _, raw_headings) = split_sections(body, &mask_code_blocks(body));
223    for heading in &raw_headings {
224        if !known_headings.contains(&heading.as_str()) {
225            return Err(ValidationError::UnknownSection {
226                path: path.to_string(),
227                section: heading.clone(),
228            });
229        }
230    }
231    Ok(())
232}
233
234/// The engine-side format declaration for the auto-managed
235/// `## Relationships` section — the first consumer of the shared
236/// section-format mechanism (plan 08). The Relationships section is
237/// engine-managed, not a schema `SectionDef`, so the declaration
238/// lives here: an optional bullet list (empty sections are legal)
239/// whose items carry the canonical relation-line shape. Replaces the
240/// pre-plan hand-rolled per-line regex scan — one format-check
241/// implementation in the tree.
242fn relationships_format_def() -> &'static memstead_schema::SectionDef {
243    static DEF: OnceLock<memstead_schema::SectionDef> = OnceLock::new();
244    DEF.get_or_init(|| {
245        let content = "list(bullet)?";
246        memstead_schema::SectionDef {
247            key: "relationships".to_string(),
248            heading: "Relationships".to_string(),
249            required: false,
250            load_bearing: None,
251            search_weight: 0.0,
252            catch_all: false,
253            write_rules: vec![],
254            description: None,
255            content: Some(content.to_string()),
256            item_pattern: Some(r"\*\*[A-Z_]+\*\*:\s*\[\[[^\]]+\]\](\s*—.*)?".to_string()),
257            table: None,
258            example: Some("- **USES**: [[target-name]]".to_string()),
259            format_severity: memstead_schema::ConstraintSeverity::Block,
260            compiled_content: Some(
261                memstead_schema::content_expr::ContentExpr::parse(content)
262                    .expect("engine-side declaration is valid"),
263            ),
264            format_problems: Vec::new(),
265        }
266    })
267}
268
269fn check_relationships_syntax(body: &str, path: &str) -> Result<(), ValidationError> {
270    // The section body comes from the one splitter, sliced from the
271    // ORIGINAL body — not reassembled from the masked copy. The
272    // content checker below is a CommonMark parser; feeding it a body
273    // whose code blocks had already become whitespace was the seam
274    // where splitter and validator judged differently shaped input.
275    let (sections, _, _) = split_sections(body, &mask_code_blocks(body));
276    let section = match sections.get("relationships") {
277        Some((_, s)) if !s.trim().is_empty() => s.clone(),
278        _ => return Ok(()),
279    };
280    if let Some(v) =
281        crate::section_format::check_section_format(relationships_format_def(), &section)
282            .into_iter()
283            .next()
284    {
285        let line = match &v {
286            crate::section_format::SectionFormatViolation::ItemPatternMismatch { text, .. } => {
287                text.clone()
288            }
289            other => other.describe(),
290        };
291        return Err(ValidationError::InvalidRelationshipLine {
292            path: path.to_string(),
293            line,
294        });
295    }
296    Ok(())
297}
298
299fn check_relationship_types(entity: &Entity, path: &str) -> Result<(), ValidationError> {
300    for rel in &entity.relationships {
301        if !rel_type_regex().is_match(&rel.rel_type) {
302            return Err(ValidationError::InvalidRelationshipType {
303                path: path.to_string(),
304                rel_type: rel.rel_type.clone(),
305            });
306        }
307    }
308    Ok(())
309}
310
311fn rel_type_regex() -> &'static Regex {
312    static RE: OnceLock<Regex> = OnceLock::new();
313    RE.get_or_init(|| Regex::new(r"^[A-Z_]+$").unwrap())
314}
315
316/// Bracket-balance + slug-regex + reserved-syntax check for every
317/// wiki-link in the body. Operates on the masked body, so content in
318/// any CommonMark code block is ignored and inline `` `…` `` spans are
319/// invisible too — grammar examples like `` `[[<target>]]` `` in prose
320/// don't trip the slug-regex check. Exactly the masking
321/// `entity::parser::extract_inline_links` performs on the extraction
322/// side: one definition, both paths.
323///
324/// Inline code spans come from the CommonMark parser, not from a
325/// delimiter-count regex: multi-backtick spans (``` `` ` `` ```, used
326/// to display a literal backtick) are one span to the parser, where a
327/// single-backtick regex would slice into the middle of one and leave
328/// stray `` ` `` / `[[` remnants that then fool the bracket checker.
329fn check_wiki_links(body: &str, path: &str) -> Result<(), ValidationError> {
330    let masked = crate::markdown::mask_code_blocks_and_spans(body);
331
332    check_bracket_balance(&masked, path)?;
333
334    let link_re = wiki_link_regex();
335    for cap in link_re.captures_iter(&masked) {
336        let inner = &cap[1];
337        // Structural refusals fire first so their message stays
338        // specific. Slug-form grammar then routes through the same
339        // `wiki_link_to_id` that the create/update mutation pipeline
340        // calls — install-path and create-path refuse the same inputs
341        // by construction.
342        if inner.is_empty() {
343            return Err(ValidationError::InvalidWikiLink {
344                path: path.to_string(),
345                link: format!("[[{inner}]]"),
346                reason: "empty target".to_string(),
347            });
348        }
349        if inner.contains("::") {
350            return Err(ValidationError::InvalidWikiLink {
351                path: path.to_string(),
352                link: format!("[[{inner}]]"),
353                reason: "reserved `::` cross-mem syntax is not accepted".to_string(),
354            });
355        }
356        let target = match inner.find('|') {
357            Some(i) => &inner[..i],
358            None => inner,
359        };
360        if target.contains('#') {
361            return Err(ValidationError::InvalidWikiLink {
362                path: path.to_string(),
363                link: format!("[[{inner}]]"),
364                reason: "reserved `#` deep-link syntax is not accepted".to_string(),
365            });
366        }
367
368        // Delegate the slug / mem grammar checks to the shared
369        // strict resolver. The validator passes an empty current
370        // mem — the strict resolver's self-prefix-strip step is
371        // skipped (it's a Tier-1 convenience; the grammar gate fires
372        // before it), so the grammar outcome is mem-independent.
373        if let Err(e) = wiki_link_to_id(inner, "") {
374            return Err(ValidationError::InvalidWikiLink {
375                path: path.to_string(),
376                link: format!("[[{inner}]]"),
377                reason: e.to_string(),
378            });
379        }
380    }
381    Ok(())
382}
383
384fn check_bracket_balance(masked: &str, path: &str) -> Result<(), ValidationError> {
385    let bytes = masked.as_bytes();
386    let mut i = 0;
387    let mut open = 0usize;
388    while i + 1 < bytes.len() {
389        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
390            if open > 0 {
391                return Err(ValidationError::UnbalancedBrackets {
392                    path: path.to_string(),
393                });
394            }
395            open += 1;
396            i += 2;
397            continue;
398        }
399        if bytes[i] == b']' && bytes[i + 1] == b']' {
400            if open == 0 {
401                return Err(ValidationError::UnbalancedBrackets {
402                    path: path.to_string(),
403                });
404            }
405            open -= 1;
406            i += 2;
407            continue;
408        }
409        i += 1;
410    }
411    if open > 0 {
412        return Err(ValidationError::UnbalancedBrackets {
413            path: path.to_string(),
414        });
415    }
416    Ok(())
417}
418
419fn wiki_link_regex() -> &'static Regex {
420    static RE: OnceLock<Regex> = OnceLock::new();
421    RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::entity::parser::parse_markdown;
428    use memstead_schema::type_by_name;
429
430    fn spec_type() -> std::sync::Arc<memstead_schema::TypeDefinition> {
431        type_by_name("spec").unwrap()
432    }
433
434    fn parse(content: &str) -> Entity {
435        parse_markdown(content, "test.md", &spec_type(), "v")
436            .unwrap()
437            .entity
438    }
439
440    fn validate(content: &str, entity: &Entity) -> Result<(), ValidationError> {
441        validate_strict(content, entity, &spec_type(), "test.md")
442    }
443
444    const MINIMAL_SPEC: &str = "\
445---
446type: spec
447created_date: 2026-01-15
448last_modified: 2026-01-15
449level: M0
450---
451# Test Entity
452
453## Identity
454
455A meaningful identity line.
456
457## Purpose
458
459Why it exists.
460
461## Specifies
462
463What it covers.
464
465## Constraints
466
467Its limits.
468
469## Rationale
470
471Design notes.
472";
473
474    #[test]
475    fn accepts_valid_spec() {
476        let entity = parse(MINIMAL_SPEC);
477        validate(MINIMAL_SPEC, &entity).unwrap();
478    }
479
480    #[test]
481    fn rejects_missing_frontmatter() {
482        let content = "# No Frontmatter\n\n## Identity\nBody.\n";
483        let entity = parse(&format!("---\ntype: spec\n---\n{content}"));
484        let err = validate(content, &entity).unwrap_err();
485        assert!(matches!(err, ValidationError::MissingFrontmatter { .. }));
486    }
487
488    #[test]
489    fn rejects_unclosed_frontmatter() {
490        let content = "---\ntype: spec\n# stuck in frontmatter\n";
491        let entity = parse(MINIMAL_SPEC); // entity parses fine; we still reject raw
492        let err = validate(content, &entity).unwrap_err();
493        assert!(matches!(err, ValidationError::InvalidFrontmatter { .. }));
494    }
495
496    #[test]
497    fn rejects_unknown_frontmatter_key() {
498        let content = MINIMAL_SPEC.replacen("level: M0", "level: M0\nunexpected_key: oops", 1);
499        let entity = parse(&content);
500        let err = validate(&content, &entity).unwrap_err();
501        match err {
502            ValidationError::UnknownFrontmatterKey { key, .. } => {
503                assert_eq!(key, "unexpected_key");
504            }
505            other => panic!("expected UnknownFrontmatterKey, got {other:?}"),
506        }
507    }
508
509    #[test]
510    fn rejects_missing_required_field() {
511        let content = MINIMAL_SPEC.replacen("level: M0\n", "", 1);
512        let entity = parse(&content);
513        let err = validate(&content, &entity).unwrap_err();
514        assert!(matches!(err, ValidationError::MissingRequiredField { .. }));
515    }
516
517    #[test]
518    fn rejects_missing_title() {
519        let content = MINIMAL_SPEC.replacen("# Test Entity\n", "\n", 1);
520        let entity = parse(&content);
521        let err = validate(&content, &entity).unwrap_err();
522        assert!(matches!(err, ValidationError::MissingTitle { .. }));
523    }
524
525    #[test]
526    fn rejects_missing_required_section() {
527        let content = MINIMAL_SPEC.replacen("## Purpose\n\nWhy it exists.\n\n", "", 1);
528        let entity = parse(&content);
529        let err = validate(&content, &entity).unwrap_err();
530        assert!(matches!(
531            err,
532            ValidationError::MissingRequiredSection { .. }
533        ));
534    }
535
536    // Note: every shipping schema today declares exactly one catch_all
537    // section (pinned by schemas::tests::every_schema_has_exactly_one_catch_all),
538    // so `check_unknown_sections` cannot fire for the 10 registered
539    // schemas. The check stays as defense-in-depth for hypothetical
540    // future no-catch-all schemas; testing it would require a test-only
541    // TypeDefinition fixture, deferred.
542
543    #[test]
544    fn rejects_malformed_relationship_line() {
545        let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- USES: [[target]]\n");
546        let entity = parse(&content);
547        let err = validate(&content, &entity).unwrap_err();
548        assert!(matches!(
549            err,
550            ValidationError::InvalidRelationshipLine { .. }
551        ));
552    }
553
554    #[test]
555    fn accepts_valid_relationship_line() {
556        let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- **USES**: [[target-name]]\n");
557        let entity = parse(&content);
558        validate(&content, &entity).unwrap();
559    }
560
561    #[test]
562    fn rejects_invalid_wiki_link_uppercase() {
563        let content = format!("{MINIMAL_SPEC}\nSee [[MyThing]] for details.\n");
564        let entity = parse(&content);
565        let err = validate(&content, &entity).unwrap_err();
566        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
567    }
568
569    #[test]
570    fn rejects_invalid_wiki_link_underscore() {
571        let content = format!("{MINIMAL_SPEC}\nSee [[a_b]] for details.\n");
572        let entity = parse(&content);
573        let err = validate(&content, &entity).unwrap_err();
574        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
575    }
576
577    #[test]
578    fn rejects_invalid_wiki_link_space() {
579        let content = format!("{MINIMAL_SPEC}\nSee [[a b]] for details.\n");
580        let entity = parse(&content);
581        let err = validate(&content, &entity).unwrap_err();
582        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
583    }
584
585    #[test]
586    fn accepts_tier_two_cross_mem_link() {
587        let content = format!(
588            "{MINIMAL_SPEC}\nSee [[engine:health]] and [[engine:architecture/result]] for more.\n"
589        );
590        let entity = parse(&content);
591        validate(&content, &entity).unwrap();
592    }
593
594    /// Hierarchical mem paths are first-class. The install-side check
595    /// converges onto `wiki_link_to_id`, which already accepts
596    /// hierarchical Tier-2 prefixes — install no longer rejects what
597    /// create produces.
598    #[test]
599    fn accepts_hierarchical_tier_two_link() {
600        let content = format!("{MINIMAL_SPEC}\nSee [[external/engine:health]] for details.\n");
601        let entity = parse(&content);
602        validate(&content, &entity).unwrap();
603    }
604
605    #[test]
606    fn rejects_tier_two_with_empty_leaf() {
607        let content = format!("{MINIMAL_SPEC}\nSee [[:slug]] for details.\n");
608        let entity = parse(&content);
609        let err = validate(&content, &entity).unwrap_err();
610        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
611    }
612
613    #[test]
614    fn rejects_tier_two_with_empty_slug() {
615        let content = format!("{MINIMAL_SPEC}\nSee [[engine:]] for details.\n");
616        let entity = parse(&content);
617        let err = validate(&content, &entity).unwrap_err();
618        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
619    }
620
621    #[test]
622    fn rejects_tier_two_with_invalid_leaf_chars() {
623        let content = format!("{MINIMAL_SPEC}\nSee [[Engine:slug]] for details.\n");
624        let entity = parse(&content);
625        let err = validate(&content, &entity).unwrap_err();
626        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
627    }
628
629    #[test]
630    fn rejects_tier_two_with_invalid_slug_chars() {
631        let content = format!("{MINIMAL_SPEC}\nSee [[engine:Slug]] for details.\n");
632        let entity = parse(&content);
633        let err = validate(&content, &entity).unwrap_err();
634        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
635    }
636
637    #[test]
638    fn rejects_reserved_cross_mem_syntax() {
639        let content = format!("{MINIMAL_SPEC}\nSee [[other-mem::entity]] for details.\n");
640        let entity = parse(&content);
641        let err = validate(&content, &entity).unwrap_err();
642        match err {
643            ValidationError::InvalidWikiLink { reason, .. } => {
644                assert!(reason.contains("::"), "reason={reason}");
645            }
646            other => panic!("expected InvalidWikiLink, got {other:?}"),
647        }
648    }
649
650    #[test]
651    fn rejects_reserved_deep_link_syntax() {
652        let content = format!("{MINIMAL_SPEC}\nSee [[entity#section]]");
653        let entity = parse(&content);
654        let err = validate(&content, &entity).unwrap_err();
655        match err {
656            ValidationError::InvalidWikiLink { reason, .. } => {
657                assert!(reason.contains("#"), "reason={reason}");
658            }
659            other => panic!("expected InvalidWikiLink, got {other:?}"),
660        }
661    }
662
663    #[test]
664    fn rejects_empty_wiki_link() {
665        let content = format!("{MINIMAL_SPEC}\nSee [[]]");
666        let entity = parse(&content);
667        let err = validate(&content, &entity).unwrap_err();
668        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
669    }
670
671    #[test]
672    fn rejects_unbalanced_brackets() {
673        let content = format!("{MINIMAL_SPEC}\nSee [[unterminated for details.\n");
674        let entity = parse(&content);
675        let err = validate(&content, &entity).unwrap_err();
676        assert!(matches!(err, ValidationError::UnbalancedBrackets { .. }));
677    }
678
679    #[test]
680    fn accepts_valid_stub_wiki_link() {
681        let content = format!("{MINIMAL_SPEC}\nSee [[planned-feature]] and [[a/b/c]] for more.\n");
682        let entity = parse(&content);
683        validate(&content, &entity).unwrap();
684    }
685
686    #[test]
687    fn accepts_wiki_link_inside_inline_code() {
688        let content =
689            format!("{MINIMAL_SPEC}\nOne line per edge, shape `- **<REL>**: [[<target>]]`.\n");
690        let entity = parse(&content);
691        validate(&content, &entity).unwrap();
692    }
693
694    #[test]
695    fn accepts_literal_backtick_via_double_delimiter() {
696        // Real-world line from a macOS spec documenting the inline-markup
697        // tokenizer: mixes `` ` `` (double-delim showing a literal `) with
698        // `[[` inside single-delim backticks. Before the double-backtick
699        // pre-pass was added, the single-backtick regex sliced through the
700        // `` ` `` span and left a stray `[[` that tripped the bracket
701        // checker.
702        let content = format!(
703            "{MINIMAL_SPEC}\nWalks left-to-right looking for the earliest of `**`, `` ` ``, `[[`. Done.\n"
704        );
705        let entity = parse(&content);
706        validate(&content, &entity).unwrap();
707    }
708
709    #[test]
710    fn accepts_brackets_inside_double_backtick_span() {
711        // `` `text` `` — double-backtick delimiter wrapping content that
712        // itself contains single backticks. The stripped span may hold
713        // unbalanced brackets without leaking to the bracket checker.
714        let content = format!("{MINIMAL_SPEC}\n| Inline code | `` `[[slug]]` `` | note. |\n");
715        let entity = parse(&content);
716        validate(&content, &entity).unwrap();
717    }
718
719    #[test]
720    fn accepts_wiki_link_with_alias() {
721        let content = format!("{MINIMAL_SPEC}\nSee [[target|Display Text]] for more.\n");
722        let entity = parse(&content);
723        validate(&content, &entity).unwrap();
724    }
725
726    #[test]
727    fn accepts_wiki_link_with_parent_relative_and_md() {
728        let content = format!("{MINIMAL_SPEC}\nSee [[../parent/entity.md]] for more.\n");
729        let entity = parse(&content);
730        validate(&content, &entity).unwrap();
731    }
732
733    #[test]
734    fn accepts_wiki_link_inside_code_block() {
735        let content = format!("{MINIMAL_SPEC}\n```\nlet x = [[this is not a link]];\n```\n");
736        let entity = parse(&content);
737        validate(&content, &entity).unwrap();
738    }
739
740    #[test]
741    fn accepts_windows_line_endings() {
742        let content = MINIMAL_SPEC.replace('\n', "\r\n");
743        let entity = parse(&content);
744        validate(&content, &entity).unwrap();
745    }
746
747    #[test]
748    fn accepts_leading_bom() {
749        // Nothing upstream strips the mark: archive extraction keeps the
750        // raw bytes so offset reporting stays truthful, and `validate_strict`
751        // hands them straight to the core, which strips. Both the marked and
752        // the unmarked form must therefore validate identically.
753        let raw = format!("\u{feff}{MINIMAL_SPEC}");
754        let stripped = raw.strip_prefix('\u{feff}').unwrap();
755        let entity = parse(stripped);
756        validate(&raw, &entity).unwrap();
757    }
758
759    // --- the CommonMark referee: validator and splitter agree ------
760
761    /// Build a spec whose `## Specifies` body is `body`.
762    fn spec_with_specifies(body: &str) -> String {
763        MINIMAL_SPEC.replace("What it covers.", body)
764    }
765
766    /// Every builtin type declares a catch-all section, which makes
767    /// `check_unknown_sections` a no-op for them — so the check is
768    /// exercised against a variant of the spec type whose sections all
769    /// declare themselves closed.
770    fn spec_type_without_catch_all() -> std::sync::Arc<memstead_schema::TypeDefinition> {
771        let mut t = (*spec_type()).clone();
772        for section in &mut t.sections {
773            section.catch_all = false;
774        }
775        std::sync::Arc::new(t)
776    }
777
778    fn validate_no_catch_all(content: &str) -> Result<(), ValidationError> {
779        let ty = spec_type_without_catch_all();
780        let entity = crate::entity::parser::parse_markdown(content, "test.md", &ty, "specs")
781            .unwrap()
782            .entity;
783        validate_strict(content, &entity, &ty, "test.md")
784    }
785
786    /// The unknown-section check now draws boundaries from the one
787    /// splitter, so a `## ` inside any CommonMark code block is not a
788    /// section on either side of the seam.
789    #[test]
790    fn unknown_section_check_ignores_code_block_headings() {
791        for body in [
792            "```\n## Not A Section\n```",
793            "~~~\n## Not A Section\n~~~",
794            "> ```\n> ## Not A Section\n> ```",
795            "````\n```\n## Not A Section\n```\n````",
796            "    ## Not A Section",
797        ] {
798            let content = spec_with_specifies(body);
799            validate_no_catch_all(&content).unwrap_or_else(|e| {
800                panic!("code-block heading must not be a section: {body:?} -> {e}")
801            });
802        }
803    }
804
805    /// Complement: a real column-0 `## ` in prose is still an unknown
806    /// section.
807    #[test]
808    fn unknown_section_check_still_refuses_a_prose_heading() {
809        let content = spec_with_specifies("text\n\n## Invented Section\n\nmore");
810        let err = validate_no_catch_all(&content).unwrap_err();
811        assert!(
812            matches!(err, ValidationError::UnknownSection { ref section, .. } if section == "Invented Section"),
813            "{err:?}"
814        );
815    }
816
817    /// The title check scans the masked body, because the parser's
818    /// title extraction does.
819    #[test]
820    fn title_check_ignores_a_heading_inside_a_code_block() {
821        let content = MINIMAL_SPEC.replace("# Test Entity", "```\n# Fake\n```");
822        let entity = parse(&content);
823        let err = validate(&content, &entity).unwrap_err();
824        assert!(
825            matches!(err, ValidationError::MissingTitle { .. }),
826            "{err:?}"
827        );
828    }
829
830    /// One inline-code definition: a link the validator cannot see is
831    /// a link no path synthesises an edge from. A multi-backtick span
832    /// is the case a delimiter-count regex slices through.
833    #[test]
834    fn inline_code_spans_hide_links_from_the_validator() {
835        let content = spec_with_specifies("`[[Not A Slug]]` and ``[[Also Not]]`` are literals.");
836        let entity = parse(&content);
837        validate(&content, &entity).expect("links inside inline code are not links to any path");
838    }
839
840    /// Complement: the same malformed link in prose is still refused.
841    #[test]
842    fn a_malformed_link_in_prose_is_still_refused() {
843        let content = spec_with_specifies("See [[Not A Slug]].");
844        let entity = parse(&content);
845        let err = validate(&content, &entity).unwrap_err();
846        assert!(
847            matches!(err, ValidationError::InvalidWikiLink { .. }),
848            "{err:?}"
849        );
850    }
851
852    /// The seam: the relationship-syntax check used to reassemble the
853    /// section by line-scanning the *masked* body, so the CommonMark
854    /// content checker judged a body whose code blocks had already
855    /// become whitespace — and a code block in the Relationships
856    /// section read as an empty section and passed silently. The
857    /// section now comes from the one splitter, sliced from the
858    /// original.
859    #[test]
860    fn relationships_section_is_judged_on_the_original_body() {
861        let content = MINIMAL_SPEC.replace(
862            "## Rationale\n\nDesign notes.\n",
863            "## Rationale\n\nDesign notes.\n\n## Relationships\n\n```\n- **USES**: [[x]]\n```\n",
864        );
865        let entity = parse(&content);
866        let err = validate(&content, &entity).expect_err("a code block is not a relationship list");
867        assert!(
868            matches!(err, ValidationError::InvalidRelationshipLine { .. }),
869            "{err:?}"
870        );
871    }
872
873    /// Complement: the ordinary bullet list still validates, and a
874    /// `## Relationships` heading that only appears inside a code
875    /// block still opens no section.
876    #[test]
877    fn relationships_section_complements() {
878        let ok = MINIMAL_SPEC.replace(
879            "## Rationale\n\nDesign notes.\n",
880            "## Rationale\n\nDesign notes.\n\n## Relationships\n\n- **USES**: [[some-target]]\n",
881        );
882        let entity = parse(&ok);
883        validate(&ok, &entity).expect("a bullet relationship list is valid");
884
885        let fenced = spec_with_specifies("```\n## Relationships\n\nnot a list at all\n```");
886        let entity = parse(&fenced);
887        validate(&fenced, &entity).expect("a fenced `## Relationships` opens no section to check");
888    }
889
890    /// The empty-target refusal — the asymmetry's typed side — stays.
891    #[test]
892    fn empty_wiki_link_target_is_still_refused() {
893        let content = spec_with_specifies("An empty [[]] link.");
894        let entity = parse(&content);
895        let err = validate(&content, &entity).unwrap_err();
896        assert!(
897            matches!(err, ValidationError::InvalidWikiLink { ref reason, .. } if reason == "empty target"),
898            "{err:?}"
899        );
900    }
901}