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;
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.
48fn 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.
172fn check_title_presence(body: &str, path: &str) -> Result<(), ValidationError> {
173    let mut lines_seen = 0;
174    for line in body.lines() {
175        if line.trim().is_empty() {
176            continue;
177        }
178        lines_seen += 1;
179        if lines_seen > 3 {
180            break;
181        }
182        if let Some(rest) = line.strip_prefix("# ")
183            && !rest.trim().is_empty()
184        {
185            return Ok(());
186        }
187    }
188    Err(ValidationError::MissingTitle {
189        path: path.to_string(),
190    })
191}
192
193fn check_sections_present(
194    entity: &Entity,
195    schema: &TypeDefinition,
196    path: &str,
197) -> Result<(), ValidationError> {
198    for section in &schema.sections {
199        if !section.required || section.catch_all {
200            continue;
201        }
202        let present = entity
203            .sections
204            .get(section.key.as_str())
205            .is_some_and(|v| !v.trim().is_empty());
206        if !present {
207            return Err(ValidationError::MissingRequiredSection {
208                path: path.to_string(),
209                section: section.heading.clone(),
210            });
211        }
212    }
213    Ok(())
214}
215
216fn check_unknown_sections(
217    body: &str,
218    schema: &TypeDefinition,
219    path: &str,
220) -> Result<(), ValidationError> {
221    if schema.sections.iter().any(|s| s.catch_all) {
222        return Ok(());
223    }
224    let known_headings: Vec<&str> = schema
225        .sections
226        .iter()
227        .map(|s| s.heading.as_str())
228        .chain(std::iter::once("Relationships"))
229        .collect();
230
231    let masked = mask_code_blocks(body);
232    for line in masked.lines() {
233        if let Some(rest) = line.strip_prefix("## ") {
234            let heading = rest.trim();
235            if !known_headings.contains(&heading) {
236                return Err(ValidationError::UnknownSection {
237                    path: path.to_string(),
238                    section: heading.to_string(),
239                });
240            }
241        }
242    }
243    Ok(())
244}
245
246/// The engine-side format declaration for the auto-managed
247/// `## Relationships` section — the first consumer of the shared
248/// section-format mechanism (plan 08). The Relationships section is
249/// engine-managed, not a schema `SectionDef`, so the declaration
250/// lives here: an optional bullet list (empty sections are legal)
251/// whose items carry the canonical relation-line shape. Replaces the
252/// pre-plan hand-rolled per-line regex scan — one format-check
253/// implementation in the tree.
254fn relationships_format_def() -> &'static memstead_schema::SectionDef {
255    static DEF: OnceLock<memstead_schema::SectionDef> = OnceLock::new();
256    DEF.get_or_init(|| {
257        let content = "list(bullet)?";
258        memstead_schema::SectionDef {
259            key: "relationships".to_string(),
260            heading: "Relationships".to_string(),
261            required: false,
262            search_weight: 0.0,
263            catch_all: false,
264            write_rules: vec![],
265            description: None,
266            content: Some(content.to_string()),
267            item_pattern: Some(r"\*\*[A-Z_]+\*\*:\s*\[\[[^\]]+\]\](\s*—.*)?".to_string()),
268            table: None,
269            example: Some("- **USES**: [[target-name]]".to_string()),
270            format_severity: memstead_schema::ConstraintSeverity::Block,
271            compiled_content: Some(
272                memstead_schema::content_expr::ContentExpr::parse(content)
273                    .expect("engine-side declaration is valid"),
274            ),
275            format_problems: Vec::new(),
276        }
277    })
278}
279
280fn check_relationships_syntax(body: &str, path: &str) -> Result<(), ValidationError> {
281    // Extract the `## Relationships` section body (code fences
282    // masked so an embedded `## Relationships` inside a fence cannot
283    // open the section).
284    let masked = mask_code_blocks(body);
285    let mut section = String::new();
286    let mut in_rel = false;
287    for line in masked.lines() {
288        if line.starts_with("## ") {
289            in_rel = line.strip_prefix("## ").map(str::trim) == Some("Relationships");
290            continue;
291        }
292        if in_rel {
293            section.push_str(line);
294            section.push('\n');
295        }
296    }
297    if section.trim().is_empty() {
298        return Ok(());
299    }
300    if let Some(v) =
301        crate::section_format::check_section_format(relationships_format_def(), &section)
302            .into_iter()
303            .next()
304    {
305        let line = match &v {
306            crate::section_format::SectionFormatViolation::ItemPatternMismatch { text, .. } => {
307                text.clone()
308            }
309            other => other.describe(),
310        };
311        return Err(ValidationError::InvalidRelationshipLine {
312            path: path.to_string(),
313            line,
314        });
315    }
316    Ok(())
317}
318
319fn check_relationship_types(entity: &Entity, path: &str) -> Result<(), ValidationError> {
320    for rel in &entity.relationships {
321        if !rel_type_regex().is_match(&rel.rel_type) {
322            return Err(ValidationError::InvalidRelationshipType {
323                path: path.to_string(),
324                rel_type: rel.rel_type.clone(),
325            });
326        }
327    }
328    Ok(())
329}
330
331fn rel_type_regex() -> &'static Regex {
332    static RE: OnceLock<Regex> = OnceLock::new();
333    RE.get_or_init(|| Regex::new(r"^[A-Z_]+$").unwrap())
334}
335
336/// Bracket-balance + slug-regex + reserved-syntax check for every
337/// wiki-link in the body. Operates on the code-block-masked body so
338/// fenced code sections are ignored. Inline `` `…` `` spans are also
339/// stripped so grammar examples like `` `[[<target>]]` `` in prose don't
340/// trip the slug-regex check — mirrors what `entity::parser::extract_inline_links`
341/// already does on the extraction side.
342///
343/// Two inline passes, in order: first the CommonMark double-backtick
344/// delimiter (``` `` ` `` ``` — used to display a literal backtick, or
345/// to wrap content that itself contains a single backtick), then plain
346/// single-backtick spans. Running them in that order prevents the
347/// single-backtick regex from slicing into the middle of a double-
348/// backtick span and leaving stray `` ` `` / `[[` remnants that would
349/// then fool the bracket checker.
350fn check_wiki_links(body: &str, path: &str) -> Result<(), ValidationError> {
351    let fenced_masked = mask_code_blocks(body);
352    let after_double = inline_double_backtick_regex()
353        .replace_all(&fenced_masked, "")
354        .to_string();
355    let masked = inline_code_regex()
356        .replace_all(&after_double, "")
357        .to_string();
358
359    check_bracket_balance(&masked, path)?;
360
361    let link_re = wiki_link_regex();
362    for cap in link_re.captures_iter(&masked) {
363        let inner = &cap[1];
364        // Structural refusals fire first so their message stays
365        // specific. Slug-form grammar then routes through the same
366        // `wiki_link_to_id` that the create/update mutation pipeline
367        // calls — install-path and create-path refuse the same inputs
368        // by construction.
369        if inner.is_empty() {
370            return Err(ValidationError::InvalidWikiLink {
371                path: path.to_string(),
372                link: format!("[[{inner}]]"),
373                reason: "empty target".to_string(),
374            });
375        }
376        if inner.contains("::") {
377            return Err(ValidationError::InvalidWikiLink {
378                path: path.to_string(),
379                link: format!("[[{inner}]]"),
380                reason: "reserved `::` cross-mem syntax is not accepted".to_string(),
381            });
382        }
383        let target = match inner.find('|') {
384            Some(i) => &inner[..i],
385            None => inner,
386        };
387        if target.contains('#') {
388            return Err(ValidationError::InvalidWikiLink {
389                path: path.to_string(),
390                link: format!("[[{inner}]]"),
391                reason: "reserved `#` deep-link syntax is not accepted".to_string(),
392            });
393        }
394
395        // Delegate the slug / mem grammar checks to the shared
396        // strict resolver. The validator passes an empty current
397        // mem — the strict resolver's self-prefix-strip step is
398        // skipped (it's a Tier-1 convenience; the grammar gate fires
399        // before it), so the grammar outcome is mem-independent.
400        if let Err(e) = wiki_link_to_id(inner, "") {
401            return Err(ValidationError::InvalidWikiLink {
402                path: path.to_string(),
403                link: format!("[[{inner}]]"),
404                reason: e.to_string(),
405            });
406        }
407    }
408    Ok(())
409}
410
411fn check_bracket_balance(masked: &str, path: &str) -> Result<(), ValidationError> {
412    let bytes = masked.as_bytes();
413    let mut i = 0;
414    let mut open = 0usize;
415    while i + 1 < bytes.len() {
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        if bytes[i] == b']' && bytes[i + 1] == b']' {
427            if open == 0 {
428                return Err(ValidationError::UnbalancedBrackets {
429                    path: path.to_string(),
430                });
431            }
432            open -= 1;
433            i += 2;
434            continue;
435        }
436        i += 1;
437    }
438    if open > 0 {
439        return Err(ValidationError::UnbalancedBrackets {
440            path: path.to_string(),
441        });
442    }
443    Ok(())
444}
445
446fn wiki_link_regex() -> &'static Regex {
447    static RE: OnceLock<Regex> = OnceLock::new();
448    RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
449}
450
451fn inline_code_regex() -> &'static Regex {
452    static RE: OnceLock<Regex> = OnceLock::new();
453    RE.get_or_init(|| Regex::new(r"`[^`]+`").unwrap())
454}
455
456fn inline_double_backtick_regex() -> &'static Regex {
457    static RE: OnceLock<Regex> = OnceLock::new();
458    RE.get_or_init(|| Regex::new(r"``[\s\S]*?``").unwrap())
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::entity::parser::parse_markdown;
465    use memstead_schema::type_by_name;
466
467    fn spec_type() -> std::sync::Arc<memstead_schema::TypeDefinition> {
468        type_by_name("spec").unwrap()
469    }
470
471    fn parse(content: &str) -> Entity {
472        parse_markdown(content, "test.md", &spec_type(), "v")
473            .unwrap()
474            .entity
475    }
476
477    fn validate(content: &str, entity: &Entity) -> Result<(), ValidationError> {
478        validate_strict(content, entity, &spec_type(), "test.md")
479    }
480
481    const MINIMAL_SPEC: &str = "\
482---
483type: spec
484created_date: 2026-01-15
485last_modified: 2026-01-15
486level: M0
487---
488# Test Entity
489
490## Identity
491
492A meaningful identity line.
493
494## Purpose
495
496Why it exists.
497
498## Specifies
499
500What it covers.
501
502## Constraints
503
504Its limits.
505
506## Rationale
507
508Design notes.
509";
510
511    #[test]
512    fn accepts_valid_spec() {
513        let entity = parse(MINIMAL_SPEC);
514        validate(MINIMAL_SPEC, &entity).unwrap();
515    }
516
517    #[test]
518    fn rejects_missing_frontmatter() {
519        let content = "# No Frontmatter\n\n## Identity\nBody.\n";
520        let entity = parse(&format!("---\ntype: spec\n---\n{content}"));
521        let err = validate(content, &entity).unwrap_err();
522        assert!(matches!(err, ValidationError::MissingFrontmatter { .. }));
523    }
524
525    #[test]
526    fn rejects_unclosed_frontmatter() {
527        let content = "---\ntype: spec\n# stuck in frontmatter\n";
528        let entity = parse(MINIMAL_SPEC); // entity parses fine; we still reject raw
529        let err = validate(content, &entity).unwrap_err();
530        assert!(matches!(err, ValidationError::InvalidFrontmatter { .. }));
531    }
532
533    #[test]
534    fn rejects_unknown_frontmatter_key() {
535        let content = MINIMAL_SPEC.replacen("level: M0", "level: M0\nunexpected_key: oops", 1);
536        let entity = parse(&content);
537        let err = validate(&content, &entity).unwrap_err();
538        match err {
539            ValidationError::UnknownFrontmatterKey { key, .. } => {
540                assert_eq!(key, "unexpected_key");
541            }
542            other => panic!("expected UnknownFrontmatterKey, got {other:?}"),
543        }
544    }
545
546    #[test]
547    fn rejects_missing_required_field() {
548        let content = MINIMAL_SPEC.replacen("level: M0\n", "", 1);
549        let entity = parse(&content);
550        let err = validate(&content, &entity).unwrap_err();
551        assert!(matches!(err, ValidationError::MissingRequiredField { .. }));
552    }
553
554    #[test]
555    fn rejects_missing_title() {
556        let content = MINIMAL_SPEC.replacen("# Test Entity\n", "\n", 1);
557        let entity = parse(&content);
558        let err = validate(&content, &entity).unwrap_err();
559        assert!(matches!(err, ValidationError::MissingTitle { .. }));
560    }
561
562    #[test]
563    fn rejects_missing_required_section() {
564        let content = MINIMAL_SPEC.replacen("## Purpose\n\nWhy it exists.\n\n", "", 1);
565        let entity = parse(&content);
566        let err = validate(&content, &entity).unwrap_err();
567        assert!(matches!(
568            err,
569            ValidationError::MissingRequiredSection { .. }
570        ));
571    }
572
573    // Note: every shipping schema today declares exactly one catch_all
574    // section (pinned by schemas::tests::every_schema_has_exactly_one_catch_all),
575    // so `check_unknown_sections` cannot fire for the 10 registered
576    // schemas. The check stays as defense-in-depth for hypothetical
577    // future no-catch-all schemas; testing it would require a test-only
578    // TypeDefinition fixture, deferred.
579
580    #[test]
581    fn rejects_malformed_relationship_line() {
582        let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- USES: [[target]]\n");
583        let entity = parse(&content);
584        let err = validate(&content, &entity).unwrap_err();
585        assert!(matches!(
586            err,
587            ValidationError::InvalidRelationshipLine { .. }
588        ));
589    }
590
591    #[test]
592    fn accepts_valid_relationship_line() {
593        let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- **USES**: [[target-name]]\n");
594        let entity = parse(&content);
595        validate(&content, &entity).unwrap();
596    }
597
598    #[test]
599    fn rejects_invalid_wiki_link_uppercase() {
600        let content = format!("{MINIMAL_SPEC}\nSee [[MyThing]] for details.\n");
601        let entity = parse(&content);
602        let err = validate(&content, &entity).unwrap_err();
603        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
604    }
605
606    #[test]
607    fn rejects_invalid_wiki_link_underscore() {
608        let content = format!("{MINIMAL_SPEC}\nSee [[a_b]] for details.\n");
609        let entity = parse(&content);
610        let err = validate(&content, &entity).unwrap_err();
611        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
612    }
613
614    #[test]
615    fn rejects_invalid_wiki_link_space() {
616        let content = format!("{MINIMAL_SPEC}\nSee [[a b]] for details.\n");
617        let entity = parse(&content);
618        let err = validate(&content, &entity).unwrap_err();
619        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
620    }
621
622    #[test]
623    fn accepts_tier_two_cross_mem_link() {
624        let content = format!(
625            "{MINIMAL_SPEC}\nSee [[engine:health]] and [[engine:architecture/result]] for more.\n"
626        );
627        let entity = parse(&content);
628        validate(&content, &entity).unwrap();
629    }
630
631    /// Hierarchical mem paths are first-class. The install-side check
632    /// converges onto `wiki_link_to_id`, which already accepts
633    /// hierarchical Tier-2 prefixes — install no longer rejects what
634    /// create produces.
635    #[test]
636    fn accepts_hierarchical_tier_two_link() {
637        let content = format!("{MINIMAL_SPEC}\nSee [[external/engine:health]] for details.\n");
638        let entity = parse(&content);
639        validate(&content, &entity).unwrap();
640    }
641
642    #[test]
643    fn rejects_tier_two_with_empty_leaf() {
644        let content = format!("{MINIMAL_SPEC}\nSee [[:slug]] for details.\n");
645        let entity = parse(&content);
646        let err = validate(&content, &entity).unwrap_err();
647        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
648    }
649
650    #[test]
651    fn rejects_tier_two_with_empty_slug() {
652        let content = format!("{MINIMAL_SPEC}\nSee [[engine:]] for details.\n");
653        let entity = parse(&content);
654        let err = validate(&content, &entity).unwrap_err();
655        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
656    }
657
658    #[test]
659    fn rejects_tier_two_with_invalid_leaf_chars() {
660        let content = format!("{MINIMAL_SPEC}\nSee [[Engine:slug]] for details.\n");
661        let entity = parse(&content);
662        let err = validate(&content, &entity).unwrap_err();
663        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
664    }
665
666    #[test]
667    fn rejects_tier_two_with_invalid_slug_chars() {
668        let content = format!("{MINIMAL_SPEC}\nSee [[engine:Slug]] for details.\n");
669        let entity = parse(&content);
670        let err = validate(&content, &entity).unwrap_err();
671        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
672    }
673
674    #[test]
675    fn rejects_reserved_cross_mem_syntax() {
676        let content = format!("{MINIMAL_SPEC}\nSee [[other-mem::entity]] for details.\n");
677        let entity = parse(&content);
678        let err = validate(&content, &entity).unwrap_err();
679        match err {
680            ValidationError::InvalidWikiLink { reason, .. } => {
681                assert!(reason.contains("::"), "reason={reason}");
682            }
683            other => panic!("expected InvalidWikiLink, got {other:?}"),
684        }
685    }
686
687    #[test]
688    fn rejects_reserved_deep_link_syntax() {
689        let content = format!("{MINIMAL_SPEC}\nSee [[entity#section]]");
690        let entity = parse(&content);
691        let err = validate(&content, &entity).unwrap_err();
692        match err {
693            ValidationError::InvalidWikiLink { reason, .. } => {
694                assert!(reason.contains("#"), "reason={reason}");
695            }
696            other => panic!("expected InvalidWikiLink, got {other:?}"),
697        }
698    }
699
700    #[test]
701    fn rejects_empty_wiki_link() {
702        let content = format!("{MINIMAL_SPEC}\nSee [[]]");
703        let entity = parse(&content);
704        let err = validate(&content, &entity).unwrap_err();
705        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
706    }
707
708    #[test]
709    fn rejects_unbalanced_brackets() {
710        let content = format!("{MINIMAL_SPEC}\nSee [[unterminated for details.\n");
711        let entity = parse(&content);
712        let err = validate(&content, &entity).unwrap_err();
713        assert!(matches!(err, ValidationError::UnbalancedBrackets { .. }));
714    }
715
716    #[test]
717    fn accepts_valid_stub_wiki_link() {
718        let content = format!("{MINIMAL_SPEC}\nSee [[planned-feature]] and [[a/b/c]] for more.\n");
719        let entity = parse(&content);
720        validate(&content, &entity).unwrap();
721    }
722
723    #[test]
724    fn accepts_wiki_link_inside_inline_code() {
725        let content =
726            format!("{MINIMAL_SPEC}\nOne line per edge, shape `- **<REL>**: [[<target>]]`.\n");
727        let entity = parse(&content);
728        validate(&content, &entity).unwrap();
729    }
730
731    #[test]
732    fn accepts_literal_backtick_via_double_delimiter() {
733        // Real-world line from a macOS spec documenting the inline-markup
734        // tokenizer: mixes `` ` `` (double-delim showing a literal `) with
735        // `[[` inside single-delim backticks. Before the double-backtick
736        // pre-pass was added, the single-backtick regex sliced through the
737        // `` ` `` span and left a stray `[[` that tripped the bracket
738        // checker.
739        let content = format!(
740            "{MINIMAL_SPEC}\nWalks left-to-right looking for the earliest of `**`, `` ` ``, `[[`. Done.\n"
741        );
742        let entity = parse(&content);
743        validate(&content, &entity).unwrap();
744    }
745
746    #[test]
747    fn accepts_brackets_inside_double_backtick_span() {
748        // `` `text` `` — double-backtick delimiter wrapping content that
749        // itself contains single backticks. The stripped span may hold
750        // unbalanced brackets without leaking to the bracket checker.
751        let content = format!("{MINIMAL_SPEC}\n| Inline code | `` `[[slug]]` `` | note. |\n");
752        let entity = parse(&content);
753        validate(&content, &entity).unwrap();
754    }
755
756    #[test]
757    fn accepts_wiki_link_with_alias() {
758        let content = format!("{MINIMAL_SPEC}\nSee [[target|Display Text]] for more.\n");
759        let entity = parse(&content);
760        validate(&content, &entity).unwrap();
761    }
762
763    #[test]
764    fn accepts_wiki_link_with_parent_relative_and_md() {
765        let content = format!("{MINIMAL_SPEC}\nSee [[../parent/entity.md]] for more.\n");
766        let entity = parse(&content);
767        validate(&content, &entity).unwrap();
768    }
769
770    #[test]
771    fn accepts_wiki_link_inside_code_block() {
772        let content = format!("{MINIMAL_SPEC}\n```\nlet x = [[this is not a link]];\n```\n");
773        let entity = parse(&content);
774        validate(&content, &entity).unwrap();
775    }
776
777    #[test]
778    fn accepts_windows_line_endings() {
779        let content = MINIMAL_SPEC.replace('\n', "\r\n");
780        let entity = parse(&content);
781        validate(&content, &entity).unwrap();
782    }
783
784    #[test]
785    fn accepts_leading_bom() {
786        // The BOM is stripped by the archive extraction layer before
787        // parse_markdown sees the bytes — mirror that here. The strict
788        // checker also tolerates BOM on the raw pass (defense-in-depth).
789        let raw = format!("\u{feff}{MINIMAL_SPEC}");
790        let stripped = raw.strip_prefix('\u{feff}').unwrap();
791        let entity = parse(stripped);
792        validate(&raw, &entity).unwrap();
793    }
794}