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            search_weight: 0.0,
268            catch_all: false,
269            write_rules: vec![],
270            description: None,
271            content: Some(content.to_string()),
272            item_pattern: Some(r"\*\*[A-Z_]+\*\*:\s*\[\[[^\]]+\]\](\s*—.*)?".to_string()),
273            table: None,
274            example: Some("- **USES**: [[target-name]]".to_string()),
275            format_severity: memstead_schema::ConstraintSeverity::Block,
276            compiled_content: Some(
277                memstead_schema::content_expr::ContentExpr::parse(content)
278                    .expect("engine-side declaration is valid"),
279            ),
280            format_problems: Vec::new(),
281        }
282    })
283}
284
285fn check_relationships_syntax(body: &str, path: &str) -> Result<(), ValidationError> {
286    // The section body comes from the one splitter, sliced from the
287    // ORIGINAL body — not reassembled from the masked copy. The
288    // content checker below is a CommonMark parser; feeding it a body
289    // whose code blocks had already become whitespace was the seam
290    // where splitter and validator judged differently shaped input.
291    let (sections, _, _) = split_sections(body, &mask_code_blocks(body));
292    let section = match sections.get("relationships") {
293        Some(s) if !s.trim().is_empty() => s.clone(),
294        _ => return Ok(()),
295    };
296    if let Some(v) =
297        crate::section_format::check_section_format(relationships_format_def(), &section)
298            .into_iter()
299            .next()
300    {
301        let line = match &v {
302            crate::section_format::SectionFormatViolation::ItemPatternMismatch { text, .. } => {
303                text.clone()
304            }
305            other => other.describe(),
306        };
307        return Err(ValidationError::InvalidRelationshipLine {
308            path: path.to_string(),
309            line,
310        });
311    }
312    Ok(())
313}
314
315fn check_relationship_types(entity: &Entity, path: &str) -> Result<(), ValidationError> {
316    for rel in &entity.relationships {
317        if !rel_type_regex().is_match(&rel.rel_type) {
318            return Err(ValidationError::InvalidRelationshipType {
319                path: path.to_string(),
320                rel_type: rel.rel_type.clone(),
321            });
322        }
323    }
324    Ok(())
325}
326
327fn rel_type_regex() -> &'static Regex {
328    static RE: OnceLock<Regex> = OnceLock::new();
329    RE.get_or_init(|| Regex::new(r"^[A-Z_]+$").unwrap())
330}
331
332/// Bracket-balance + slug-regex + reserved-syntax check for every
333/// wiki-link in the body. Operates on the masked body, so content in
334/// any CommonMark code block is ignored and inline `` `…` `` spans are
335/// invisible too — grammar examples like `` `[[<target>]]` `` in prose
336/// don't trip the slug-regex check. Exactly the masking
337/// `entity::parser::extract_inline_links` performs on the extraction
338/// side: one definition, both paths.
339///
340/// Inline code spans come from the CommonMark parser, not from a
341/// delimiter-count regex: multi-backtick spans (``` `` ` `` ```, used
342/// to display a literal backtick) are one span to the parser, where a
343/// single-backtick regex would slice into the middle of one and leave
344/// stray `` ` `` / `[[` remnants that then fool the bracket checker.
345fn check_wiki_links(body: &str, path: &str) -> Result<(), ValidationError> {
346    let masked = crate::markdown::mask_code_blocks_and_spans(body);
347
348    check_bracket_balance(&masked, path)?;
349
350    let link_re = wiki_link_regex();
351    for cap in link_re.captures_iter(&masked) {
352        let inner = &cap[1];
353        // Structural refusals fire first so their message stays
354        // specific. Slug-form grammar then routes through the same
355        // `wiki_link_to_id` that the create/update mutation pipeline
356        // calls — install-path and create-path refuse the same inputs
357        // by construction.
358        if inner.is_empty() {
359            return Err(ValidationError::InvalidWikiLink {
360                path: path.to_string(),
361                link: format!("[[{inner}]]"),
362                reason: "empty target".to_string(),
363            });
364        }
365        if inner.contains("::") {
366            return Err(ValidationError::InvalidWikiLink {
367                path: path.to_string(),
368                link: format!("[[{inner}]]"),
369                reason: "reserved `::` cross-mem syntax is not accepted".to_string(),
370            });
371        }
372        let target = match inner.find('|') {
373            Some(i) => &inner[..i],
374            None => inner,
375        };
376        if target.contains('#') {
377            return Err(ValidationError::InvalidWikiLink {
378                path: path.to_string(),
379                link: format!("[[{inner}]]"),
380                reason: "reserved `#` deep-link syntax is not accepted".to_string(),
381            });
382        }
383
384        // Delegate the slug / mem grammar checks to the shared
385        // strict resolver. The validator passes an empty current
386        // mem — the strict resolver's self-prefix-strip step is
387        // skipped (it's a Tier-1 convenience; the grammar gate fires
388        // before it), so the grammar outcome is mem-independent.
389        if let Err(e) = wiki_link_to_id(inner, "") {
390            return Err(ValidationError::InvalidWikiLink {
391                path: path.to_string(),
392                link: format!("[[{inner}]]"),
393                reason: e.to_string(),
394            });
395        }
396    }
397    Ok(())
398}
399
400fn check_bracket_balance(masked: &str, path: &str) -> Result<(), ValidationError> {
401    let bytes = masked.as_bytes();
402    let mut i = 0;
403    let mut open = 0usize;
404    while i + 1 < bytes.len() {
405        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
406            if open > 0 {
407                return Err(ValidationError::UnbalancedBrackets {
408                    path: path.to_string(),
409                });
410            }
411            open += 1;
412            i += 2;
413            continue;
414        }
415        if bytes[i] == b']' && bytes[i + 1] == b']' {
416            if open == 0 {
417                return Err(ValidationError::UnbalancedBrackets {
418                    path: path.to_string(),
419                });
420            }
421            open -= 1;
422            i += 2;
423            continue;
424        }
425        i += 1;
426    }
427    if open > 0 {
428        return Err(ValidationError::UnbalancedBrackets {
429            path: path.to_string(),
430        });
431    }
432    Ok(())
433}
434
435fn wiki_link_regex() -> &'static Regex {
436    static RE: OnceLock<Regex> = OnceLock::new();
437    RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::entity::parser::parse_markdown;
444    use memstead_schema::type_by_name;
445
446    fn spec_type() -> std::sync::Arc<memstead_schema::TypeDefinition> {
447        type_by_name("spec").unwrap()
448    }
449
450    fn parse(content: &str) -> Entity {
451        parse_markdown(content, "test.md", &spec_type(), "v")
452            .unwrap()
453            .entity
454    }
455
456    fn validate(content: &str, entity: &Entity) -> Result<(), ValidationError> {
457        validate_strict(content, entity, &spec_type(), "test.md")
458    }
459
460    const MINIMAL_SPEC: &str = "\
461---
462type: spec
463created_date: 2026-01-15
464last_modified: 2026-01-15
465level: M0
466---
467# Test Entity
468
469## Identity
470
471A meaningful identity line.
472
473## Purpose
474
475Why it exists.
476
477## Specifies
478
479What it covers.
480
481## Constraints
482
483Its limits.
484
485## Rationale
486
487Design notes.
488";
489
490    #[test]
491    fn accepts_valid_spec() {
492        let entity = parse(MINIMAL_SPEC);
493        validate(MINIMAL_SPEC, &entity).unwrap();
494    }
495
496    #[test]
497    fn rejects_missing_frontmatter() {
498        let content = "# No Frontmatter\n\n## Identity\nBody.\n";
499        let entity = parse(&format!("---\ntype: spec\n---\n{content}"));
500        let err = validate(content, &entity).unwrap_err();
501        assert!(matches!(err, ValidationError::MissingFrontmatter { .. }));
502    }
503
504    #[test]
505    fn rejects_unclosed_frontmatter() {
506        let content = "---\ntype: spec\n# stuck in frontmatter\n";
507        let entity = parse(MINIMAL_SPEC); // entity parses fine; we still reject raw
508        let err = validate(content, &entity).unwrap_err();
509        assert!(matches!(err, ValidationError::InvalidFrontmatter { .. }));
510    }
511
512    #[test]
513    fn rejects_unknown_frontmatter_key() {
514        let content = MINIMAL_SPEC.replacen("level: M0", "level: M0\nunexpected_key: oops", 1);
515        let entity = parse(&content);
516        let err = validate(&content, &entity).unwrap_err();
517        match err {
518            ValidationError::UnknownFrontmatterKey { key, .. } => {
519                assert_eq!(key, "unexpected_key");
520            }
521            other => panic!("expected UnknownFrontmatterKey, got {other:?}"),
522        }
523    }
524
525    #[test]
526    fn rejects_missing_required_field() {
527        let content = MINIMAL_SPEC.replacen("level: M0\n", "", 1);
528        let entity = parse(&content);
529        let err = validate(&content, &entity).unwrap_err();
530        assert!(matches!(err, ValidationError::MissingRequiredField { .. }));
531    }
532
533    #[test]
534    fn rejects_missing_title() {
535        let content = MINIMAL_SPEC.replacen("# Test Entity\n", "\n", 1);
536        let entity = parse(&content);
537        let err = validate(&content, &entity).unwrap_err();
538        assert!(matches!(err, ValidationError::MissingTitle { .. }));
539    }
540
541    #[test]
542    fn rejects_missing_required_section() {
543        let content = MINIMAL_SPEC.replacen("## Purpose\n\nWhy it exists.\n\n", "", 1);
544        let entity = parse(&content);
545        let err = validate(&content, &entity).unwrap_err();
546        assert!(matches!(
547            err,
548            ValidationError::MissingRequiredSection { .. }
549        ));
550    }
551
552    // Note: every shipping schema today declares exactly one catch_all
553    // section (pinned by schemas::tests::every_schema_has_exactly_one_catch_all),
554    // so `check_unknown_sections` cannot fire for the 10 registered
555    // schemas. The check stays as defense-in-depth for hypothetical
556    // future no-catch-all schemas; testing it would require a test-only
557    // TypeDefinition fixture, deferred.
558
559    #[test]
560    fn rejects_malformed_relationship_line() {
561        let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- USES: [[target]]\n");
562        let entity = parse(&content);
563        let err = validate(&content, &entity).unwrap_err();
564        assert!(matches!(
565            err,
566            ValidationError::InvalidRelationshipLine { .. }
567        ));
568    }
569
570    #[test]
571    fn accepts_valid_relationship_line() {
572        let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- **USES**: [[target-name]]\n");
573        let entity = parse(&content);
574        validate(&content, &entity).unwrap();
575    }
576
577    #[test]
578    fn rejects_invalid_wiki_link_uppercase() {
579        let content = format!("{MINIMAL_SPEC}\nSee [[MyThing]] 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 rejects_invalid_wiki_link_underscore() {
587        let content = format!("{MINIMAL_SPEC}\nSee [[a_b]] for details.\n");
588        let entity = parse(&content);
589        let err = validate(&content, &entity).unwrap_err();
590        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
591    }
592
593    #[test]
594    fn rejects_invalid_wiki_link_space() {
595        let content = format!("{MINIMAL_SPEC}\nSee [[a b]] for details.\n");
596        let entity = parse(&content);
597        let err = validate(&content, &entity).unwrap_err();
598        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
599    }
600
601    #[test]
602    fn accepts_tier_two_cross_mem_link() {
603        let content = format!(
604            "{MINIMAL_SPEC}\nSee [[engine:health]] and [[engine:architecture/result]] for more.\n"
605        );
606        let entity = parse(&content);
607        validate(&content, &entity).unwrap();
608    }
609
610    /// Hierarchical mem paths are first-class. The install-side check
611    /// converges onto `wiki_link_to_id`, which already accepts
612    /// hierarchical Tier-2 prefixes — install no longer rejects what
613    /// create produces.
614    #[test]
615    fn accepts_hierarchical_tier_two_link() {
616        let content = format!("{MINIMAL_SPEC}\nSee [[external/engine:health]] for details.\n");
617        let entity = parse(&content);
618        validate(&content, &entity).unwrap();
619    }
620
621    #[test]
622    fn rejects_tier_two_with_empty_leaf() {
623        let content = format!("{MINIMAL_SPEC}\nSee [[: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_empty_slug() {
631        let content = format!("{MINIMAL_SPEC}\nSee [[engine:]] 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_tier_two_with_invalid_leaf_chars() {
639        let content = format!("{MINIMAL_SPEC}\nSee [[Engine:slug]] for details.\n");
640        let entity = parse(&content);
641        let err = validate(&content, &entity).unwrap_err();
642        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
643    }
644
645    #[test]
646    fn rejects_tier_two_with_invalid_slug_chars() {
647        let content = format!("{MINIMAL_SPEC}\nSee [[engine:Slug]] for details.\n");
648        let entity = parse(&content);
649        let err = validate(&content, &entity).unwrap_err();
650        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
651    }
652
653    #[test]
654    fn rejects_reserved_cross_mem_syntax() {
655        let content = format!("{MINIMAL_SPEC}\nSee [[other-mem::entity]] for details.\n");
656        let entity = parse(&content);
657        let err = validate(&content, &entity).unwrap_err();
658        match err {
659            ValidationError::InvalidWikiLink { reason, .. } => {
660                assert!(reason.contains("::"), "reason={reason}");
661            }
662            other => panic!("expected InvalidWikiLink, got {other:?}"),
663        }
664    }
665
666    #[test]
667    fn rejects_reserved_deep_link_syntax() {
668        let content = format!("{MINIMAL_SPEC}\nSee [[entity#section]]");
669        let entity = parse(&content);
670        let err = validate(&content, &entity).unwrap_err();
671        match err {
672            ValidationError::InvalidWikiLink { reason, .. } => {
673                assert!(reason.contains("#"), "reason={reason}");
674            }
675            other => panic!("expected InvalidWikiLink, got {other:?}"),
676        }
677    }
678
679    #[test]
680    fn rejects_empty_wiki_link() {
681        let content = format!("{MINIMAL_SPEC}\nSee [[]]");
682        let entity = parse(&content);
683        let err = validate(&content, &entity).unwrap_err();
684        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
685    }
686
687    #[test]
688    fn rejects_unbalanced_brackets() {
689        let content = format!("{MINIMAL_SPEC}\nSee [[unterminated for details.\n");
690        let entity = parse(&content);
691        let err = validate(&content, &entity).unwrap_err();
692        assert!(matches!(err, ValidationError::UnbalancedBrackets { .. }));
693    }
694
695    #[test]
696    fn accepts_valid_stub_wiki_link() {
697        let content = format!("{MINIMAL_SPEC}\nSee [[planned-feature]] and [[a/b/c]] for more.\n");
698        let entity = parse(&content);
699        validate(&content, &entity).unwrap();
700    }
701
702    #[test]
703    fn accepts_wiki_link_inside_inline_code() {
704        let content =
705            format!("{MINIMAL_SPEC}\nOne line per edge, shape `- **<REL>**: [[<target>]]`.\n");
706        let entity = parse(&content);
707        validate(&content, &entity).unwrap();
708    }
709
710    #[test]
711    fn accepts_literal_backtick_via_double_delimiter() {
712        // Real-world line from a macOS spec documenting the inline-markup
713        // tokenizer: mixes `` ` `` (double-delim showing a literal `) with
714        // `[[` inside single-delim backticks. Before the double-backtick
715        // pre-pass was added, the single-backtick regex sliced through the
716        // `` ` `` span and left a stray `[[` that tripped the bracket
717        // checker.
718        let content = format!(
719            "{MINIMAL_SPEC}\nWalks left-to-right looking for the earliest of `**`, `` ` ``, `[[`. Done.\n"
720        );
721        let entity = parse(&content);
722        validate(&content, &entity).unwrap();
723    }
724
725    #[test]
726    fn accepts_brackets_inside_double_backtick_span() {
727        // `` `text` `` — double-backtick delimiter wrapping content that
728        // itself contains single backticks. The stripped span may hold
729        // unbalanced brackets without leaking to the bracket checker.
730        let content = format!("{MINIMAL_SPEC}\n| Inline code | `` `[[slug]]` `` | note. |\n");
731        let entity = parse(&content);
732        validate(&content, &entity).unwrap();
733    }
734
735    #[test]
736    fn accepts_wiki_link_with_alias() {
737        let content = format!("{MINIMAL_SPEC}\nSee [[target|Display Text]] for more.\n");
738        let entity = parse(&content);
739        validate(&content, &entity).unwrap();
740    }
741
742    #[test]
743    fn accepts_wiki_link_with_parent_relative_and_md() {
744        let content = format!("{MINIMAL_SPEC}\nSee [[../parent/entity.md]] for more.\n");
745        let entity = parse(&content);
746        validate(&content, &entity).unwrap();
747    }
748
749    #[test]
750    fn accepts_wiki_link_inside_code_block() {
751        let content = format!("{MINIMAL_SPEC}\n```\nlet x = [[this is not a link]];\n```\n");
752        let entity = parse(&content);
753        validate(&content, &entity).unwrap();
754    }
755
756    #[test]
757    fn accepts_windows_line_endings() {
758        let content = MINIMAL_SPEC.replace('\n', "\r\n");
759        let entity = parse(&content);
760        validate(&content, &entity).unwrap();
761    }
762
763    #[test]
764    fn accepts_leading_bom() {
765        // The BOM is stripped by the archive extraction layer before
766        // parse_markdown sees the bytes — mirror that here. The strict
767        // checker also tolerates BOM on the raw pass (defense-in-depth).
768        let raw = format!("\u{feff}{MINIMAL_SPEC}");
769        let stripped = raw.strip_prefix('\u{feff}').unwrap();
770        let entity = parse(stripped);
771        validate(&raw, &entity).unwrap();
772    }
773
774    // --- the CommonMark referee: validator and splitter agree ------
775
776    /// Build a spec whose `## Specifies` body is `body`.
777    fn spec_with_specifies(body: &str) -> String {
778        MINIMAL_SPEC.replace("What it covers.", body)
779    }
780
781    /// Every builtin type declares a catch-all section, which makes
782    /// `check_unknown_sections` a no-op for them — so the check is
783    /// exercised against a variant of the spec type whose sections all
784    /// declare themselves closed.
785    fn spec_type_without_catch_all() -> std::sync::Arc<memstead_schema::TypeDefinition> {
786        let mut t = (*spec_type()).clone();
787        for section in &mut t.sections {
788            section.catch_all = false;
789        }
790        std::sync::Arc::new(t)
791    }
792
793    fn validate_no_catch_all(content: &str) -> Result<(), ValidationError> {
794        let ty = spec_type_without_catch_all();
795        let entity = crate::entity::parser::parse_markdown(content, "test.md", &ty, "specs")
796            .unwrap()
797            .entity;
798        validate_strict(content, &entity, &ty, "test.md")
799    }
800
801    /// The unknown-section check now draws boundaries from the one
802    /// splitter, so a `## ` inside any CommonMark code block is not a
803    /// section on either side of the seam.
804    #[test]
805    fn unknown_section_check_ignores_code_block_headings() {
806        for body in [
807            "```\n## Not A Section\n```",
808            "~~~\n## Not A Section\n~~~",
809            "> ```\n> ## Not A Section\n> ```",
810            "````\n```\n## Not A Section\n```\n````",
811            "    ## Not A Section",
812        ] {
813            let content = spec_with_specifies(body);
814            validate_no_catch_all(&content).unwrap_or_else(|e| {
815                panic!("code-block heading must not be a section: {body:?} -> {e}")
816            });
817        }
818    }
819
820    /// Complement: a real column-0 `## ` in prose is still an unknown
821    /// section.
822    #[test]
823    fn unknown_section_check_still_refuses_a_prose_heading() {
824        let content = spec_with_specifies("text\n\n## Invented Section\n\nmore");
825        let err = validate_no_catch_all(&content).unwrap_err();
826        assert!(
827            matches!(err, ValidationError::UnknownSection { ref section, .. } if section == "Invented Section"),
828            "{err:?}"
829        );
830    }
831
832    /// The title check scans the masked body, because the parser's
833    /// title extraction does.
834    #[test]
835    fn title_check_ignores_a_heading_inside_a_code_block() {
836        let content = MINIMAL_SPEC.replace("# Test Entity", "```\n# Fake\n```");
837        let entity = parse(&content);
838        let err = validate(&content, &entity).unwrap_err();
839        assert!(
840            matches!(err, ValidationError::MissingTitle { .. }),
841            "{err:?}"
842        );
843    }
844
845    /// One inline-code definition: a link the validator cannot see is
846    /// a link no path synthesises an edge from. A multi-backtick span
847    /// is the case a delimiter-count regex slices through.
848    #[test]
849    fn inline_code_spans_hide_links_from_the_validator() {
850        let content = spec_with_specifies("`[[Not A Slug]]` and ``[[Also Not]]`` are literals.");
851        let entity = parse(&content);
852        validate(&content, &entity).expect("links inside inline code are not links to any path");
853    }
854
855    /// Complement: the same malformed link in prose is still refused.
856    #[test]
857    fn a_malformed_link_in_prose_is_still_refused() {
858        let content = spec_with_specifies("See [[Not A Slug]].");
859        let entity = parse(&content);
860        let err = validate(&content, &entity).unwrap_err();
861        assert!(
862            matches!(err, ValidationError::InvalidWikiLink { .. }),
863            "{err:?}"
864        );
865    }
866
867    /// The seam: the relationship-syntax check used to reassemble the
868    /// section by line-scanning the *masked* body, so the CommonMark
869    /// content checker judged a body whose code blocks had already
870    /// become whitespace — and a code block in the Relationships
871    /// section read as an empty section and passed silently. The
872    /// section now comes from the one splitter, sliced from the
873    /// original.
874    #[test]
875    fn relationships_section_is_judged_on_the_original_body() {
876        let content = MINIMAL_SPEC.replace(
877            "## Rationale\n\nDesign notes.\n",
878            "## Rationale\n\nDesign notes.\n\n## Relationships\n\n```\n- **USES**: [[x]]\n```\n",
879        );
880        let entity = parse(&content);
881        let err = validate(&content, &entity).expect_err("a code block is not a relationship list");
882        assert!(
883            matches!(err, ValidationError::InvalidRelationshipLine { .. }),
884            "{err:?}"
885        );
886    }
887
888    /// Complement: the ordinary bullet list still validates, and a
889    /// `## Relationships` heading that only appears inside a code
890    /// block still opens no section.
891    #[test]
892    fn relationships_section_complements() {
893        let ok = MINIMAL_SPEC.replace(
894            "## Rationale\n\nDesign notes.\n",
895            "## Rationale\n\nDesign notes.\n\n## Relationships\n\n- **USES**: [[some-target]]\n",
896        );
897        let entity = parse(&ok);
898        validate(&ok, &entity).expect("a bullet relationship list is valid");
899
900        let fenced = spec_with_specifies("```\n## Relationships\n\nnot a list at all\n```");
901        let entity = parse(&fenced);
902        validate(&fenced, &entity).expect("a fenced `## Relationships` opens no section to check");
903    }
904
905    /// The empty-target refusal — the asymmetry's typed side — stays.
906    #[test]
907    fn empty_wiki_link_target_is_still_refused() {
908        let content = spec_with_specifies("An empty [[]] link.");
909        let entity = parse(&content);
910        let err = validate(&content, &entity).unwrap_err();
911        assert!(
912            matches!(err, ValidationError::InvalidWikiLink { ref reason, .. } if reason == "empty target"),
913            "{err:?}"
914        );
915    }
916}