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