Skip to main content

memstead_base/entity/
parser.rs

1//! Markdown → Entity parser. Handles YAML frontmatter, sections, wiki-links.
2//!
3//! Key design decisions:
4//! - Hand-rolled YAML frontmatter parser (NOT serde_yaml) to match JS type coercion
5//! - Code blocks are masked before section/link detection to prevent false matches
6//! - The parser is schema-aware: it uses the schema to determine catch-all sections
7
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10use std::sync::OnceLock;
11
12use indexmap::IndexMap;
13use regex::Regex;
14use sha2::{Digest, Sha256};
15
16use memstead_schema::TypeDefinition;
17
18use super::id::{WikiLinkError, file_path_to_id, wiki_link_to_id, wiki_link_to_id_lenient};
19use super::{Entity, EntityId, HeadingSpan, MetadataValue, ParseResult, Relationship};
20
21/// Parse a markdown string into an Entity.
22pub fn parse_markdown(
23    content: &str,
24    relative_path: &str,
25    schema: &TypeDefinition,
26    mem: &str,
27) -> Result<ParseResult, ParseError> {
28    let id = file_path_to_id(relative_path, mem);
29
30    // Compute content hash from raw markdown
31    let content_hash = compute_hash(content);
32
33    // Extract YAML frontmatter FIRST — frontmatter is not markdown, and
34    // handing it to a CommonMark parser invents block structure that is
35    // not there (a value line that reads as a fence opener would open a
36    // code block running past the closing `---` and mask the whole
37    // body). Only the body is markdown, so only the body is masked.
38    let (metadata, body) = split_frontmatter(content)?;
39    let masked_body = mask_code_blocks(&body);
40
41    // Extract title (first # heading)
42    let title = extract_title(&body, &masked_body).unwrap_or_else(|| id.name().to_string());
43
44    // Split body into ## sections (match against masked, slice from original).
45    // Duplicate `## Heading` lines whose slug matches a schema-declared key
46    // become `DuplicateSectionHeading` warnings below; first-wins is the
47    // resolution policy.
48    let (sections_map, duplicate_headings, raw_section_headings) =
49        split_sections(&body, &masked_body);
50
51    // Parse typed relationships from the Relationships section.
52    // The entity-id collector lets the parser surface
53    // `AMBIGUOUS_DESCRIPTION_DELIMITER` warnings against a concrete
54    // source so boot / reload / attach sites can report them in
55    // `LoadCollector::warnings`.
56    let rel_heading_key = "relationships";
57    let entity_id_for_rel_warnings = file_path_to_id(relative_path, mem);
58    let (relationships, rel_parse_warnings) = parse_relationships_with_warnings(
59        sections_map
60            .get(rel_heading_key)
61            .map(|s| s.as_str())
62            .unwrap_or(""),
63        mem,
64        Some(&entity_id_for_rel_warnings),
65    );
66
67    // Build catch-all section content
68    let catch_all_content = build_catch_all(&sections_map, schema);
69
70    // Extract schema-defined section values.
71    // IndexMap + this loop order is what guarantees sections iterate in the
72    // schema's declared order downstream. Do not change to a HashMap.
73    let mut result_sections = IndexMap::new();
74    for s in &schema.sections {
75        if s.catch_all {
76            result_sections.insert(s.key.clone(), catch_all_content.trim().to_string());
77        } else {
78            let val = sections_map
79                .get(s.key.as_str())
80                .map(|v| v.trim().to_string())
81                .unwrap_or_default();
82            result_sections.insert(s.key.clone(), val);
83        }
84    }
85
86    // Parse metadata values with type coercion
87    let mut parsed_metadata = parse_metadata(&metadata);
88
89    // Determine type from metadata or default, and ensure it's in metadata.
90    // The entity's `type:` frontmatter key takes precedence over the mem's
91    // default type — parse-time resolution means each file is authoritative
92    // about its own type.
93    let type_name = parsed_metadata
94        .get("type")
95        .and_then(|v| v.as_str())
96        .unwrap_or(schema.name.as_str())
97        .to_string();
98    parsed_metadata.insert("type".to_string(), MetadataValue::String(type_name.clone()));
99
100    // Extract inline wiki-links from text fields (excluding relationships section)
101    let inline_link_text: String = schema
102        .text_fields
103        .iter()
104        .filter_map(|f| result_sections.get(f.as_str()))
105        .cloned()
106        .collect::<Vec<_>>()
107        .join("\n");
108    // Read-time scan: tolerate pre-strict on-disk drift so loaders
109    // and dangling-link reporters keep working against legacy
110    // entities. The mutation pipeline re-extracts strictly via
111    // `extract_inline_links` and refuses on grammar violations.
112    let inline_links = extract_inline_links_lenient(&inline_link_text, mem);
113
114    // Filter out targets already covered by explicit relationships
115    let explicit_targets: HashSet<_> = relationships.iter().map(|r| &r.target).collect();
116    let inline_links: Vec<EntityId> = inline_links
117        .into_iter()
118        .filter(|link| !explicit_targets.contains(link))
119        .collect();
120
121    // Extract H3–H6 spans per section for search-time heading-path attribution.
122    // Side-struct only: regenerated every parse, never persisted.
123    let heading_spans = extract_heading_spans(&result_sections);
124
125    // Build warnings for duplicate-heading occurrences whose slug matches a
126    // schema-declared key. Catch-all keys (`s.catch_all`) absorb arbitrary
127    // headings by design, so duplicates there are not surfaced.
128    let declared_keys: HashSet<&str> = schema
129        .sections
130        .iter()
131        .filter(|s| !s.catch_all)
132        .map(|s| s.key.as_str())
133        .collect();
134    let entity_id_for_warnings = file_path_to_id(relative_path, mem);
135    let mut parse_warnings: Vec<crate::ops::WarningHint> = duplicate_headings
136        .into_iter()
137        .filter(|d| declared_keys.contains(d.key.as_str()))
138        .map(|d| crate::ops::WarningHint::DuplicateSectionHeading {
139            entity_id: entity_id_for_warnings.clone(),
140            section_key: d.key,
141            heading: d.heading,
142            occurrences: d.occurrences,
143        })
144        .collect();
145    parse_warnings.extend(rel_parse_warnings);
146
147    let entity = Entity {
148        id,
149        title,
150        entity_type: type_name,
151        mem: mem.to_string(),
152        file_path: relative_path.to_string(),
153        metadata: parsed_metadata,
154        sections: result_sections,
155        relationships,
156        content_hash,
157        stub: false,
158        stub_kind: None,
159        heading_spans,
160        raw_section_headings,
161    };
162
163    Ok(ParseResult {
164        entity,
165        inline_links,
166        parse_warnings,
167    })
168}
169
170/// Parse an entity from a file on disk.
171pub fn parse_file(
172    path: &Path,
173    mem_dir: &Path,
174    schema: &TypeDefinition,
175    mem: &str,
176) -> Result<ParseResult, ParseError> {
177    let content = std::fs::read_to_string(path)?;
178    let relative_path = path.strip_prefix(mem_dir).unwrap_or(path).to_string_lossy();
179    parse_markdown(&content, &relative_path, schema, mem)
180}
181
182// ---------------------------------------------------------------------------
183// Frontmatter
184// ---------------------------------------------------------------------------
185
186/// Extract the `type:` value from frontmatter without running the full parser.
187///
188/// Used by the loader to resolve each file's type independently — the mem
189/// config's default type is only a fallback for files that don't declare one.
190/// Returns None if there's no frontmatter, no `type:` line, or it's empty.
191pub fn peek_type_from_frontmatter(content: &str) -> Option<String> {
192    let content = strip_bom(content);
193    let after_open = if content.starts_with("---\r\n") {
194        5
195    } else if content.starts_with("---\n") {
196        4
197    } else {
198        return None;
199    };
200
201    let close_pos = content[after_open..].find("\n---")?;
202    let frontmatter = &content[after_open..after_open + close_pos];
203
204    for line in frontmatter.lines() {
205        let trimmed = line.trim();
206        if trimmed.is_empty() || trimmed.starts_with('#') {
207            continue;
208        }
209        let Some(colon_idx) = trimmed.find(':') else {
210            continue;
211        };
212        let key = trimmed[..colon_idx].trim();
213        if key != "type" {
214            continue;
215        }
216        let mut value = trimmed[colon_idx + 1..].trim();
217        if let Some(hash_idx) = value.find('#') {
218            value = value[..hash_idx].trim();
219        }
220        let value = value.trim_matches(|c| c == '"' || c == '\'');
221        if value.is_empty() {
222            return None;
223        }
224        return Some(value.to_string());
225    }
226    None
227}
228
229/// Peek the entity title (first `# ` heading in the body) and type
230/// (`type:` frontmatter field) from raw markdown without running the
231/// full schema-aware parser. Used by surfaces that read a markdown blob
232/// outside the in-memory store — e.g. `memstead_diff` walking git trees
233/// between two arbitrary refs, where the store snapshot (current HEAD)
234/// is not a valid source for a non-HEAD ref. Returns `None` for `title`
235/// when the body carries no `# ` heading and `None` for `entity_type`
236/// when the frontmatter lacks a non-empty `type:`.
237pub fn peek_title_and_type(content: &str) -> (Option<String>, Option<String>) {
238    let entity_type = peek_type_from_frontmatter(content);
239    let body = body_after_frontmatter(content);
240    let title = extract_title(body, &mask_code_blocks(body));
241    (title, entity_type)
242}
243
244/// Return the body slice after a leading `---` frontmatter block, or
245/// the whole input when no frontmatter is present. Mirrors the offset
246/// arithmetic in [`split_frontmatter`] but borrows rather than
247/// allocating — a scan needs to read, not own.
248///
249/// **Call this before handing a whole entity file to any markdown
250/// reader in this module.** Frontmatter is not markdown: a CommonMark
251/// parser reads block structure into it that is not there, and a YAML
252/// value that looks like a fence opener (legal at 1–3 spaces) opens a
253/// code block that runs past the `---` terminator to end of file,
254/// masking the entire body. Every reader here — [`mask_code_blocks`],
255/// [`extract_inline_links`], [`extract_inline_links_lenient`],
256/// [`split_sections`] — expects a body, and the callers inside the
257/// engine that hold one already pass section bodies. A caller holding
258/// a raw file or git blob does not, and must trim it here first.
259pub fn body_after_frontmatter(content: &str) -> &str {
260    let content = strip_bom(content);
261    let after_open = if content.starts_with("---\r\n") {
262        5
263    } else if content.starts_with("---\n") {
264        4
265    } else {
266        return content;
267    };
268    let Some(close_pos) = content[after_open..].find("\n---") else {
269        return content;
270    };
271    let body_start = after_open + close_pos + 4; // past "\n---"
272    let rest = &content[body_start..];
273    rest.strip_prefix("\r\n")
274        .or_else(|| rest.strip_prefix('\n'))
275        .unwrap_or(rest)
276}
277
278/// Strip a leading UTF-8 BOM. The strict validator and the archive
279/// extraction layer both strip it before their frontmatter split; the
280/// tolerant family must land on the same boundary for the same document
281/// — before this, a BOM'd local file silently parsed as all-body and
282/// lost its entire frontmatter (the archive path was unaffected).
283fn strip_bom(s: &str) -> &str {
284    s.strip_prefix('\u{feff}').unwrap_or(s)
285}
286
287/// Split content into frontmatter metadata string and body.
288/// Returns (metadata_string, body). Both boundaries are found in the
289/// raw content — the caller masks the body afterwards.
290pub(crate) fn split_frontmatter(content: &str) -> Result<(String, String), ParseError> {
291    let content = strip_bom(content);
292    // Look for YAML frontmatter: ---\n...\n---
293    if content.starts_with("---\n") || content.starts_with("---\r\n") {
294        let after_open = if content.starts_with("---\r\n") { 5 } else { 4 };
295        // Find closing ---
296        if let Some(close_pos) = content[after_open..].find("\n---") {
297            let meta_end = after_open + close_pos;
298            let metadata = content[after_open..meta_end].to_string();
299            // Body starts after the closing --- and its newline
300            let body_start = meta_end + 4; // "\n---"
301            let body_start = if content[body_start..].starts_with('\n') {
302                body_start + 1
303            } else if content[body_start..].starts_with("\r\n") {
304                body_start + 2
305            } else {
306                body_start
307            };
308            let body = content[body_start..].to_string();
309            return Ok((metadata, body));
310        }
311    }
312
313    // No frontmatter found — entire content is body
314    Ok((String::new(), content.to_string()))
315}
316
317/// Parse metadata key-value pairs with JS-compatible type coercion.
318///
319/// Handles: strings, integers, floats, booleans.
320/// Strips inline comments (`value # comment`) and quotes (`"value"`).
321fn parse_metadata(text: &str) -> IndexMap<String, MetadataValue> {
322    let mut meta = IndexMap::new();
323    if text.is_empty() {
324        return meta;
325    }
326
327    for line in text.lines() {
328        let trimmed = line.trim();
329        // Skip empty lines, comments, heading markers, delimiters
330        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("---") {
331            continue;
332        }
333
334        let Some(colon_idx) = trimmed.find(':') else {
335            continue;
336        };
337
338        let key = trimmed[..colon_idx].trim().to_string();
339        let raw_value = trimmed[colon_idx + 1..].trim();
340
341        // Strip inline comments (# not inside the value)
342        let value = strip_inline_comment(raw_value).trim().to_string();
343
344        if value.is_empty() {
345            meta.insert(key, MetadataValue::String(String::new()));
346            continue;
347        }
348
349        // Type coercion (matching JS parser behavior exactly)
350        if value == "true" {
351            meta.insert(key, MetadataValue::Bool(true));
352        } else if value == "false" {
353            meta.insert(key, MetadataValue::Bool(false));
354        } else if is_float_literal(&value) {
355            if let Ok(f) = value.parse::<f64>() {
356                meta.insert(key, MetadataValue::Float(f));
357            } else {
358                meta.insert(key, MetadataValue::String(strip_quotes(&value)));
359            }
360        } else if is_integer_literal(&value) {
361            if let Ok(n) = value.parse::<i64>() {
362                meta.insert(key, MetadataValue::Integer(n));
363            } else {
364                meta.insert(key, MetadataValue::String(strip_quotes(&value)));
365            }
366        } else {
367            meta.insert(key, MetadataValue::String(strip_quotes(&value)));
368        }
369    }
370
371    meta
372}
373
374/// Check if a string matches the JS float regex: /^-?\d+\.\d+$/
375fn is_float_literal(s: &str) -> bool {
376    let s = s.strip_prefix('-').unwrap_or(s);
377    if let Some((before, after)) = s.split_once('.') {
378        !before.is_empty()
379            && before.chars().all(|c| c.is_ascii_digit())
380            && !after.is_empty()
381            && after.chars().all(|c| c.is_ascii_digit())
382    } else {
383        false
384    }
385}
386
387/// Check if a string matches the JS integer regex: /^-?\d+$/
388fn is_integer_literal(s: &str) -> bool {
389    let s = s.strip_prefix('-').unwrap_or(s);
390    !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
391}
392
393/// Would `parse_metadata` coerce this raw value away from
394/// `MetadataValue::String`? Exposed so the generator can decide whether
395/// to YAML-quote a string value that would otherwise round-trip as
396/// Integer / Float / Bool. Kept co-located with the coercion rules so
397/// the two cannot drift.
398pub(crate) fn would_coerce_from_string(s: &str) -> bool {
399    s == "true" || s == "false" || is_integer_literal(s) || is_float_literal(s)
400}
401
402/// Strip inline comments: `value # comment` → `value`.
403fn strip_inline_comment(s: &str) -> &str {
404    // Find ` #` pattern (space followed by #)
405    // But be careful not to strip inside quoted strings
406    if let Some(idx) = s.find(" #") {
407        s[..idx].trim_end()
408    } else {
409        s
410    }
411}
412
413/// Strip surrounding quotes: `"value"` or `'value'` → `value`.
414/// A lone quote character is not a quoted value — `len >= 2` keeps the
415/// slice in bounds (a 1-char `"` satisfies both starts_with and ends_with).
416fn strip_quotes(s: &str) -> String {
417    if s.len() >= 2
418        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
419    {
420        s[1..s.len() - 1].to_string()
421    } else {
422        s.to_string()
423    }
424}
425
426// ---------------------------------------------------------------------------
427// Code block masking
428// ---------------------------------------------------------------------------
429
430/// Mask every CommonMark code block by replacing its bytes with spaces
431/// (preserves line count and byte offsets). Handles unclosed blocks
432/// safely — they mask to end of text.
433///
434/// The definition lives in [`crate::markdown`] — one referee for every
435/// content reader in the engine. Re-exported here because this module's
436/// callers are the historical ones.
437pub use crate::markdown::{mask_code_blocks, mask_code_blocks_and_spans};
438
439// ---------------------------------------------------------------------------
440// Merge-conflict detection
441// ---------------------------------------------------------------------------
442
443/// True when `text` — a whole entity file — carries a complete git
444/// merge-conflict block: an ordered `<<<<<<< …` / `=======` /
445/// `>>>>>>> …` triple at line starts.
446///
447/// The frontmatter is scanned **raw** and the body over
448/// [`mask_code_blocks`] output. Both halves of that split are
449/// load-bearing:
450///
451/// - Masking the body is why a code example documenting conflict
452///   markers never trips the check. It is a legibility trade-off, not
453///   a soundness one: a real conflict whose markers all fall inside
454///   one code block goes undetected (the file then loads/degrades
455///   exactly as it did before this check existed) — git writes markers
456///   without regard for fences, so that shape is rare, while marker
457///   examples in documentation entities are not.
458/// - Frontmatter is **not** masked, because frontmatter is not
459///   markdown. Handing it to a CommonMark parser invents block
460///   structure that is not there: a YAML value that reads as a fence
461///   opener (legal at 1–3 spaces) opens a code block that runs past
462///   the `---` terminator to end of file and blanks the entire body,
463///   markers and all — a conflicted file would then load with both
464///   sides fused into one entity, which is precisely the outcome
465///   `entity::loader`'s caller exists to prevent. Git also writes
466///   conflict markers into frontmatter, so scanning it is not merely
467///   safe, it is required.
468pub fn has_merge_conflict_markers(text: &str) -> bool {
469    // One view, not two scans: a conflict can straddle the `---`
470    // terminator (git writes markers wherever the hunks fall), so the
471    // raw frontmatter and the masked body are rejoined and scanned as
472    // a single text. Masking preserves byte length, so the join is the
473    // original file with only body code blocks blanked.
474    let body = body_after_frontmatter(text);
475    let frontmatter = &text[..text.len() - body.len()];
476    let view = format!("{frontmatter}{}", mask_code_blocks(body));
477
478    let mut seen_start = false;
479    let mut seen_separator = false;
480    for line in view.lines() {
481        if line.starts_with("<<<<<<< ") {
482            seen_start = true;
483            seen_separator = false;
484        } else if seen_start && line.trim_end() == "=======" {
485            seen_separator = true;
486        } else if seen_separator && line.starts_with(">>>>>>> ") {
487            return true;
488        }
489    }
490    false
491}
492
493// ---------------------------------------------------------------------------
494// Section splitting
495// ---------------------------------------------------------------------------
496
497/// Tracks one schema-declared section key seen more than once on parse.
498/// `key` is the slugified storage key (e.g. `realization`); `heading` is
499/// the original literal text from the first occurrence (e.g. `Realization`).
500/// `occurrences` counts every header line for that key — first plus
501/// duplicates.
502pub(crate) struct DuplicateSection {
503    pub key: String,
504    pub heading: String,
505    pub occurrences: usize,
506}
507
508/// Split body into named sections. Returns `Map<lowercase_key, content>`
509/// plus a list of duplicate-heading occurrences. Duplicate headings keep
510/// the first occurrence's body; subsequent occurrences are dropped from
511/// the storage value entirely (no embedded `## Heading` separator). The
512/// caller decides whether each duplicate becomes a `WarningHint`
513/// (schema-declared keys only — catch-all repetition stays silent).
514/// The third element is every literal heading text in document order
515/// (duplicates included) — the raw material for the health check that
516/// distinguishes "section absent" from "content under a non-deriving
517/// heading".
518pub(crate) fn split_sections(
519    body: &str,
520    masked_body: &str,
521) -> (IndexMap<String, String>, Vec<DuplicateSection>, Vec<String>) {
522    // IndexMap, not HashMap: the catch-all builder re-emits non-schema
523    // sections in this map's iteration order, so the order must be the
524    // document's — hash-random order made canonical bytes unstable
525    // across parses whenever more than one non-schema section coexisted
526    // (reachable on the tolerant local-read path, which refuses nothing).
527    let mut sections = IndexMap::new();
528    let mut duplicates: HashMap<String, DuplicateSection> = HashMap::new();
529    let mut raw_headings = Vec::new();
530    static SECTION_RE: OnceLock<Regex> = OnceLock::new();
531    let section_re = SECTION_RE.get_or_init(|| Regex::new(r"(?m)^## (.+)$").unwrap());
532
533    let matches: Vec<_> = section_re.find_iter(masked_body).collect();
534
535    for (i, m) in matches.iter().enumerate() {
536        // Extract heading name from original body (not masked)
537        let heading_line = &body[m.start()..m.end()];
538        let name = heading_line
539            .strip_prefix("## ")
540            .unwrap_or(heading_line)
541            .trim();
542
543        let content_start = m.end();
544        let content_end = if i + 1 < matches.len() {
545            matches[i + 1].start()
546        } else {
547            body.len()
548        };
549        let content = body[content_start..content_end].trim().to_string();
550        // Schema section keys are underscore-separated (e.g. `current_state`).
551        // A heading like `## Current State` must derive to the same form so
552        // schema-declared sections land in `result_sections` under the right
553        // key instead of falling through to catch-all — which would break
554        // canonical byte-stability for any multi-word section. The derivation
555        // is shared with the schema loader's round-trip check — never inline
556        // a second copy here.
557        let key = memstead_schema::derive_section_key(name);
558        raw_headings.push(name.to_string());
559
560        match sections.entry(key.clone()) {
561            indexmap::map::Entry::Vacant(slot) => {
562                slot.insert(content);
563                duplicates.insert(
564                    key.clone(),
565                    DuplicateSection {
566                        key: key.clone(),
567                        heading: name.to_string(),
568                        occurrences: 1,
569                    },
570                );
571            }
572            indexmap::map::Entry::Occupied(_) => {
573                // First-wins: drop this duplicate's body entirely. Bump the
574                // occurrence count for the warning emitted by the caller.
575                if let Some(d) = duplicates.get_mut(&key) {
576                    d.occurrences += 1;
577                }
578            }
579        }
580    }
581
582    let dup_list: Vec<DuplicateSection> = duplicates
583        .into_values()
584        .filter(|d| d.occurrences > 1)
585        .collect();
586
587    (sections, dup_list, raw_headings)
588}
589
590/// Extract the title from the first `# ` heading.
591///
592/// Scans the masked body so a `# ` line inside a code block can never
593/// become the entity title, and reads the text back from the original —
594/// masking preserves byte offsets and line count, so the two line
595/// sequences correspond one-to-one.
596fn extract_title(body: &str, masked_body: &str) -> Option<String> {
597    for (line, masked) in body.lines().zip(masked_body.lines()) {
598        if masked.starts_with("# ") {
599            return Some(line[2..].trim().to_string());
600        }
601    }
602    None
603}
604
605// ---------------------------------------------------------------------------
606// Heading spans (H3–H6)
607// ---------------------------------------------------------------------------
608
609/// Extract H3–H6 heading spans from each section's content. Byte offsets are
610/// into the (trimmed) section string stored in `result_sections`. Code blocks
611/// are masked before scanning so `### foo` inside any code block is ignored.
612///
613/// End offsets use a level-aware closing rule: a span closes at the next
614/// heading with the same or lower level (H3 closes on next H3 or H2 — but
615/// H2 doesn't appear here since sections are already split), otherwise at
616/// the end of the section. Level skips (H2 → H4 without H3) are tolerated:
617/// the H4 span is recorded flat, and query-time path resolution uses offset
618/// containment to reconstruct ancestry.
619fn extract_heading_spans(sections: &IndexMap<String, String>) -> HashMap<String, Vec<HeadingSpan>> {
620    // Compiled once per process; shape-constrained so it can't fail at runtime.
621    static RE: OnceLock<Regex> = OnceLock::new();
622    let re = RE.get_or_init(|| Regex::new(r"(?m)^(#{3,6})[ \t]+(.+)$").unwrap());
623    let mut out: HashMap<String, Vec<HeadingSpan>> = HashMap::new();
624
625    for (key, content) in sections {
626        if content.is_empty() {
627            continue;
628        }
629        let masked = mask_code_blocks(content);
630
631        // Collect (start_offset, level, title) in document order.
632        let raw: Vec<(usize, u8, String)> = re
633            .captures_iter(&masked)
634            .map(|cap| {
635                let whole = cap.get(0).unwrap();
636                let level = cap[1].len() as u8; // 3..=6
637                // Read the title from the original (unmasked) content so the
638                // captured text survives code-block masking's space-padding.
639                let line_end = content[whole.start()..]
640                    .find('\n')
641                    .map(|i| whole.start() + i)
642                    .unwrap_or(content.len());
643                let hashes_end = whole.start() + level as usize;
644                let title = content[hashes_end..line_end].trim().to_string();
645                (whole.start(), level, title)
646            })
647            .collect();
648
649        if raw.is_empty() {
650            continue;
651        }
652
653        let mut spans: Vec<HeadingSpan> = Vec::with_capacity(raw.len());
654        for (i, &(start, level, ref title)) in raw.iter().enumerate() {
655            // Scan forward for the next heading with level <= this one.
656            let end = raw[i + 1..]
657                .iter()
658                .find(|(_, l, _)| *l <= level)
659                .map(|(s, _, _)| *s)
660                .unwrap_or(content.len());
661            spans.push(HeadingSpan {
662                level,
663                title: title.clone(),
664                start_offset: start,
665                end_offset: end,
666            });
667        }
668        out.insert(key.clone(), spans);
669    }
670
671    out
672}
673
674// ---------------------------------------------------------------------------
675// Catch-all section
676// ---------------------------------------------------------------------------
677
678/// Build catch-all section content from its own section + non-schema sections.
679fn build_catch_all(sections: &IndexMap<String, String>, schema: &TypeDefinition) -> String {
680    let catch_all = match schema.catch_all_section() {
681        Some(s) => s,
682        None => return String::new(),
683    };
684
685    let known_sections: HashSet<&str> = schema
686        .sections
687        .iter()
688        .map(|s| s.key.as_str())
689        .chain(std::iter::once("relationships"))
690        .collect();
691
692    let mut parts = Vec::new();
693
694    // First, add the explicit catch-all section content
695    if let Some(content) = sections.get(catch_all.key.as_str())
696        && !content.is_empty()
697    {
698        parts.push(content.clone());
699    }
700
701    // Then add all non-schema sections (with headings reconstructed),
702    // in document order — `sections` is an IndexMap for exactly this
703    // loop: with more than one non-schema section (reachable on the
704    // tolerant local-read path, which refuses nothing) a hash-random
705    // order made the reconstructed catch-all differ from parse to parse.
706    for (key, content) in sections {
707        if !known_sections.contains(key.as_str()) && !content.is_empty() {
708            let heading = format!(
709                "## {}{}",
710                key.chars().next().unwrap_or_default().to_uppercase(),
711                &key[key.chars().next().map_or(0, |c| c.len_utf8())..]
712            );
713            parts.push(format!("{heading}\n{content}"));
714        }
715    }
716
717    parts.join("\n\n")
718}
719
720// ---------------------------------------------------------------------------
721// Relationships
722// ---------------------------------------------------------------------------
723
724/// Parse typed relationships from the Relationships section.
725///
726/// Recognises two row shapes:
727/// - simple: `- **TYPE**: [[target]]` → `description: None`
728/// - em-dash: `- **TYPE**: [[target]] — text` → `description: Some(text)`
729///
730/// Returns the relations plus parse-time warnings flagging
731/// AMBIGUOUS-delimiter rows (`-- text`, `- text`, en-dash, minus). On
732/// AMBIGUOUS rows the description is dropped — the renderer will
733/// normalise the row to the simple form on next write.
734///
735/// Rows inside a code block are not relationships. The scan runs over
736/// the masked section body and reads every captured span from the
737/// original, so a fenced or indented example of the row syntax — the
738/// obvious thing to write in an entity documenting that syntax — no
739/// longer becomes a live edge and an auto-stub. Without the mask this
740/// path synthesised edges from links the strict validator cannot see
741/// (`validator::strict::check_wiki_links` masks), which is exactly the
742/// asymmetry the one-definition rule exists to prevent.
743pub(crate) fn parse_relationships_with_warnings(
744    text: &str,
745    mem: &str,
746    entity_id: Option<&EntityId>,
747) -> (Vec<Relationship>, Vec<crate::ops::WarningHint>) {
748    // Anchor on the canonical row prefix `- **TYPE**: [[<target>]]` and
749    // capture everything that follows on the same line so the trailing
750    // segment can be classified (simple, em-dash, or AMBIGUOUS).
751    static RE: OnceLock<Regex> = OnceLock::new();
752    let re = RE.get_or_init(|| {
753        Regex::new(r"(?m)^\s*-\s*\*\*(\w+)\*\*:\s*\[\[([^\]]+)\]\](?P<tail>[^\n]*)").unwrap()
754    });
755    let mut relationships = Vec::new();
756    let mut warnings = Vec::new();
757    // Blocks AND inline spans — the same mask every link scanner uses.
758    // A legitimate row's target can never sit inside a code span, so
759    // masking spans costs nothing and closes the seam: with a
760    // blocks-only mask a row inside a multi-line inline span stayed
761    // invisible to the validator and to every extractor while still
762    // building an edge and a stub. Masking preserves byte offsets, so a
763    // match found in the masked copy indexes the original exactly.
764    let masked = mask_code_blocks_and_spans(text);
765    for cap in re.captures_iter(&masked) {
766        let rel_type = text[cap.get(1).unwrap().range()].to_uppercase();
767        // Read-time parsing of the ## Relationships table tolerates
768        // pre-strict on-disk drift so legacy rows whose target fails
769        // the wiki-link grammar continue to round-trip. The mutation
770        // pipeline (`memstead_relate`, declare_relations) gates strictly
771        // via `validate_relation_target_grammar`.
772        let target = wiki_link_to_id_lenient(&text[cap.get(2).unwrap().range()], mem);
773        let tail = cap.name("tail").map(|m| &text[m.range()]).unwrap_or("");
774        let description = match classify_description_tail(tail) {
775            DescriptionTail::None => None,
776            DescriptionTail::EmDash(text) => Some(text),
777            DescriptionTail::Ambiguous(literal) => {
778                if let Some(id) = entity_id {
779                    warnings.push(crate::ops::WarningHint::AmbiguousDescriptionDelimiter {
780                        from: id.clone(),
781                        rel_type: rel_type.clone(),
782                        target: target.clone(),
783                        trailing: literal,
784                    });
785                }
786                None
787            }
788        };
789        relationships.push(Relationship {
790            rel_type,
791            target,
792            description,
793        });
794    }
795    (relationships, warnings)
796}
797
798/// Classification of the per-line tail that follows `]]` on a
799/// `## Relationships` row.
800enum DescriptionTail {
801    /// Tail is empty or whitespace-only.
802    None,
803    /// Tail begins with the canonical em-dash delimiter; carries the
804    /// captured description text (trimmed of trailing whitespace).
805    EmDash(String),
806    /// Tail starts with a non-canonical dash-like delimiter (`-`,
807    /// `--`, U+2013 en-dash, U+2212 minus). Carries the literal
808    /// trailing content so the warning surfaces what was dropped.
809    Ambiguous(String),
810}
811
812/// Inspect the post-`]]` tail of a `## Relationships` row and decide
813/// what shape it takes. The em-dash delimiter is the exact three-byte
814/// UTF-8 sequence of U+2014 framed by single ASCII spaces; everything
815/// else falls into [`DescriptionTail::None`] or
816/// [`DescriptionTail::Ambiguous`].
817fn classify_description_tail(tail: &str) -> DescriptionTail {
818    let trimmed_end = tail.trim_end();
819    if trimmed_end.is_empty() {
820        return DescriptionTail::None;
821    }
822    // Canonical: literal space + U+2014 + literal space + content.
823    if let Some(rest) = trimmed_end.strip_prefix(" \u{2014} ") {
824        if rest.is_empty() {
825            return DescriptionTail::None;
826        }
827        return DescriptionTail::EmDash(rest.to_string());
828    }
829    // U+2014 directly after `]]` (no leading space) is also ambiguous
830    // — the canonical form requires the framing space. Likewise an
831    // em-dash with no trailing content (` — `) collapses to None.
832    if let Some(rest) = trimmed_end.strip_prefix(" \u{2014}") {
833        // ` —` (no trailing space, but content followed) lands here.
834        return DescriptionTail::Ambiguous(format!(" \u{2014}{rest}"));
835    }
836    // Dash-likes: ASCII `--`, ASCII `-`, en-dash U+2013, minus U+2212.
837    let starters = [" --", " -", " \u{2013}", " \u{2212}"];
838    if starters
839        .iter()
840        .any(|prefix| trimmed_end.starts_with(prefix))
841    {
842        return DescriptionTail::Ambiguous(trimmed_end.to_string());
843    }
844    // Anything else after `]]` (e.g. inline comment, stray text) —
845    // classify as ambiguous so the operator sees that content was
846    // dropped rather than silently swallowed.
847    DescriptionTail::Ambiguous(trimmed_end.to_string())
848}
849
850// ---------------------------------------------------------------------------
851// Wiki-links
852// ---------------------------------------------------------------------------
853
854/// The `[[target]]` / `[[target|label]]` wiki-link pattern, compiled once.
855///
856/// The inner group is `*`, not `+`, so an empty target `[[]]` is *seen*
857/// by every path — the strict validator refuses it with a typed
858/// `InvalidWikiLink`, and this module's strict extractor routes it to
859/// the same refusal. A pattern that cannot see `[[]]` is how one path
860/// came to silently ignore what another path refused.
861fn wiki_link_re() -> &'static Regex {
862    static RE: OnceLock<Regex> = OnceLock::new();
863    RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
864}
865
866/// Extract unique mem-prefixed entity IDs from inline wiki-links,
867/// strictly validating each target against the slug-form grammar.
868/// Strips code blocks and inline code spans before scanning, by the
869/// one CommonMark definition ([`crate::markdown`]).
870///
871/// Returns the deduped valid ids on success, or every refusal in the
872/// scan window on failure (errors are collected, not fail-fast — the
873/// agent sees every malformed link in a single round-trip).
874///
875/// Mutation-pipeline callers (`synthesise_alias_relations`, etc.) use
876/// this strict variant and map [`WikiLinkError`] to the typed engine
877/// envelope with section context. Read-side scanners that must
878/// tolerate pre-strict on-disk drift use [`extract_inline_links_lenient`].
879pub(crate) fn extract_inline_links(
880    text: &str,
881    mem: &str,
882) -> Result<Vec<EntityId>, Vec<WikiLinkError>> {
883    let stripped = mask_code_blocks_and_spans(text);
884
885    let link_re = wiki_link_re();
886    let mut seen = HashSet::new();
887    let mut links = Vec::new();
888    let mut errors = Vec::new();
889
890    for cap in link_re.captures_iter(&stripped) {
891        match wiki_link_to_id(&cap[1], mem) {
892            Ok(id) => {
893                if errors.is_empty() && seen.insert(id.0.clone()) {
894                    links.push(id);
895                }
896            }
897            Err(e) => errors.push(e),
898        }
899    }
900
901    if errors.is_empty() {
902        Ok(links)
903    } else {
904        Err(errors)
905    }
906}
907
908/// Permissive sibling of [`extract_inline_links`] for read-side
909/// scanners. Decodes every `[[...]]` token via [`wiki_link_to_id_lenient`]
910/// so on-disk drift (legacy entities, archive-imports from pre-strict
911/// engines, partial-mutation rollbacks) keeps flowing through dangling-
912/// link reporters and graph inspectors. Mutation paths MUST NOT use this
913/// helper — see [`extract_inline_links`] for the strict variant.
914pub fn extract_inline_links_lenient(text: &str, mem: &str) -> Vec<EntityId> {
915    let stripped = mask_code_blocks_and_spans(text);
916
917    let link_re = wiki_link_re();
918    let mut seen = HashSet::new();
919    let mut links = Vec::new();
920
921    for cap in link_re.captures_iter(&stripped) {
922        // An empty target decodes to no id. The read side tolerates
923        // drift by ignoring what it cannot decode; the strict side
924        // refuses it (`extract_inline_links`). Both *see* it — that is
925        // the part that must not diverge.
926        if cap[1].is_empty() {
927            continue;
928        }
929        let id = wiki_link_to_id_lenient(&cap[1], mem);
930        if seen.insert(id.0.clone()) {
931            links.push(id);
932        }
933    }
934
935    links
936}
937
938// ---------------------------------------------------------------------------
939// Content hash
940// ---------------------------------------------------------------------------
941
942/// Compute SHA-256 hash of content, truncated to 16 hex characters.
943pub fn compute_hash(content: &str) -> String {
944    let mut hasher = Sha256::new();
945    hasher.update(content.as_bytes());
946    let result = hasher.finalize();
947    crate::hex_lower(&result)[..16].to_string()
948}
949
950// ---------------------------------------------------------------------------
951// Errors
952// ---------------------------------------------------------------------------
953
954#[derive(Debug, thiserror::Error)]
955pub enum ParseError {
956    #[error("missing frontmatter")]
957    MissingFrontmatter,
958    #[error("invalid frontmatter: {0}")]
959    InvalidFrontmatter(String),
960    #[error("missing title")]
961    MissingTitle,
962    #[error("io error: {0}")]
963    Io(#[from] std::io::Error),
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969    use memstead_schema::{builtin_names, type_by_name};
970    use std::sync::Arc;
971
972    fn spec_schema() -> Arc<TypeDefinition> {
973        type_by_name(builtin_names::SPEC).unwrap()
974    }
975
976    fn memo_schema() -> Arc<TypeDefinition> {
977        type_by_name(builtin_names::MEMO).unwrap()
978    }
979
980    #[test]
981    fn parse_metadata_types() {
982        let meta = parse_metadata("key: value\nnum: 42\nfloat: 0.85\nbool: true\nfalsy: false");
983        assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
984        assert_eq!(meta["num"], MetadataValue::Integer(42));
985        assert_eq!(meta["float"], MetadataValue::Float(0.85));
986        assert_eq!(meta["bool"], MetadataValue::Bool(true));
987        assert_eq!(meta["falsy"], MetadataValue::Bool(false));
988    }
989
990    #[test]
991    fn parse_metadata_strips_comments() {
992        let meta = parse_metadata("key: value # this is a comment");
993        assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
994    }
995
996    #[test]
997    fn parse_metadata_strips_quotes() {
998        let meta = parse_metadata("key: \"quoted value\"\nkey2: 'single'");
999        assert_eq!(
1000            meta["key"],
1001            MetadataValue::String("quoted value".to_string())
1002        );
1003        assert_eq!(meta["key2"], MetadataValue::String("single".to_string()));
1004    }
1005
1006    #[test]
1007    fn parse_metadata_survives_malformed_values() {
1008        // A lone quote character satisfies both starts_with and ends_with —
1009        // the old unguarded slice `s[1..s.len()-1]` panicked on it.
1010        let meta = parse_metadata(
1011            "key: \"\nkey2: '\nkey3: \"\"\nkey4: ''\nkey5: \"unterminated\nkey6: mixed'\"",
1012        );
1013        assert_eq!(meta["key"], MetadataValue::String("\"".to_string()));
1014        assert_eq!(meta["key2"], MetadataValue::String("'".to_string()));
1015        assert_eq!(meta["key3"], MetadataValue::String(String::new()));
1016        assert_eq!(meta["key4"], MetadataValue::String(String::new()));
1017        assert_eq!(
1018            meta["key5"],
1019            MetadataValue::String("\"unterminated".to_string())
1020        );
1021        assert_eq!(meta["key6"], MetadataValue::String("mixed'\"".to_string()));
1022
1023        // More frontmatter shapes that must parse to a value, never panic:
1024        // colon-only lines, multi-byte values, keyless colons, huge digits.
1025        let meta =
1026            parse_metadata(":\n: value\nkey7: ✓\"\nkey8: 99999999999999999999999999\nkey9: -");
1027        assert_eq!(meta["key7"], MetadataValue::String("✓\"".to_string()));
1028        assert_eq!(
1029            meta["key8"],
1030            MetadataValue::String("99999999999999999999999999".to_string())
1031        );
1032        assert_eq!(meta["key9"], MetadataValue::String("-".to_string()));
1033    }
1034
1035    #[test]
1036    fn parse_metadata_skips_comments_and_empty() {
1037        let meta = parse_metadata("# comment\n\nkey: val\n---");
1038        assert_eq!(meta.len(), 1);
1039        assert_eq!(meta["key"], MetadataValue::String("val".to_string()));
1040    }
1041
1042    #[test]
1043    fn peek_type_finds_value() {
1044        let content = "---\ntype: memo\ntitle: Test\n---\n# Body\n";
1045        assert_eq!(
1046            peek_type_from_frontmatter(content),
1047            Some("memo".to_string())
1048        );
1049    }
1050
1051    #[test]
1052    fn peek_type_returns_none_when_missing() {
1053        let content = "---\ntitle: Test\n---\n# Body\n";
1054        assert_eq!(peek_type_from_frontmatter(content), None);
1055    }
1056
1057    #[test]
1058    fn peek_type_returns_none_without_frontmatter() {
1059        let content = "# Just a heading\n\nBody with type: concept inside text.\n";
1060        assert_eq!(peek_type_from_frontmatter(content), None);
1061    }
1062
1063    #[test]
1064    fn peek_type_handles_windows_line_endings() {
1065        let content = "---\r\ntype: principle\r\n---\r\n# Body\r\n";
1066        assert_eq!(
1067            peek_type_from_frontmatter(content),
1068            Some("principle".to_string())
1069        );
1070    }
1071
1072    #[test]
1073    fn peek_type_strips_quotes_and_comments() {
1074        let quoted = "---\ntype: \"concept\"\n---\n";
1075        assert_eq!(
1076            peek_type_from_frontmatter(quoted),
1077            Some("concept".to_string())
1078        );
1079        let commented = "---\ntype: memo # kind of\n---\n";
1080        assert_eq!(
1081            peek_type_from_frontmatter(commented),
1082            Some("memo".to_string())
1083        );
1084    }
1085
1086    #[test]
1087    fn peek_type_empty_value_returns_none() {
1088        let content = "---\ntype:\n---\n";
1089        assert_eq!(peek_type_from_frontmatter(content), None);
1090    }
1091
1092    #[test]
1093    fn peek_type_ignores_legacy_schema_key() {
1094        // After the hard break, a bare `schema:` in frontmatter is not
1095        // recognized as the type key — it's just arbitrary metadata.
1096        let content = concat!("---\n", "schema", ": memo\n---\n");
1097        assert_eq!(peek_type_from_frontmatter(content), None);
1098    }
1099
1100    #[test]
1101    fn mask_code_blocks_basic() {
1102        let input = "before\n```\ncode [[link]]\n```\nafter";
1103        let masked = mask_code_blocks(input);
1104        assert!(!masked.contains("[[link]]"));
1105        assert!(masked.contains("before"));
1106        assert!(masked.contains("after"));
1107    }
1108
1109    #[test]
1110    fn mask_code_blocks_preserves_line_count() {
1111        let input = "line1\n```\ncode\nmore code\n```\nline6";
1112        let masked = mask_code_blocks(input);
1113        assert_eq!(input.lines().count(), masked.lines().count());
1114    }
1115
1116    #[test]
1117    fn mask_code_blocks_unclosed() {
1118        let input = "before\n```\ncode\nmore code";
1119        let masked = mask_code_blocks(input);
1120        assert!(masked.contains("before"));
1121        assert!(!masked.contains("code"));
1122    }
1123
1124    #[test]
1125    fn parse_relationships_basic() {
1126        let text = "- **USES**: [[target-entity]]\n- **PART_OF**: [[parent]]";
1127        let rels = parse_relationships_with_warnings(text, "specs", None).0;
1128        assert_eq!(rels.len(), 2);
1129        assert_eq!(rels[0].rel_type, "USES");
1130        assert_eq!(rels[0].target.0, "specs--target-entity");
1131        assert_eq!(rels[1].rel_type, "PART_OF");
1132        assert_eq!(rels[1].target.0, "specs--parent");
1133        // Simple form parses without a description.
1134        assert!(rels[0].description.is_none());
1135        assert!(rels[1].description.is_none());
1136    }
1137
1138    #[test]
1139    fn parse_relationships_canonical_em_dash_captures_description() {
1140        let text = "- **OTHER**: [[a]] \u{2014} replaced by checkout-flow";
1141        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1142        assert_eq!(rels.len(), 1);
1143        assert_eq!(
1144            rels[0].description.as_deref(),
1145            Some("replaced by checkout-flow")
1146        );
1147        assert!(warnings.is_empty(), "canonical em-dash does not warn");
1148    }
1149
1150    #[test]
1151    fn parse_relationships_em_dash_inside_description_body() {
1152        let text = "- **OTHER**: [[a]] \u{2014} note with — inside body";
1153        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1154        assert_eq!(rels.len(), 1);
1155        assert_eq!(
1156            rels[0].description.as_deref(),
1157            Some("note with — inside body"),
1158            "the parser captures up to end-of-line; em-dashes inside the body survive"
1159        );
1160        assert!(warnings.is_empty());
1161    }
1162
1163    #[test]
1164    fn parse_relationships_ambiguous_double_hyphen_warns_and_drops_content() {
1165        let text = "- **USES**: [[a]] -- legacy delimiter";
1166        let entity_id = EntityId::new("specs", "src");
1167        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1168        assert_eq!(rels.len(), 1);
1169        assert!(rels[0].description.is_none(), "trailing content is dropped");
1170        assert_eq!(warnings.len(), 1);
1171        assert!(matches!(
1172            warnings[0],
1173            crate::ops::WarningHint::AmbiguousDescriptionDelimiter { .. }
1174        ));
1175    }
1176
1177    #[test]
1178    fn parse_relationships_ambiguous_single_hyphen_warns_and_drops_content() {
1179        let text = "- **USES**: [[a]] - single hyphen";
1180        let entity_id = EntityId::new("specs", "src");
1181        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1182        assert_eq!(rels.len(), 1);
1183        assert!(rels[0].description.is_none());
1184        assert_eq!(warnings.len(), 1);
1185        assert_eq!(warnings[0].code(), "AMBIGUOUS_DESCRIPTION_DELIMITER");
1186    }
1187
1188    #[test]
1189    fn parse_relationships_hyphenated_slug_target_parses_unambiguously() {
1190        let text = "- **USES**: [[some-slug-with-hyphens]] \u{2014} ok";
1191        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1192        assert_eq!(rels.len(), 1);
1193        assert_eq!(rels[0].target.path(), "some-slug-with-hyphens");
1194        assert_eq!(rels[0].description.as_deref(), Some("ok"));
1195        assert!(warnings.is_empty());
1196    }
1197
1198    #[test]
1199    fn parse_full_entity() {
1200        let md = "\
1201---
1202type: spec
1203created_date: 2026-01-15
1204last_modified: 2026-04-12
1205level: M0
1206tags: backend, api
1207---
1208# Test Entity
1209
1210## Identity
1211
1212This is a test entity.
1213
1214## Purpose
1215
1216Testing the parser.
1217
1218## Relationships
1219
1220- **USES**: [[other-entity]]
1221
1222## Specifies
1223
1224Some specification content with [[inline-link]].
1225";
1226        let result = parse_markdown(md, "test-entity.md", &spec_schema(), "specs").unwrap();
1227        let entity = &result.entity;
1228        assert_eq!(entity.id.0, "specs--test-entity");
1229        assert_eq!(entity.title, "Test Entity");
1230        assert_eq!(entity.mem, "specs");
1231        assert_eq!(
1232            entity.metadata["type"],
1233            MetadataValue::String("spec".to_string())
1234        );
1235        assert_eq!(
1236            entity.metadata["level"],
1237            MetadataValue::String("M0".to_string())
1238        );
1239        assert_eq!(
1240            entity.metadata["tags"],
1241            MetadataValue::String("backend, api".to_string())
1242        );
1243        assert_eq!(entity.sections["identity"], "This is a test entity.");
1244        assert_eq!(entity.sections["purpose"], "Testing the parser.");
1245        assert_eq!(entity.relationships.len(), 1);
1246        assert_eq!(entity.relationships[0].rel_type, "USES");
1247        assert_eq!(entity.relationships[0].target.0, "specs--other-entity");
1248        assert_eq!(result.inline_links.len(), 1);
1249        assert_eq!(result.inline_links[0].0, "specs--inline-link");
1250    }
1251
1252    #[test]
1253    fn parse_full_entity_memo_schema() {
1254        let md = "\
1255---
1256type: memo
1257created_date: 2026-01-15
1258last_modified: 2026-04-12
1259status: active
1260tags: decision, architecture
1261---
1262# Use Sled For Storage
1263
1264## Claim
1265
1266Sled is the right embedded store for this workload.
1267
1268## Context
1269
1270We evaluated sled, rocksdb, and sqlite for the in-process graph cache.
1271
1272## Substance
1273
1274Sled wins on pure-Rust dependency footprint.
1275";
1276        let result = parse_markdown(md, "use-sled.md", &memo_schema(), "memos").unwrap();
1277        let entity = &result.entity;
1278        assert_eq!(entity.id.0, "memos--use-sled");
1279        assert_eq!(entity.title, "Use Sled For Storage");
1280        assert_eq!(entity.mem, "memos");
1281        assert_eq!(
1282            entity.metadata["type"],
1283            MetadataValue::String("memo".to_string())
1284        );
1285        assert_eq!(
1286            entity.metadata["status"],
1287            MetadataValue::String("active".to_string())
1288        );
1289        assert_eq!(
1290            entity.sections["claim"],
1291            "Sled is the right embedded store for this workload."
1292        );
1293        assert_eq!(
1294            entity.sections["context"],
1295            "We evaluated sled, rocksdb, and sqlite for the in-process graph cache."
1296        );
1297        assert_eq!(
1298            entity.sections["substance"],
1299            "Sled wins on pure-Rust dependency footprint."
1300        );
1301        assert!(!entity.sections.contains_key("identity"));
1302        assert!(!entity.sections.contains_key("purpose"));
1303    }
1304
1305    #[test]
1306    fn parse_entity_without_frontmatter() {
1307        let md = "# No Frontmatter\n\n## Identity\n\nJust a title and section.";
1308        let result = parse_markdown(md, "no-fm.md", &spec_schema(), "specs").unwrap();
1309        assert_eq!(result.entity.title, "No Frontmatter");
1310        // Only the auto-injected type field should be present
1311        assert_eq!(result.entity.metadata.len(), 1);
1312        assert_eq!(
1313            result.entity.metadata.get("type"),
1314            Some(&MetadataValue::String("spec".to_string()))
1315        );
1316    }
1317
1318    #[test]
1319    fn parse_entity_code_blocks_not_detected() {
1320        let md = "\
1321---
1322type: spec
1323---
1324# Code Test
1325
1326## Identity
1327
1328Test entity.
1329
1330## Specifies
1331
1332```
1333## Not A Section
1334- **USES**: [[not-a-link]]
1335```
1336
1337Real content after code block.
1338";
1339        let result = parse_markdown(md, "code-test.md", &spec_schema(), "specs").unwrap();
1340        // The ## inside code block should NOT be parsed as a section
1341        assert!(!result.entity.sections.contains_key("not a section"));
1342        // The wiki-link inside code block should NOT be extracted
1343        assert!(result.inline_links.is_empty());
1344    }
1345
1346    // Fixture pinned by the adversarial harness (seed 0x5eedf001, case 224):
1347    // a BOM'd file parsed tolerantly as all-body — the entire frontmatter
1348    // was silently lost — while the strict validator and the archive path
1349    // strip the BOM and see the frontmatter. All three implementations
1350    // must land on the same boundary for the same document.
1351    #[test]
1352    fn bom_prefixed_frontmatter_is_recognized() {
1353        let md = "\u{feff}---\ntype: spec\n---\n# Bom Entity\n\n## Identity\n\nBody.\n";
1354        assert_eq!(peek_type_from_frontmatter(md), Some("spec".to_string()));
1355        assert_eq!(
1356            body_after_frontmatter(md),
1357            "# Bom Entity\n\n## Identity\n\nBody.\n"
1358        );
1359        let (meta, body) = split_frontmatter(md).unwrap();
1360        assert_eq!(meta, "type: spec");
1361        assert_eq!(body, "# Bom Entity\n\n## Identity\n\nBody.\n");
1362        let result = parse_markdown(md, "bom.md", &spec_schema(), "specs").unwrap();
1363        assert_eq!(
1364            result.entity.metadata["type"],
1365            MetadataValue::String("spec".to_string())
1366        );
1367        assert_eq!(result.entity.sections["identity"], "Body.");
1368    }
1369
1370    // Fixture pinned by the adversarial harness (seed 0x5eedf001, case 46):
1371    // a section whose content ends inside an open code fence absorbed every
1372    // section the generator wrote after it on the next parse — content
1373    // shifted between sections and the document GREW on every
1374    // parse→generate round. The generator now terminates the open fence;
1375    // the first round normalises, then parse→generate is a fixpoint.
1376    #[test]
1377    fn open_fence_in_section_content_does_not_swallow_following_sections() {
1378        let md = "\
1379---
1380type: spec
1381---
1382# Code Test
1383
1384## Identity
1385
1386Base.
1387
1388## Specifies
1389
1390```
1391truncated code with no closer";
1392        let schema = spec_schema();
1393        let e1 = parse_markdown(md, "open-fence.md", &schema, "specs").unwrap();
1394        let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1395        let e2 = parse_markdown(&m1, "open-fence.md", &schema, "specs").unwrap();
1396        assert_eq!(
1397            e2.entity.sections["identity"], "Base.",
1398            "sections before the open fence survive"
1399        );
1400        assert!(
1401            !e2.entity.sections["specifies"].contains("## Constraints"),
1402            "the generated sections after the fence are not absorbed into it"
1403        );
1404        let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1405        assert_eq!(
1406            m1, m2,
1407            "parse→generate is a fixpoint after one normalising round"
1408        );
1409    }
1410
1411    // Fixture pinned by the adversarial harness (seed 0x5eedf001, case 26):
1412    // a document carrying MULTIPLE non-schema sections — reachable on the
1413    // tolerant local-read path, which refuses nothing — reconstructed its
1414    // catch-all in HashMap iteration order, so canonical bytes differed
1415    // from parse to parse of the same input. The catch-all must re-emit
1416    // non-schema sections in document order, and parse→generate must be
1417    // idempotent for such input.
1418    #[test]
1419    fn catch_all_reconstruction_is_document_ordered_and_idempotent() {
1420        let md = "\
1421---
1422type: spec
1423---
1424# Multi Unknown
1425
1426## Identity
1427
1428Base.
1429
1430## Claim
1431
1432First unknown.
1433
1434## Context
1435
1436Second unknown.
1437
1438## Substance
1439
1440Third unknown.
1441";
1442        let schema = spec_schema();
1443        let e1 = parse_markdown(md, "multi-unknown.md", &schema, "specs").unwrap();
1444        assert_eq!(
1445            e1.entity.sections["specifies"],
1446            "## Claim\nFirst unknown.\n\n## Context\nSecond unknown.\n\n## Substance\nThird unknown.",
1447            "non-schema sections land in the catch-all in document order"
1448        );
1449        let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1450        let e2 = parse_markdown(&m1, "multi-unknown.md", &schema, "specs").unwrap();
1451        let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1452        assert_eq!(
1453            m1, m2,
1454            "parse→generate is idempotent over multi-unknown-section input"
1455        );
1456    }
1457
1458    #[test]
1459    fn compute_hash_deterministic() {
1460        let hash1 = compute_hash("test content");
1461        let hash2 = compute_hash("test content");
1462        assert_eq!(hash1, hash2);
1463        assert_eq!(hash1.len(), 16);
1464    }
1465
1466    #[test]
1467    fn compute_hash_differs() {
1468        let hash1 = compute_hash("content a");
1469        let hash2 = compute_hash("content b");
1470        assert_ne!(hash1, hash2);
1471    }
1472
1473    #[test]
1474    fn is_float_literal_matches() {
1475        assert!(is_float_literal("0.85"));
1476        assert!(is_float_literal("-1.5"));
1477        assert!(is_float_literal("100.0"));
1478        assert!(!is_float_literal(".5"));
1479        assert!(!is_float_literal("1."));
1480        assert!(!is_float_literal("42"));
1481        assert!(!is_float_literal("hello"));
1482    }
1483
1484    #[test]
1485    fn is_integer_literal_matches() {
1486        assert!(is_integer_literal("42"));
1487        assert!(is_integer_literal("-1"));
1488        assert!(is_integer_literal("0"));
1489        assert!(!is_integer_literal("0.5"));
1490        assert!(!is_integer_literal("hello"));
1491        assert!(!is_integer_literal(""));
1492    }
1493
1494    // Regression lock for metadata-key order. The parser reads frontmatter
1495    // line-by-line into an IndexMap, so metadata iteration yields the file's
1496    // declared key order. Render sites iterate entity.metadata directly (see
1497    // `render::render_entity_markdown`), so any regression to HashMap
1498    // reintroduces hash-seed-dependent frontmatter ordering in MCP output.
1499    #[test]
1500    fn parse_preserves_frontmatter_key_order() {
1501        let md = "\
1502---
1503type: principle
1504universality: domain-wide
1505authority: proposed
1506tags: a, b, c
1507created_date: 2026-01-15
1508last_modified: 2026-04-12
1509---
1510# Key Order
1511";
1512        let result = parse_markdown(
1513            md,
1514            "key-order.md",
1515            &type_by_name(builtin_names::PRINCIPLE).unwrap(),
1516            "knowledge",
1517        )
1518        .unwrap();
1519        let keys: Vec<&str> = result.entity.metadata.keys().map(|s| s.as_str()).collect();
1520        assert_eq!(
1521            keys,
1522            vec![
1523                "type",
1524                "universality",
1525                "authority",
1526                "tags",
1527                "created_date",
1528                "last_modified",
1529            ],
1530            "metadata iteration must preserve frontmatter declaration order"
1531        );
1532    }
1533
1534    // Regression lock for section-order round-trip stability. Today this
1535    // passes by construction: the parser inserts keys in schema-declared
1536    // order, the generator writes them in schema-declared order, and
1537    // `IndexMap` preserves that order across re-parses. HashMap iteration
1538    // order was the hole — an IndexMap-based entity.sections closes it.
1539    // Keep the test; if a future refactor reintroduces a HashMap anywhere on
1540    // the parse/write path, this catches it.
1541    #[test]
1542    fn parse_write_roundtrip_preserves_section_order() {
1543        let md = "\
1544---
1545type: spec
1546created_date: 2026-01-15
1547last_modified: 2026-04-12
1548level: M0
1549---
1550# Order Roundtrip
1551
1552## Identity
1553
1554Identity content.
1555
1556## Purpose
1557
1558Purpose content.
1559
1560## Specifies
1561
1562Specifies content.
1563";
1564        let schema = spec_schema();
1565        let first = parse_markdown(md, "order-roundtrip.md", &schema, "specs").unwrap();
1566        let regenerated = crate::entity::generator::generate_markdown(&first.entity, &schema);
1567        let second = parse_markdown(&regenerated, "order-roundtrip.md", &schema, "specs").unwrap();
1568
1569        let first_keys: Vec<&String> = first.entity.sections.keys().collect();
1570        let second_keys: Vec<&String> = second.entity.sections.keys().collect();
1571        assert_eq!(
1572            first_keys, second_keys,
1573            "section iteration order must survive parse -> generate -> parse"
1574        );
1575    }
1576
1577    // ------------------------------------------------------------------
1578    // Heading-spans extraction (H3–H6)
1579    //
1580    // These lock the parser contract: one extra pass per section that
1581    // records H3+ headings as a side-struct. Flat storage; level skips
1582    // are tolerated; code blocks are ignored. See
1583    // `extract_heading_spans`.
1584    // ------------------------------------------------------------------
1585
1586    #[test]
1587    fn parser_extracts_single_h3() {
1588        let md = "\
1589---
1590type: spec
1591---
1592# Entity
1593
1594## Identity
1595
1596Body.
1597
1598## Specifies
1599
1600### Response Shapes
1601
1602Content under response shapes.
1603";
1604        let result = parse_markdown(md, "h3-single.md", &spec_schema(), "specs").unwrap();
1605        let spans = result
1606            .entity
1607            .heading_spans
1608            .get("specifies")
1609            .expect("specifies section should have spans");
1610        assert_eq!(spans.len(), 1);
1611        assert_eq!(spans[0].level, 3);
1612        assert_eq!(spans[0].title, "Response Shapes");
1613        // The section is trimmed, so the H3 sits at offset 0.
1614        assert_eq!(spans[0].start_offset, 0);
1615        let section = result.entity.sections.get("specifies").unwrap();
1616        assert_eq!(spans[0].end_offset, section.len());
1617        // Non-specifies sections either get no entry or the content has no H3+ headings.
1618        assert!(
1619            result
1620                .entity
1621                .heading_spans
1622                .get("identity")
1623                .is_none_or(Vec::is_empty)
1624        );
1625    }
1626
1627    #[test]
1628    fn parser_extracts_nested_h3_h4() {
1629        let md = "\
1630---
1631type: spec
1632---
1633# Entity
1634
1635## Identity
1636
1637Body.
1638
1639## Specifies
1640
1641### Outer
1642
1643Outer body.
1644
1645#### Inner
1646
1647Inner body.
1648";
1649        let result = parse_markdown(md, "h3-h4.md", &spec_schema(), "specs").unwrap();
1650        let spans = result.entity.heading_spans.get("specifies").unwrap();
1651        assert_eq!(spans.len(), 2, "both H3 and H4 must be recorded");
1652        assert_eq!(spans[0].level, 3);
1653        assert_eq!(spans[0].title, "Outer");
1654        assert_eq!(spans[1].level, 4);
1655        assert_eq!(spans[1].title, "Inner");
1656        assert!(
1657            spans[0].start_offset < spans[1].start_offset,
1658            "spans must be in document order"
1659        );
1660        // H3 contains H4: H3.end_offset must cover H4.start_offset.
1661        assert!(
1662            spans[0].end_offset > spans[1].start_offset,
1663            "outer H3 must contain inner H4 by offset"
1664        );
1665    }
1666
1667    #[test]
1668    fn parser_ignores_headings_in_code_blocks() {
1669        let md = "\
1670---
1671type: spec
1672---
1673# Entity
1674
1675## Identity
1676
1677Body.
1678
1679## Specifies
1680
1681Prefix.
1682
1683```
1684### Not a heading
1685Still code.
1686```
1687
1688Suffix.
1689";
1690        let result = parse_markdown(md, "h3-code.md", &spec_schema(), "specs").unwrap();
1691        let spans = result
1692            .entity
1693            .heading_spans
1694            .get("specifies")
1695            .cloned()
1696            .unwrap_or_default();
1697        assert!(
1698            spans.is_empty(),
1699            "a '### ' inside a fenced block must not register as a heading span: {spans:?}"
1700        );
1701    }
1702
1703    #[test]
1704    fn parser_handles_level_skip() {
1705        let md = "\
1706---
1707type: spec
1708---
1709# Entity
1710
1711## Identity
1712
1713Body.
1714
1715## Specifies
1716
1717#### Skipped To H4
1718
1719Content under a sudden H4 — no virtual H3 is inserted.
1720";
1721        let result = parse_markdown(md, "h2-h4.md", &spec_schema(), "specs").unwrap();
1722        let spans = result.entity.heading_spans.get("specifies").unwrap();
1723        assert_eq!(spans.len(), 1);
1724        assert_eq!(spans[0].level, 4);
1725        assert_eq!(spans[0].title, "Skipped To H4");
1726    }
1727
1728    #[test]
1729    fn parser_handles_duplicate_siblings() {
1730        let md = "\
1731---
1732type: spec
1733---
1734# Entity
1735
1736## Identity
1737
1738Body.
1739
1740## Specifies
1741
1742### Same Title
1743
1744First occurrence body.
1745
1746### Same Title
1747
1748Second occurrence body.
1749";
1750        let result = parse_markdown(md, "h3-dup.md", &spec_schema(), "specs").unwrap();
1751        let spans = result.entity.heading_spans.get("specifies").unwrap();
1752        assert_eq!(spans.len(), 2, "duplicate siblings must produce two spans");
1753        assert_eq!(spans[0].title, spans[1].title);
1754        assert_ne!(
1755            spans[0].start_offset, spans[1].start_offset,
1756            "spans with identical titles must be distinguishable by offset"
1757        );
1758        // Siblings at the same level: neither contains the other.
1759        assert!(
1760            spans[0].end_offset <= spans[1].start_offset,
1761            "first sibling must close before the second starts"
1762        );
1763    }
1764
1765    // Duplicate `## Heading` lines for a schema-declared key collapse to the
1766    // first occurrence's body and emit a `DuplicateSectionHeading` warning.
1767    // Catch-all keys absorb arbitrary headings by design and do not warn.
1768
1769    #[test]
1770    fn duplicate_declared_heading_two_populated_keeps_first_warns() {
1771        let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nfirst body\n\n## Identity\n\nsecond body\n";
1772        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1773        assert_eq!(
1774            result.entity.sections.get("identity").map(String::as_str),
1775            Some("first body"),
1776            "first body must win"
1777        );
1778        assert!(
1779            !result
1780                .entity
1781                .sections
1782                .get("identity")
1783                .unwrap()
1784                .contains("## Identity"),
1785            "storage value must not embed a duplicate heading"
1786        );
1787        assert_eq!(result.parse_warnings.len(), 1);
1788        match &result.parse_warnings[0] {
1789            crate::ops::WarningHint::DuplicateSectionHeading {
1790                section_key,
1791                heading,
1792                occurrences,
1793                ..
1794            } => {
1795                assert_eq!(section_key, "identity");
1796                assert_eq!(heading, "Identity");
1797                assert_eq!(*occurrences, 2);
1798            }
1799            other => panic!("expected DuplicateSectionHeading, got {other:?}"),
1800        }
1801    }
1802
1803    #[test]
1804    fn duplicate_declared_heading_blank_then_populated_keeps_blank() {
1805        // First-wins is mechanical: a blank first occurrence wins over a
1806        // populated second one. The warning surfaces so the operator
1807        // notices content was discarded.
1808        let md =
1809            "---\ntype: spec\n---\n# Title\n\n## Identity\n\n## Identity\n\nleftover content\n";
1810        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1811        assert_eq!(
1812            result.entity.sections.get("identity").map(String::as_str),
1813            Some(""),
1814            "first (blank) occurrence wins; second body is dropped"
1815        );
1816        assert_eq!(result.parse_warnings.len(), 1);
1817    }
1818
1819    #[test]
1820    fn duplicate_declared_heading_three_occurrences() {
1821        let md = "---\ntype: spec\n---\n# Title\n\n## Constraints\n\nA\n\n## Constraints\n\n## Constraints\n\nC\n";
1822        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1823        assert_eq!(
1824            result
1825                .entity
1826                .sections
1827                .get("constraints")
1828                .map(String::as_str),
1829            Some("A"),
1830        );
1831        assert_eq!(result.parse_warnings.len(), 1);
1832        match &result.parse_warnings[0] {
1833            crate::ops::WarningHint::DuplicateSectionHeading { occurrences, .. } => {
1834                assert_eq!(*occurrences, 3);
1835            }
1836            _ => unreachable!(),
1837        }
1838    }
1839
1840    #[test]
1841    fn no_warning_when_each_declared_section_appears_once() {
1842        let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Purpose\n\nP\n\n## Constraints\n\nC\n";
1843        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1844        assert!(result.parse_warnings.is_empty());
1845    }
1846
1847    #[test]
1848    fn no_warning_when_catch_all_section_repeats() {
1849        // `specifies` is the spec schema's catch-all section. Repetition
1850        // there is silent — duplicates only warn for non-catch-all keys.
1851        let md =
1852            "---\ntype: spec\n---\n# Title\n\n## Specifies\n\nfirst\n\n## Specifies\n\nsecond\n";
1853        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1854        assert!(
1855            result.parse_warnings.is_empty(),
1856            "catch-all repetition must not warn"
1857        );
1858    }
1859
1860    // Three `## Realization` headings on a spec entity. The default-schema
1861    // `spec` does not declare `realization`, so it flows to the catch-all
1862    // `specifies` bucket and emits no warning, but the storage must still
1863    // not concatenate duplicate heading bytes — that was the bug being
1864    // fixed. Workspaces that declare `realization` (e.g. `software@0.1.0`)
1865    // additionally surface a `DuplicateSectionHeading` warning.
1866    #[test]
1867    fn duplicate_realization_does_not_concatenate_headers_in_storage() {
1868        let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Realization\n\n- a.mjs\n- b.mjs\n\n## Realization\n\n## Realization\n\n- c.mjs\n\n## Constraints\n\nC\n";
1869        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1870        let catch_all = result.entity.sections.get("specifies").unwrap();
1871        let header_count = catch_all.matches("## Realization").count();
1872        assert!(
1873            header_count <= 1,
1874            "catch-all bucket must not contain multiple `## Realization` headers — got {header_count}: {catch_all:?}"
1875        );
1876    }
1877
1878    // After a parse → render round-trip, an entity that was loaded from a
1879    // markdown file with three `## Identity` headings emits exactly one
1880    // `## Identity` heading on re-render. This is the self-heal contract:
1881    // the next read-modify-write of a duplicate-heading entity collapses
1882    // the markdown to one heading per declared section.
1883    #[test]
1884    fn parse_render_round_trip_collapses_duplicate_headings() {
1885        let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nA\n\n## Identity\n\n## Identity\n\nC\n\n## Purpose\n\nP\n";
1886        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1887        let rendered = crate::render::render_entity_markdown(&result.entity, None);
1888        let identity_count = rendered.matches("## Identity").count();
1889        assert_eq!(
1890            identity_count, 1,
1891            "rendered output must carry exactly one `## Identity`, got {identity_count}: {rendered}"
1892        );
1893        // First-wins: the rendered Identity body is `A`, not `C`.
1894        assert!(rendered.contains("\n## Identity\n\nA\n"));
1895        assert!(!rendered.contains("C\n"), "second body must not survive");
1896    }
1897}
1898
1899/// End-to-end pins for the six verified code-block misparse classes,
1900/// on the real entity paths: section splitting, title extraction,
1901/// heading spans, and wiki-link extraction. The unit-level pins for
1902/// the mask itself live in [`crate::markdown`]; these assert the
1903/// classes are actually fixed where they did damage.
1904#[cfg(test)]
1905mod commonmark_referee {
1906    use super::*;
1907    use memstead_schema::{builtin_names, type_by_name};
1908    use std::sync::Arc;
1909
1910    fn spec_schema() -> Arc<TypeDefinition> {
1911        type_by_name(builtin_names::SPEC).unwrap()
1912    }
1913
1914    /// Wrap a `## Specifies` body in a minimal, valid spec entity.
1915    fn entity_with_specifies(body: &str) -> ParseResult {
1916        let md = format!(
1917            "---\ntype: spec\n---\n\n# Referee Test\n\n## Identity\n\nx\n\n## Specifies\n\n{body}\n"
1918        );
1919        parse_markdown(&md, "referee-test.md", &spec_schema(), "specs").unwrap()
1920    }
1921
1922    fn headings(result: &ParseResult) -> Vec<&str> {
1923        result
1924            .entity
1925            .raw_section_headings
1926            .iter()
1927            .map(String::as_str)
1928            .collect()
1929    }
1930
1931    fn link_targets(result: &ParseResult) -> Vec<String> {
1932        result.inline_links.iter().map(|id| id.0.clone()).collect()
1933    }
1934
1935    /// The complement first: without it, every assertion below could
1936    /// pass because the paths do nothing at all.
1937    #[test]
1938    fn complement_prose_headings_and_links_still_work() {
1939        let r = entity_with_specifies("See [[real-target]] here.");
1940        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1941        assert_eq!(link_targets(&r), vec!["specs--real-target".to_string()]);
1942        assert_eq!(r.entity.title, "Referee Test");
1943    }
1944
1945    #[test]
1946    fn class_1_indented_code_block() {
1947        let r = entity_with_specifies("Example:\n\n    ## Not A Section\n    [[not-a-link]]\n");
1948        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1949        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1950    }
1951
1952    #[test]
1953    fn class_2_fence_indented_one_to_three_spaces() {
1954        let r = entity_with_specifies(
1955            "- item\n\n   ```\n   ## Not A Section\n   [[not-a-link]]\n   ```\n",
1956        );
1957        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1958        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1959    }
1960
1961    #[test]
1962    fn class_3_tilde_fence() {
1963        let r = entity_with_specifies("~~~\n## Not A Section\n[[not-a-link]]\n~~~\n");
1964        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1965        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1966    }
1967
1968    #[test]
1969    fn class_4_info_string_on_the_closing_line() {
1970        let r = entity_with_specifies(
1971            "```\ncode\n``` still-code\n## Not A Section\n[[not-a-link]]\n```\n",
1972        );
1973        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1974        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1975    }
1976
1977    #[test]
1978    fn class_5_fence_inside_a_blockquote() {
1979        let r = entity_with_specifies("> ```\n> ## Not A Section\n> [[not-a-link]]\n> ```\n");
1980        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1981        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1982    }
1983
1984    #[test]
1985    fn class_6_opening_fence_length_is_honoured_on_close() {
1986        let r = entity_with_specifies("````\n```\n## Not A Section\n[[not-a-link]]\n```\n````\n");
1987        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1988        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1989    }
1990
1991    /// The title extractor was the one scanner with no masking at all.
1992    #[test]
1993    fn a_heading_inside_a_code_block_never_becomes_the_title() {
1994        let md =
1995            "---\ntype: spec\n---\n\n```\n# Fake Title\n```\n\n# Real Title\n\n## Identity\n\nx\n";
1996        let r = parse_markdown(md, "title-test.md", &spec_schema(), "specs").unwrap();
1997        assert_eq!(r.entity.title, "Real Title");
1998    }
1999
2000    /// …and when the code block is the whole body, the filename
2001    /// fallback applies rather than a title mined out of code.
2002    #[test]
2003    fn a_code_block_only_body_falls_back_to_the_filename() {
2004        let md = "---\ntype: spec\n---\n\n    # Fake Title\n\n## Identity\n\nx\n";
2005        let r = parse_markdown(md, "fallback-test.md", &spec_schema(), "specs").unwrap();
2006        assert_eq!(r.entity.title, "fallback-test");
2007    }
2008
2009    #[test]
2010    fn heading_spans_ignore_code_block_content() {
2011        let r = entity_with_specifies("### Real Sub\n\n~~~\n### Fake Sub\n~~~\n");
2012        let spans = r.entity.heading_spans.get("specifies").expect("spans");
2013        let titles: Vec<&str> = spans.iter().map(|s| s.title.as_str()).collect();
2014        assert_eq!(titles, vec!["Real Sub"]);
2015    }
2016
2017    /// One definition of "not visible to a link scanner": a link the
2018    /// strict validator cannot see is a link no path turns into an
2019    /// edge. Multi-backtick spans are the case a delimiter-count regex
2020    /// slices through.
2021    #[test]
2022    fn inline_code_spans_hide_links_on_the_extraction_path() {
2023        let r = entity_with_specifies("`[[hidden-one]]` and ``[[hidden-two]]`` but [[visible]].");
2024        assert_eq!(link_targets(&r), vec!["specs--visible".to_string()]);
2025    }
2026
2027    /// The empty-target asymmetry: the strict extractor now *sees*
2028    /// `[[]]` and routes it to the same refusal the validator emits,
2029    /// instead of a pattern that could not match it at all.
2030    #[test]
2031    fn empty_wiki_link_target_is_refused_by_the_strict_extractor() {
2032        let errors = extract_inline_links("an empty [[]] link", "specs")
2033            .expect_err("empty target must refuse");
2034        assert_eq!(errors.len(), 1, "{errors:?}");
2035    }
2036
2037    /// The read side tolerates drift by ignoring what it cannot
2038    /// decode — but it sees the same token the strict side refuses.
2039    #[test]
2040    fn empty_wiki_link_target_yields_no_id_on_the_lenient_path() {
2041        assert!(extract_inline_links_lenient("an empty [[]] link", "specs").is_empty());
2042    }
2043
2044    /// A conflicted file must be refused however its frontmatter is
2045    /// shaped. Masking the whole file let a YAML value that reads as a
2046    /// fence opener blank the body — markers included — so the file
2047    /// loaded as a normal entity with BOTH merge sides fused into one
2048    /// body, which is the exact outcome the guard exists to prevent.
2049    #[test]
2050    fn merge_conflict_markers_are_seen_through_fence_shaped_frontmatter() {
2051        let body = "\n# T\n\n## Identity\n\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n";
2052        for fm in [
2053            "---\ntype: spec\n---",
2054            "---\ntype: spec\nnotes: |\n  ```rust\n  fn x() {}\n---",
2055            "---\ntype: spec\nnotes: |\n   ~~~\n---",
2056            "---\ntype: spec\nnotes: |\n    indented block\n---",
2057        ] {
2058            assert!(
2059                has_merge_conflict_markers(&format!("{fm}{body}")),
2060                "conflict markers must be seen through frontmatter: {fm:?}"
2061            );
2062        }
2063    }
2064
2065    /// …and markers in the frontmatter itself count: git writes them
2066    /// wherever the hunks fall, including above the `---`.
2067    #[test]
2068    fn merge_conflict_markers_in_frontmatter_are_seen() {
2069        let content =
2070            "---\n<<<<<<< HEAD\ntype: spec\n=======\ntype: memo\n>>>>>>> branch\n---\n\n# T\n";
2071        assert!(has_merge_conflict_markers(content));
2072    }
2073
2074    /// Complement: a fenced code example documenting conflict markers
2075    /// in a section body still does not trip the guard.
2076    #[test]
2077    fn a_fenced_conflict_marker_example_still_does_not_trip_the_guard() {
2078        let content = "---\ntype: spec\n---\n\n# T\n\n## Identity\n\n```\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n```\n";
2079        assert!(!has_merge_conflict_markers(content));
2080    }
2081
2082    /// A relationship row inside a code block is an example of the
2083    /// syntax, not a relationship. It used to become a live edge and an
2084    /// auto-stub while the strict validator — which masks — could not
2085    /// see the link at all: one path synthesising an edge from what
2086    /// another path refuses to see.
2087    #[test]
2088    fn a_relationship_row_inside_a_code_block_is_not_a_relationship() {
2089        for body in [
2090            "```\n- **REFERENCES**: [[ghost]]\n```",
2091            "~~~\n- **REFERENCES**: [[ghost]]\n~~~",
2092            "    - **REFERENCES**: [[ghost]]",
2093            "> ```\n> - **REFERENCES**: [[ghost]]\n> ```",
2094            "````\n```\n- **REFERENCES**: [[ghost]]\n```\n````",
2095        ] {
2096            let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2097            assert!(
2098                rels.is_empty(),
2099                "code-block row must not become an edge: {body:?} -> {rels:?}"
2100            );
2101        }
2102    }
2103
2104    /// …and a row hidden inside an INLINE CODE SPAN is not one either.
2105    /// A blocks-only mask left this row invisible to the strict
2106    /// validator and to every link extractor — both of which mask
2107    /// spans — while still building an edge and a stub from it. A
2108    /// multi-line span is the shape that bites: a lazy paragraph
2109    /// continuation keeps the backticks open across the row.
2110    #[test]
2111    fn a_relationship_row_inside_an_inline_code_span_is_not_a_relationship() {
2112        for body in [
2113            // The row is indented, so it does not interrupt the
2114            // paragraph as a list — it is a lazy continuation and the
2115            // backtick pair holds the span open across all three lines.
2116            // (At column 0 a `-` DOES start a list, the span never
2117            // forms, and the row is a real relationship — correctly.)
2118            "Example `open\n    - **REFERENCES**: [[ghost]]\nclose`",
2119            "A `- **REFERENCES**: [[ghost]]` sample.",
2120            "A ``- **REFERENCES**: [[ghost]]`` sample.",
2121        ] {
2122            let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2123            assert!(
2124                rels.is_empty(),
2125                "code-span row must not become an edge: {body:?} -> {rels:?}"
2126            );
2127        }
2128    }
2129
2130    /// Complement: a real row still parses, keeps its type, target and
2131    /// em-dash description, and still warns on an ambiguous delimiter —
2132    /// every captured span is read from the original, not the mask.
2133    #[test]
2134    fn real_relationship_rows_are_unchanged_by_the_mask() {
2135        let body = "- **REFERENCES**: [[alpha]]\n- **uses**: [[beta]] — because it must\n\n```\n- **REFERENCES**: [[ghost]]\n```\n";
2136        let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2137        assert_eq!(rels.len(), 2, "{rels:?}");
2138        assert_eq!(rels[0].rel_type, "REFERENCES");
2139        assert_eq!(rels[0].target.0, "specs--alpha");
2140        assert_eq!(rels[0].description, None);
2141        assert_eq!(
2142            rels[1].rel_type, "USES",
2143            "case is normalised from the original"
2144        );
2145        assert_eq!(rels[1].target.0, "specs--beta");
2146        assert_eq!(rels[1].description.as_deref(), Some("because it must"));
2147    }
2148
2149    #[test]
2150    fn ambiguous_delimiter_warning_still_fires_on_a_real_row() {
2151        let id = file_path_to_id("x.md", "specs");
2152        let (_, warnings) = parse_relationships_with_warnings(
2153            "- **REFERENCES**: [[alpha]] -- not an em dash\n",
2154            "specs",
2155            Some(&id),
2156        );
2157        assert_eq!(warnings.len(), 1, "{warnings:?}");
2158    }
2159
2160    /// Frontmatter is not markdown. Masking the whole file would hand
2161    /// a CommonMark parser YAML it can read as block structure: a
2162    /// value line that looks like a fence opener (legal at 1–3 spaces
2163    /// since the indented-fence class was fixed) would open a code
2164    /// block that runs past the closing `---` and mask the entire
2165    /// body — no title, no sections, no links. The split happens
2166    /// first; only the body is masked.
2167    #[test]
2168    fn frontmatter_never_opens_a_code_block_over_the_body() {
2169        for fm in [
2170            "notes: |\n  ```rust",
2171            "notes: |\n   ~~~",
2172            "notes: |\n  ```\n  still open",
2173            "notes: |\n    indented block\n",
2174        ] {
2175            let md = format!(
2176                "---\ntype: spec\n{fm}\n---\n\n# Real Title\n\n## Identity\n\nSee [[a-link]].\n"
2177            );
2178            let r = parse_markdown(&md, "fm-test.md", &spec_schema(), "specs").unwrap();
2179            assert_eq!(
2180                r.entity.title, "Real Title",
2181                "frontmatter ate the title: {fm:?}"
2182            );
2183            assert_eq!(
2184                headings(&r),
2185                vec!["Identity"],
2186                "frontmatter ate the sections: {fm:?}"
2187            );
2188            assert_eq!(
2189                link_targets(&r),
2190                vec!["specs--a-link".to_string()],
2191                "frontmatter ate the links: {fm:?}"
2192            );
2193        }
2194    }
2195}