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