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. The context is not only fences: an
794    // HTML block of the kinds no blank line ends (`<!X`, `<!--`, `<?`,
795    // `<![CDATA[`, a `<script>`-family tag) hides every fence the
796    // referee would otherwise read, so a piece that ends inside one
797    // changes what the NEXT piece's fences mean — a `## ` line that a
798    // fence masked in situ surfaces as a heading after the merge, and
799    // as an empty non-schema section it is dropped a round later
800    // (fuzz finding, long tier, 0.17.0 release readiness run, corpus
801    // member `crash-1233c134…`). The same oracle closes both.
802    let mut joined = String::new();
803    for piece in parts {
804        if joined.is_empty() {
805            joined = piece;
806        } else {
807            joined.push_str("\n\n");
808            joined.push_str(&piece);
809        }
810        if let Some(closer) = crate::markdown::closing_context_if_unterminated(&joined) {
811            joined.push('\n');
812            joined.push_str(&closer);
813        }
814    }
815    joined
816}
817
818// ---------------------------------------------------------------------------
819// Relationships
820// ---------------------------------------------------------------------------
821
822/// Parse typed relationships from the Relationships section.
823///
824/// Recognises two row shapes:
825/// - simple: `- **TYPE**: [[target]]` → `description: None`
826/// - em-dash: `- **TYPE**: [[target]] — text` → `description: Some(text)`
827///
828/// Returns the relations plus parse-time warnings flagging
829/// AMBIGUOUS-delimiter rows (`-- text`, `- text`, en-dash, minus). On
830/// AMBIGUOUS rows the description is dropped — the renderer will
831/// normalise the row to the simple form on next write.
832///
833/// Rows inside a code block are not relationships. The scan runs over
834/// the masked section body and reads every captured span from the
835/// original, so a fenced or indented example of the row syntax — the
836/// obvious thing to write in an entity documenting that syntax — no
837/// longer becomes a live edge and an auto-stub. Without the mask this
838/// path synthesised edges from links the strict validator cannot see
839/// (`validator::strict::check_wiki_links` masks), which is exactly the
840/// asymmetry the one-definition rule exists to prevent.
841pub(crate) fn parse_relationships_with_warnings(
842    text: &str,
843    mem: &str,
844    entity_id: Option<&EntityId>,
845) -> (Vec<Relationship>, Vec<crate::ops::WarningHint>) {
846    // Anchor on the canonical row prefix `- **TYPE**: [[<target>]]` and
847    // capture everything that follows on the same line so the trailing
848    // segment can be classified (simple, em-dash, or AMBIGUOUS).
849    //
850    // The target must not cross a line: a ROW is a line. A capture
851    // spanning a newline only ever came from degenerate drift, and it
852    // cannot round-trip — the generated multi-line token re-enters the
853    // mask with different structure (a following `-` + tab line reads
854    // as list-item indented code and swallows the closing `]]`), so
855    // the row silently vanished one round later (fuzz finding, corpus
856    // member `crash-93f0a4bd…`). Ids containing newlines can never
857    // exist as entity files, so such pseudo-rows are consistently not
858    // relationships in ANY round.
859    static RE: OnceLock<Regex> = OnceLock::new();
860    let re = RE.get_or_init(|| {
861        Regex::new(r"(?m)^\s*-\s*\*\*(\w+)\*\*:\s*\[\[([^\]\n]+)\]\](?P<tail>[^\n]*)").unwrap()
862    });
863    let mut relationships = Vec::new();
864    let mut warnings = Vec::new();
865    // Blocks AND inline spans — the same mask every link scanner uses.
866    // A legitimate row's target can never sit inside a code span, so
867    // masking spans costs nothing and closes the seam: with a
868    // blocks-only mask a row inside a multi-line inline span stayed
869    // invisible to the validator and to every extractor while still
870    // building an edge and a stub. Masking preserves byte offsets, so a
871    // match found in the masked copy indexes the original exactly.
872    let masked = mask_code_blocks_and_spans(text);
873    for cap in re.captures_iter(&masked) {
874        let rel_type = text[cap.get(1).unwrap().range()].to_uppercase();
875        // Read-time parsing of the ## Relationships table tolerates
876        // pre-strict on-disk drift so legacy rows whose target fails
877        // the wiki-link grammar continue to round-trip. The mutation
878        // pipeline (`memstead_relate`, declare_relations) gates strictly
879        // via `validate_relation_target_grammar`.
880        let target = wiki_link_to_id_lenient(&text[cap.get(2).unwrap().range()], mem);
881        // A raw target that decodes to an EMPTY path (`[[specs--]]`
882        // after the self-prefix strip, `[[../]]` after decoration
883        // stripping) is not a relationship: the generator would render
884        // it as `[[]]`, which the row pattern cannot re-capture, so the
885        // row silently vanished one round later (fuzz finding, corpus
886        // member `crash-0c7207a1…`). Skipping it here mirrors how rows
887        // that never match the pattern behave; both strict gates refuse
888        // such targets outright.
889        if target.path().is_empty() {
890            continue;
891        }
892        let tail = cap.name("tail").map(|m| &text[m.range()]).unwrap_or("");
893        let description = match classify_description_tail(tail) {
894            DescriptionTail::None => None,
895            DescriptionTail::EmDash(text) => Some(text),
896            DescriptionTail::Ambiguous(literal) => {
897                if let Some(id) = entity_id {
898                    warnings.push(crate::ops::WarningHint::AmbiguousDescriptionDelimiter {
899                        from: id.clone(),
900                        rel_type: rel_type.clone(),
901                        target: target.clone(),
902                        trailing: literal,
903                    });
904                }
905                None
906            }
907        };
908        relationships.push(Relationship {
909            rel_type,
910            target,
911            description,
912        });
913    }
914    (relationships, warnings)
915}
916
917/// Classification of the per-line tail that follows `]]` on a
918/// `## Relationships` row.
919enum DescriptionTail {
920    /// Tail is empty or whitespace-only.
921    None,
922    /// Tail begins with the canonical em-dash delimiter; carries the
923    /// captured description text (trimmed of trailing whitespace).
924    EmDash(String),
925    /// Tail starts with a non-canonical dash-like delimiter (`-`,
926    /// `--`, U+2013 en-dash, U+2212 minus). Carries the literal
927    /// trailing content so the warning surfaces what was dropped.
928    Ambiguous(String),
929}
930
931/// Inspect the post-`]]` tail of a `## Relationships` row and decide
932/// what shape it takes. The em-dash delimiter is the exact three-byte
933/// UTF-8 sequence of U+2014 framed by single ASCII spaces; everything
934/// else falls into [`DescriptionTail::None`] or
935/// [`DescriptionTail::Ambiguous`].
936fn classify_description_tail(tail: &str) -> DescriptionTail {
937    let trimmed_end = tail.trim_end();
938    if trimmed_end.is_empty() {
939        return DescriptionTail::None;
940    }
941    // Canonical: literal space + U+2014 + literal space + content.
942    if let Some(rest) = trimmed_end.strip_prefix(" \u{2014} ") {
943        if rest.is_empty() {
944            return DescriptionTail::None;
945        }
946        return DescriptionTail::EmDash(rest.to_string());
947    }
948    // U+2014 directly after `]]` (no leading space) is also ambiguous
949    // — the canonical form requires the framing space. Likewise an
950    // em-dash with no trailing content (` — `) collapses to None.
951    if let Some(rest) = trimmed_end.strip_prefix(" \u{2014}") {
952        // ` —` (no trailing space, but content followed) lands here.
953        return DescriptionTail::Ambiguous(format!(" \u{2014}{rest}"));
954    }
955    // Dash-likes: ASCII `--`, ASCII `-`, en-dash U+2013, minus U+2212.
956    let starters = [" --", " -", " \u{2013}", " \u{2212}"];
957    if starters
958        .iter()
959        .any(|prefix| trimmed_end.starts_with(prefix))
960    {
961        return DescriptionTail::Ambiguous(trimmed_end.to_string());
962    }
963    // Anything else after `]]` (e.g. inline comment, stray text) —
964    // classify as ambiguous so the operator sees that content was
965    // dropped rather than silently swallowed.
966    DescriptionTail::Ambiguous(trimmed_end.to_string())
967}
968
969// ---------------------------------------------------------------------------
970// Wiki-links
971// ---------------------------------------------------------------------------
972
973/// The `[[target]]` / `[[target|label]]` wiki-link pattern, compiled once.
974///
975/// The inner group is `*`, not `+`, so an empty target `[[]]` is *seen*
976/// by every path — the strict validator refuses it with a typed
977/// `InvalidWikiLink`, and this module's strict extractor routes it to
978/// the same refusal. A pattern that cannot see `[[]]` is how one path
979/// came to silently ignore what another path refused.
980fn wiki_link_re() -> &'static Regex {
981    static RE: OnceLock<Regex> = OnceLock::new();
982    RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
983}
984
985/// Extract unique mem-prefixed entity IDs from inline wiki-links,
986/// strictly validating each target against the slug-form grammar.
987/// Strips code blocks and inline code spans before scanning, by the
988/// one CommonMark definition ([`crate::markdown`]).
989///
990/// Returns the deduped valid ids on success, or every refusal in the
991/// scan window on failure (errors are collected, not fail-fast — the
992/// agent sees every malformed link in a single round-trip).
993///
994/// Mutation-pipeline callers (`synthesise_alias_relations`, etc.) use
995/// this strict variant and map [`WikiLinkError`] to the typed engine
996/// envelope with section context. Read-side scanners that must
997/// tolerate pre-strict on-disk drift use [`extract_inline_links_lenient`].
998pub(crate) fn extract_inline_links(
999    text: &str,
1000    mem: &str,
1001) -> Result<Vec<EntityId>, Vec<WikiLinkError>> {
1002    let stripped = mask_code_blocks_and_spans(text);
1003
1004    let link_re = wiki_link_re();
1005    let mut seen = HashSet::new();
1006    let mut links = Vec::new();
1007    let mut errors = Vec::new();
1008
1009    for cap in link_re.captures_iter(&stripped) {
1010        match wiki_link_to_id(&cap[1], mem) {
1011            Ok(id) => {
1012                if errors.is_empty() && seen.insert(id.0.clone()) {
1013                    links.push(id);
1014                }
1015            }
1016            Err(e) => errors.push(e),
1017        }
1018    }
1019
1020    if errors.is_empty() {
1021        Ok(links)
1022    } else {
1023        Err(errors)
1024    }
1025}
1026
1027/// Permissive sibling of [`extract_inline_links`] for read-side
1028/// scanners. Decodes every `[[...]]` token via [`wiki_link_to_id_lenient`]
1029/// so on-disk drift (legacy entities, archive-imports from pre-strict
1030/// engines, partial-mutation rollbacks) keeps flowing through dangling-
1031/// link reporters and graph inspectors. Mutation paths MUST NOT use this
1032/// helper — see [`extract_inline_links`] for the strict variant.
1033pub fn extract_inline_links_lenient(text: &str, mem: &str) -> Vec<EntityId> {
1034    let stripped = mask_code_blocks_and_spans(text);
1035
1036    let link_re = wiki_link_re();
1037    let mut seen = HashSet::new();
1038    let mut links = Vec::new();
1039
1040    for cap in link_re.captures_iter(&stripped) {
1041        // An empty target decodes to no id. The read side tolerates
1042        // drift by ignoring what it cannot decode; the strict side
1043        // refuses it (`extract_inline_links`). Both *see* it — that is
1044        // the part that must not diverge.
1045        if cap[1].is_empty() {
1046            continue;
1047        }
1048        let id = wiki_link_to_id_lenient(&cap[1], mem);
1049        if seen.insert(id.0.clone()) {
1050            links.push(id);
1051        }
1052    }
1053
1054    links
1055}
1056
1057// ---------------------------------------------------------------------------
1058// Content hash
1059// ---------------------------------------------------------------------------
1060
1061/// Compute SHA-256 hash of content, truncated to 16 hex characters.
1062pub fn compute_hash(content: &str) -> String {
1063    let mut hasher = Sha256::new();
1064    hasher.update(content.as_bytes());
1065    let result = hasher.finalize();
1066    crate::hex_lower(&result)[..16].to_string()
1067}
1068
1069// ---------------------------------------------------------------------------
1070// Errors
1071// ---------------------------------------------------------------------------
1072
1073#[derive(Debug, thiserror::Error)]
1074pub enum ParseError {
1075    #[error("missing frontmatter")]
1076    MissingFrontmatter,
1077    #[error("invalid frontmatter: {0}")]
1078    InvalidFrontmatter(String),
1079    #[error("missing title")]
1080    MissingTitle,
1081    #[error("io error: {0}")]
1082    Io(#[from] std::io::Error),
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088    use memstead_schema::{builtin_names, type_by_name};
1089    use std::sync::Arc;
1090
1091    fn spec_schema() -> Arc<TypeDefinition> {
1092        type_by_name(builtin_names::SPEC).unwrap()
1093    }
1094
1095    fn memo_schema() -> Arc<TypeDefinition> {
1096        type_by_name(builtin_names::MEMO).unwrap()
1097    }
1098
1099    #[test]
1100    fn parse_metadata_types() {
1101        let meta = parse_metadata("key: value\nnum: 42\nfloat: 0.85\nbool: true\nfalsy: false");
1102        assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
1103        assert_eq!(meta["num"], MetadataValue::Integer(42));
1104        assert_eq!(meta["float"], MetadataValue::Float(0.85));
1105        assert_eq!(meta["bool"], MetadataValue::Bool(true));
1106        assert_eq!(meta["falsy"], MetadataValue::Bool(false));
1107    }
1108
1109    #[test]
1110    fn parse_metadata_strips_comments() {
1111        let meta = parse_metadata("key: value # this is a comment");
1112        assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
1113    }
1114
1115    #[test]
1116    fn parse_metadata_strips_quotes() {
1117        let meta = parse_metadata("key: \"quoted value\"\nkey2: 'single'");
1118        assert_eq!(
1119            meta["key"],
1120            MetadataValue::String("quoted value".to_string())
1121        );
1122        assert_eq!(meta["key2"], MetadataValue::String("single".to_string()));
1123    }
1124
1125    #[test]
1126    fn parse_metadata_survives_malformed_values() {
1127        // A lone quote character satisfies both starts_with and ends_with —
1128        // the old unguarded slice `s[1..s.len()-1]` panicked on it.
1129        let meta = parse_metadata(
1130            "key: \"\nkey2: '\nkey3: \"\"\nkey4: ''\nkey5: \"unterminated\nkey6: mixed'\"",
1131        );
1132        assert_eq!(meta["key"], MetadataValue::String("\"".to_string()));
1133        assert_eq!(meta["key2"], MetadataValue::String("'".to_string()));
1134        assert_eq!(meta["key3"], MetadataValue::String(String::new()));
1135        assert_eq!(meta["key4"], MetadataValue::String(String::new()));
1136        assert_eq!(
1137            meta["key5"],
1138            MetadataValue::String("\"unterminated".to_string())
1139        );
1140        assert_eq!(meta["key6"], MetadataValue::String("mixed'\"".to_string()));
1141
1142        // More frontmatter shapes that must parse to a value, never panic:
1143        // colon-only lines, multi-byte values, keyless colons, huge digits.
1144        let meta =
1145            parse_metadata(":\n: value\nkey7: ✓\"\nkey8: 99999999999999999999999999\nkey9: -");
1146        assert_eq!(meta["key7"], MetadataValue::String("✓\"".to_string()));
1147        assert_eq!(
1148            meta["key8"],
1149            MetadataValue::String("99999999999999999999999999".to_string())
1150        );
1151        assert_eq!(meta["key9"], MetadataValue::String("-".to_string()));
1152    }
1153
1154    #[test]
1155    fn parse_metadata_skips_comments_and_empty() {
1156        let meta = parse_metadata("# comment\n\nkey: val\n---");
1157        assert_eq!(meta.len(), 1);
1158        assert_eq!(meta["key"], MetadataValue::String("val".to_string()));
1159    }
1160
1161    #[test]
1162    fn peek_type_finds_value() {
1163        let content = "---\ntype: memo\ntitle: Test\n---\n# Body\n";
1164        assert_eq!(
1165            peek_type_from_frontmatter(content),
1166            Some("memo".to_string())
1167        );
1168    }
1169
1170    #[test]
1171    fn peek_type_returns_none_when_missing() {
1172        let content = "---\ntitle: Test\n---\n# Body\n";
1173        assert_eq!(peek_type_from_frontmatter(content), None);
1174    }
1175
1176    #[test]
1177    fn peek_type_returns_none_without_frontmatter() {
1178        let content = "# Just a heading\n\nBody with type: concept inside text.\n";
1179        assert_eq!(peek_type_from_frontmatter(content), None);
1180    }
1181
1182    #[test]
1183    fn peek_type_handles_windows_line_endings() {
1184        let content = "---\r\ntype: principle\r\n---\r\n# Body\r\n";
1185        assert_eq!(
1186            peek_type_from_frontmatter(content),
1187            Some("principle".to_string())
1188        );
1189    }
1190
1191    #[test]
1192    fn peek_type_strips_quotes_and_comments() {
1193        let quoted = "---\ntype: \"concept\"\n---\n";
1194        assert_eq!(
1195            peek_type_from_frontmatter(quoted),
1196            Some("concept".to_string())
1197        );
1198        let commented = "---\ntype: memo # kind of\n---\n";
1199        assert_eq!(
1200            peek_type_from_frontmatter(commented),
1201            Some("memo".to_string())
1202        );
1203    }
1204
1205    #[test]
1206    fn peek_type_empty_value_returns_none() {
1207        let content = "---\ntype:\n---\n";
1208        assert_eq!(peek_type_from_frontmatter(content), None);
1209    }
1210
1211    #[test]
1212    fn peek_type_ignores_legacy_schema_key() {
1213        // After the hard break, a bare `schema:` in frontmatter is not
1214        // recognized as the type key — it's just arbitrary metadata.
1215        let content = concat!("---\n", "schema", ": memo\n---\n");
1216        assert_eq!(peek_type_from_frontmatter(content), None);
1217    }
1218
1219    #[test]
1220    fn mask_code_blocks_basic() {
1221        let input = "before\n```\ncode [[link]]\n```\nafter";
1222        let masked = mask_code_blocks(input);
1223        assert!(!masked.contains("[[link]]"));
1224        assert!(masked.contains("before"));
1225        assert!(masked.contains("after"));
1226    }
1227
1228    #[test]
1229    fn mask_code_blocks_preserves_line_count() {
1230        let input = "line1\n```\ncode\nmore code\n```\nline6";
1231        let masked = mask_code_blocks(input);
1232        assert_eq!(input.lines().count(), masked.lines().count());
1233    }
1234
1235    #[test]
1236    fn mask_code_blocks_unclosed() {
1237        let input = "before\n```\ncode\nmore code";
1238        let masked = mask_code_blocks(input);
1239        assert!(masked.contains("before"));
1240        assert!(!masked.contains("code"));
1241    }
1242
1243    #[test]
1244    fn parse_relationships_basic() {
1245        let text = "- **USES**: [[target-entity]]\n- **PART_OF**: [[parent]]";
1246        let rels = parse_relationships_with_warnings(text, "specs", None).0;
1247        assert_eq!(rels.len(), 2);
1248        assert_eq!(rels[0].rel_type, "USES");
1249        assert_eq!(rels[0].target.0, "specs--target-entity");
1250        assert_eq!(rels[1].rel_type, "PART_OF");
1251        assert_eq!(rels[1].target.0, "specs--parent");
1252        // Simple form parses without a description.
1253        assert!(rels[0].description.is_none());
1254        assert!(rels[1].description.is_none());
1255    }
1256
1257    #[test]
1258    fn parse_relationships_canonical_em_dash_captures_description() {
1259        let text = "- **OTHER**: [[a]] \u{2014} replaced by checkout-flow";
1260        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1261        assert_eq!(rels.len(), 1);
1262        assert_eq!(
1263            rels[0].description.as_deref(),
1264            Some("replaced by checkout-flow")
1265        );
1266        assert!(warnings.is_empty(), "canonical em-dash does not warn");
1267    }
1268
1269    #[test]
1270    fn parse_relationships_em_dash_inside_description_body() {
1271        let text = "- **OTHER**: [[a]] \u{2014} note with — inside body";
1272        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1273        assert_eq!(rels.len(), 1);
1274        assert_eq!(
1275            rels[0].description.as_deref(),
1276            Some("note with — inside body"),
1277            "the parser captures up to end-of-line; em-dashes inside the body survive"
1278        );
1279        assert!(warnings.is_empty());
1280    }
1281
1282    #[test]
1283    fn parse_relationships_ambiguous_double_hyphen_warns_and_drops_content() {
1284        let text = "- **USES**: [[a]] -- legacy delimiter";
1285        let entity_id = EntityId::new("specs", "src");
1286        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1287        assert_eq!(rels.len(), 1);
1288        assert!(rels[0].description.is_none(), "trailing content is dropped");
1289        assert_eq!(warnings.len(), 1);
1290        assert!(matches!(
1291            warnings[0],
1292            crate::ops::WarningHint::AmbiguousDescriptionDelimiter { .. }
1293        ));
1294    }
1295
1296    #[test]
1297    fn parse_relationships_ambiguous_single_hyphen_warns_and_drops_content() {
1298        let text = "- **USES**: [[a]] - single hyphen";
1299        let entity_id = EntityId::new("specs", "src");
1300        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1301        assert_eq!(rels.len(), 1);
1302        assert!(rels[0].description.is_none());
1303        assert_eq!(warnings.len(), 1);
1304        assert_eq!(warnings[0].code(), "AMBIGUOUS_DESCRIPTION_DELIMITER");
1305    }
1306
1307    #[test]
1308    fn parse_relationships_hyphenated_slug_target_parses_unambiguously() {
1309        let text = "- **USES**: [[some-slug-with-hyphens]] \u{2014} ok";
1310        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1311        assert_eq!(rels.len(), 1);
1312        assert_eq!(rels[0].target.path(), "some-slug-with-hyphens");
1313        assert_eq!(rels[0].description.as_deref(), Some("ok"));
1314        assert!(warnings.is_empty());
1315    }
1316
1317    #[test]
1318    fn parse_full_entity() {
1319        let md = "\
1320---
1321type: spec
1322created_date: 2026-01-15
1323last_modified: 2026-04-12
1324level: M0
1325tags: backend, api
1326---
1327# Test Entity
1328
1329## Identity
1330
1331This is a test entity.
1332
1333## Purpose
1334
1335Testing the parser.
1336
1337## Relationships
1338
1339- **USES**: [[other-entity]]
1340
1341## Specifies
1342
1343Some specification content with [[inline-link]].
1344";
1345        let result = parse_markdown(md, "test-entity.md", &spec_schema(), "specs").unwrap();
1346        let entity = &result.entity;
1347        assert_eq!(entity.id.0, "specs--test-entity");
1348        assert_eq!(entity.title, "Test Entity");
1349        assert_eq!(entity.mem, "specs");
1350        assert_eq!(
1351            entity.metadata["type"],
1352            MetadataValue::String("spec".to_string())
1353        );
1354        assert_eq!(
1355            entity.metadata["level"],
1356            MetadataValue::String("M0".to_string())
1357        );
1358        assert_eq!(
1359            entity.metadata["tags"],
1360            MetadataValue::String("backend, api".to_string())
1361        );
1362        assert_eq!(entity.sections["identity"], "This is a test entity.");
1363        assert_eq!(entity.sections["purpose"], "Testing the parser.");
1364        assert_eq!(entity.relationships.len(), 1);
1365        assert_eq!(entity.relationships[0].rel_type, "USES");
1366        assert_eq!(entity.relationships[0].target.0, "specs--other-entity");
1367        assert_eq!(result.inline_links.len(), 1);
1368        assert_eq!(result.inline_links[0].0, "specs--inline-link");
1369    }
1370
1371    #[test]
1372    fn parse_full_entity_memo_schema() {
1373        let md = "\
1374---
1375type: memo
1376created_date: 2026-01-15
1377last_modified: 2026-04-12
1378status: active
1379tags: decision, architecture
1380---
1381# Use Sled For Storage
1382
1383## Claim
1384
1385Sled is the right embedded store for this workload.
1386
1387## Context
1388
1389We evaluated sled, rocksdb, and sqlite for the in-process graph cache.
1390
1391## Substance
1392
1393Sled wins on pure-Rust dependency footprint.
1394";
1395        let result = parse_markdown(md, "use-sled.md", &memo_schema(), "memos").unwrap();
1396        let entity = &result.entity;
1397        assert_eq!(entity.id.0, "memos--use-sled");
1398        assert_eq!(entity.title, "Use Sled For Storage");
1399        assert_eq!(entity.mem, "memos");
1400        assert_eq!(
1401            entity.metadata["type"],
1402            MetadataValue::String("memo".to_string())
1403        );
1404        assert_eq!(
1405            entity.metadata["status"],
1406            MetadataValue::String("active".to_string())
1407        );
1408        assert_eq!(
1409            entity.sections["claim"],
1410            "Sled is the right embedded store for this workload."
1411        );
1412        assert_eq!(
1413            entity.sections["context"],
1414            "We evaluated sled, rocksdb, and sqlite for the in-process graph cache."
1415        );
1416        assert_eq!(
1417            entity.sections["substance"],
1418            "Sled wins on pure-Rust dependency footprint."
1419        );
1420        assert!(!entity.sections.contains_key("identity"));
1421        assert!(!entity.sections.contains_key("purpose"));
1422    }
1423
1424    #[test]
1425    fn parse_entity_without_frontmatter() {
1426        let md = "# No Frontmatter\n\n## Identity\n\nJust a title and section.";
1427        let result = parse_markdown(md, "no-fm.md", &spec_schema(), "specs").unwrap();
1428        assert_eq!(result.entity.title, "No Frontmatter");
1429        // Only the auto-injected type field should be present
1430        assert_eq!(result.entity.metadata.len(), 1);
1431        assert_eq!(
1432            result.entity.metadata.get("type"),
1433            Some(&MetadataValue::String("spec".to_string()))
1434        );
1435    }
1436
1437    #[test]
1438    fn parse_entity_code_blocks_not_detected() {
1439        let md = "\
1440---
1441type: spec
1442---
1443# Code Test
1444
1445## Identity
1446
1447Test entity.
1448
1449## Specifies
1450
1451```
1452## Not A Section
1453- **USES**: [[not-a-link]]
1454```
1455
1456Real content after code block.
1457";
1458        let result = parse_markdown(md, "code-test.md", &spec_schema(), "specs").unwrap();
1459        // The ## inside code block should NOT be parsed as a section
1460        assert!(!result.entity.sections.contains_key("not a section"));
1461        // The wiki-link inside code block should NOT be extracted
1462        assert!(result.inline_links.is_empty());
1463    }
1464
1465    // Fixture pinned by the adversarial harness (seed 0x5eedf001, case 224):
1466    // a BOM'd file parsed tolerantly as all-body, losing its entire
1467    // frontmatter, while the strict validator and the archive path stripped
1468    // the mark and saw it. That divergence is why the mark is now stripped in
1469    // `split_frontmatter_core` and nowhere else: one implementation cannot
1470    // disagree with itself about where a document begins.
1471    #[test]
1472    fn bom_prefixed_frontmatter_is_recognized() {
1473        let md = "\u{feff}---\ntype: spec\n---\n# Bom Entity\n\n## Identity\n\nBody.\n";
1474        assert_eq!(peek_type_from_frontmatter(md), Some("spec".to_string()));
1475        assert_eq!(
1476            body_after_frontmatter(md),
1477            "# Bom Entity\n\n## Identity\n\nBody.\n"
1478        );
1479        let (meta, body) = split_frontmatter(md).unwrap();
1480        assert_eq!(meta, "type: spec");
1481        assert_eq!(body, "# Bom Entity\n\n## Identity\n\nBody.\n");
1482        let result = parse_markdown(md, "bom.md", &spec_schema(), "specs").unwrap();
1483        assert_eq!(
1484            result.entity.metadata["type"],
1485            MetadataValue::String("spec".to_string())
1486        );
1487        assert_eq!(result.entity.sections["identity"], "Body.");
1488    }
1489
1490    // Fixture pinned by the adversarial harness (seed 0x5eedf001, case 46):
1491    // a section whose content ends inside an open code fence absorbed every
1492    // section the generator wrote after it on the next parse — content
1493    // shifted between sections and the document GREW on every
1494    // parse→generate round. The generator now terminates the open fence;
1495    // the first round normalises, then parse→generate is a fixpoint.
1496    #[test]
1497    fn open_fence_in_section_content_does_not_swallow_following_sections() {
1498        let md = "\
1499---
1500type: spec
1501---
1502# Code Test
1503
1504## Identity
1505
1506Base.
1507
1508## Specifies
1509
1510```
1511truncated code with no closer";
1512        let schema = spec_schema();
1513        let e1 = parse_markdown(md, "open-fence.md", &schema, "specs").unwrap();
1514        let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1515        let e2 = parse_markdown(&m1, "open-fence.md", &schema, "specs").unwrap();
1516        assert_eq!(
1517            e2.entity.sections["identity"], "Base.",
1518            "sections before the open fence survive"
1519        );
1520        assert!(
1521            !e2.entity.sections["specifies"].contains("## Constraints"),
1522            "the generated sections after the fence are not absorbed into it"
1523        );
1524        let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1525        assert_eq!(
1526            m1, m2,
1527            "parse→generate is a fixpoint after one normalising round"
1528        );
1529    }
1530
1531    // Fixture pinned by the adversarial harness (seed 0x5eedf001, case 26):
1532    // a document carrying MULTIPLE non-schema sections — reachable on the
1533    // tolerant local-read path, which refuses nothing — reconstructed its
1534    // catch-all in HashMap iteration order, so canonical bytes differed
1535    // from parse to parse of the same input. The catch-all must re-emit
1536    // non-schema sections in document order, and parse→generate must be
1537    // idempotent for such input.
1538    #[test]
1539    fn catch_all_reconstruction_is_document_ordered_and_idempotent() {
1540        let md = "\
1541---
1542type: spec
1543---
1544# Multi Unknown
1545
1546## Identity
1547
1548Base.
1549
1550## Claim
1551
1552First unknown.
1553
1554## Context
1555
1556Second unknown.
1557
1558## Substance
1559
1560Third unknown.
1561";
1562        let schema = spec_schema();
1563        let e1 = parse_markdown(md, "multi-unknown.md", &schema, "specs").unwrap();
1564        assert_eq!(
1565            e1.entity.sections["specifies"],
1566            "## Claim\nFirst unknown.\n\n## Context\nSecond unknown.\n\n## Substance\nThird unknown.",
1567            "non-schema sections land in the catch-all in document order"
1568        );
1569        let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1570        let e2 = parse_markdown(&m1, "multi-unknown.md", &schema, "specs").unwrap();
1571        let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1572        assert_eq!(
1573            m1, m2,
1574            "parse→generate is idempotent over multi-unknown-section input"
1575        );
1576    }
1577
1578    // Fixture pinned by the coverage-guided long tier (local run,
1579    // 2026-08-24; corpus member `crash-fd71330e…`): parse_markdown
1580    // re-trimmed every section value after split_sections had already
1581    // normalised it, silently promoting a whitespace-prefixed first
1582    // line (vertical tab + backticks) to column 0 — where the
1583    // CommonMark referee saw a fence opener the stored form did not
1584    // have, so the section structure shifted between rounds. The
1585    // splitter's trim is the only content trim.
1586    #[test]
1587    fn first_line_whitespace_prefix_survives_storage_and_round_trips() {
1588        let schema = spec_schema();
1589        let md = "---\ntype: spec\n---\n# T\n\n## Identity\n\u{b}```\nx\n\n## Purpose\np\n";
1590        let e1 = parse_markdown(md, "vt.md", &schema, "specs").unwrap();
1591        assert_eq!(
1592            e1.entity.sections["identity"], "\u{b}```\nx",
1593            "the first visible line keeps its whitespace prefix byte-exactly"
1594        );
1595        let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1596        let e2 = parse_markdown(&m1, "vt.md", &schema, "specs").unwrap();
1597        let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1598        assert_eq!(m1, m2, "parse→generate is a fixpoint");
1599    }
1600
1601    // Fixture pinned by the coverage-guided long tier (0.17.0 release
1602    // readiness run, 2026-09-02; corpus member `crash-1233c134…`): a
1603    // non-schema section whose content ends inside an HTML block of a
1604    // kind no blank line ends (`<!X`, type 4) was merged in front of a
1605    // later piece. In situ a `>` line in the catch-all's own content had
1606    // closed the block, so the later piece's tilde fence opened and
1607    // masked a `## ` line into content; merged, the block stayed open,
1608    // the fence became prose, the masked line surfaced as an empty
1609    // non-schema heading, and the next round dropped it. The merge's
1610    // incremental context close now terminates HTML blocks like fences.
1611    #[test]
1612    fn merged_open_html_block_is_closed_so_later_fences_keep_masking() {
1613        let schema = spec_schema();
1614        let md = "\
1615---
1616type: spec
1617---
1618# T
1619
1620## Claim
1621<!X
1622
1623## Specifies
1624done >
1625
1626## Later
1627~~~
1628## Hidden
1629";
1630        let e1 = parse_markdown(md, "html.md", &schema, "specs").unwrap();
1631        assert_eq!(
1632            e1.entity.sections["specifies"],
1633            "done >\n\n## Claim\n<!X\n>\n\n## Later\n~~~\n## Hidden\n~~~",
1634            "the open HTML block closes before the next piece, the dangling fence after it"
1635        );
1636        let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1637        let e2 = parse_markdown(&m1, "html.md", &schema, "specs").unwrap();
1638        let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1639        assert_eq!(m1, m2, "parse→generate is a fixpoint");
1640    }
1641
1642    // Fixture pinned by the coverage-guided long tier (first dispatch,
1643    // 2026-08-24; corpus member `crash-de0c69e0…`): the splitter's full
1644    // content trim promoted an INDENTED heading-lookalike on the first
1645    // content line (` ## Specifies`) to column 0 inside stored content;
1646    // the catch-all re-emit then made the next parse read it as a real
1647    // duplicate section heading, whose first-wins rule dropped the
1648    // content — structure from content, and a broken fixpoint. Leading
1649    // blank lines still drop; the first visible line keeps its
1650    // indentation.
1651    #[test]
1652    fn indented_heading_lookalike_stays_content_and_round_trips() {
1653        let md = "\
1654---
1655type: spec
1656---
1657# Promoted Heading
1658
1659## Identity
1660
1661Base.
1662
1663## Unknown Extra
1664
1665 ## Specifies
1666
1667Some content that must survive.
1668";
1669        let schema = spec_schema();
1670        let e1 = parse_markdown(md, "indent.md", &schema, "specs").unwrap();
1671        assert!(
1672            e1.entity.sections["specifies"].contains(" ## Specifies"),
1673            "the indented lookalike keeps its indentation inside the catch-all"
1674        );
1675        assert!(
1676            e1.entity.sections["specifies"].contains("Some content that must survive."),
1677            "content after the lookalike is preserved"
1678        );
1679        let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1680        let e2 = parse_markdown(&m1, "indent.md", &schema, "specs").unwrap();
1681        let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1682        assert_eq!(
1683            m1, m2,
1684            "parse→generate is a fixpoint after one normalising round"
1685        );
1686        assert!(
1687            e2.entity.sections["specifies"].contains("Some content that must survive."),
1688            "no content is lost across rounds"
1689        );
1690    }
1691
1692    #[test]
1693    fn compute_hash_deterministic() {
1694        let hash1 = compute_hash("test content");
1695        let hash2 = compute_hash("test content");
1696        assert_eq!(hash1, hash2);
1697        assert_eq!(hash1.len(), 16);
1698    }
1699
1700    #[test]
1701    fn compute_hash_differs() {
1702        let hash1 = compute_hash("content a");
1703        let hash2 = compute_hash("content b");
1704        assert_ne!(hash1, hash2);
1705    }
1706
1707    #[test]
1708    fn is_float_literal_matches() {
1709        assert!(is_float_literal("0.85"));
1710        assert!(is_float_literal("-1.5"));
1711        assert!(is_float_literal("100.0"));
1712        assert!(!is_float_literal(".5"));
1713        assert!(!is_float_literal("1."));
1714        assert!(!is_float_literal("42"));
1715        assert!(!is_float_literal("hello"));
1716    }
1717
1718    #[test]
1719    fn is_integer_literal_matches() {
1720        assert!(is_integer_literal("42"));
1721        assert!(is_integer_literal("-1"));
1722        assert!(is_integer_literal("0"));
1723        assert!(!is_integer_literal("0.5"));
1724        assert!(!is_integer_literal("hello"));
1725        assert!(!is_integer_literal(""));
1726    }
1727
1728    // Regression lock for metadata-key order. The parser reads frontmatter
1729    // line-by-line into an IndexMap, so metadata iteration yields the file's
1730    // declared key order. Render sites iterate entity.metadata directly (see
1731    // `render::render_entity_markdown`), so any regression to HashMap
1732    // reintroduces hash-seed-dependent frontmatter ordering in MCP output.
1733    #[test]
1734    fn parse_preserves_frontmatter_key_order() {
1735        let md = "\
1736---
1737type: principle
1738universality: domain-wide
1739authority: proposed
1740tags: a, b, c
1741created_date: 2026-01-15
1742last_modified: 2026-04-12
1743---
1744# Key Order
1745";
1746        let result = parse_markdown(
1747            md,
1748            "key-order.md",
1749            &type_by_name(builtin_names::PRINCIPLE).unwrap(),
1750            "knowledge",
1751        )
1752        .unwrap();
1753        let keys: Vec<&str> = result.entity.metadata.keys().map(|s| s.as_str()).collect();
1754        assert_eq!(
1755            keys,
1756            vec![
1757                "type",
1758                "universality",
1759                "authority",
1760                "tags",
1761                "created_date",
1762                "last_modified",
1763            ],
1764            "metadata iteration must preserve frontmatter declaration order"
1765        );
1766    }
1767
1768    // Regression lock for section-order round-trip stability. Today this
1769    // passes by construction: the parser inserts keys in schema-declared
1770    // order, the generator writes them in schema-declared order, and
1771    // `IndexMap` preserves that order across re-parses. HashMap iteration
1772    // order was the hole — an IndexMap-based entity.sections closes it.
1773    // Keep the test; if a future refactor reintroduces a HashMap anywhere on
1774    // the parse/write path, this catches it.
1775    #[test]
1776    fn parse_write_roundtrip_preserves_section_order() {
1777        let md = "\
1778---
1779type: spec
1780created_date: 2026-01-15
1781last_modified: 2026-04-12
1782level: M0
1783---
1784# Order Roundtrip
1785
1786## Identity
1787
1788Identity content.
1789
1790## Purpose
1791
1792Purpose content.
1793
1794## Specifies
1795
1796Specifies content.
1797";
1798        let schema = spec_schema();
1799        let first = parse_markdown(md, "order-roundtrip.md", &schema, "specs").unwrap();
1800        let regenerated = crate::entity::generator::generate_markdown(&first.entity, &schema);
1801        let second = parse_markdown(&regenerated, "order-roundtrip.md", &schema, "specs").unwrap();
1802
1803        let first_keys: Vec<&String> = first.entity.sections.keys().collect();
1804        let second_keys: Vec<&String> = second.entity.sections.keys().collect();
1805        assert_eq!(
1806            first_keys, second_keys,
1807            "section iteration order must survive parse -> generate -> parse"
1808        );
1809    }
1810
1811    // ------------------------------------------------------------------
1812    // Heading-spans extraction (H3–H6)
1813    //
1814    // These lock the parser contract: one extra pass per section that
1815    // records H3+ headings as a side-struct. Flat storage; level skips
1816    // are tolerated; code blocks are ignored. See
1817    // `extract_heading_spans`.
1818    // ------------------------------------------------------------------
1819
1820    #[test]
1821    fn parser_extracts_single_h3() {
1822        let md = "\
1823---
1824type: spec
1825---
1826# Entity
1827
1828## Identity
1829
1830Body.
1831
1832## Specifies
1833
1834### Response Shapes
1835
1836Content under response shapes.
1837";
1838        let result = parse_markdown(md, "h3-single.md", &spec_schema(), "specs").unwrap();
1839        let spans = result
1840            .entity
1841            .heading_spans
1842            .get("specifies")
1843            .expect("specifies section should have spans");
1844        assert_eq!(spans.len(), 1);
1845        assert_eq!(spans[0].level, 3);
1846        assert_eq!(spans[0].title, "Response Shapes");
1847        // The section is trimmed, so the H3 sits at offset 0.
1848        assert_eq!(spans[0].start_offset, 0);
1849        let section = result.entity.sections.get("specifies").unwrap();
1850        assert_eq!(spans[0].end_offset, section.len());
1851        // Non-specifies sections either get no entry or the content has no H3+ headings.
1852        assert!(
1853            result
1854                .entity
1855                .heading_spans
1856                .get("identity")
1857                .is_none_or(Vec::is_empty)
1858        );
1859    }
1860
1861    #[test]
1862    fn parser_extracts_nested_h3_h4() {
1863        let md = "\
1864---
1865type: spec
1866---
1867# Entity
1868
1869## Identity
1870
1871Body.
1872
1873## Specifies
1874
1875### Outer
1876
1877Outer body.
1878
1879#### Inner
1880
1881Inner body.
1882";
1883        let result = parse_markdown(md, "h3-h4.md", &spec_schema(), "specs").unwrap();
1884        let spans = result.entity.heading_spans.get("specifies").unwrap();
1885        assert_eq!(spans.len(), 2, "both H3 and H4 must be recorded");
1886        assert_eq!(spans[0].level, 3);
1887        assert_eq!(spans[0].title, "Outer");
1888        assert_eq!(spans[1].level, 4);
1889        assert_eq!(spans[1].title, "Inner");
1890        assert!(
1891            spans[0].start_offset < spans[1].start_offset,
1892            "spans must be in document order"
1893        );
1894        // H3 contains H4: H3.end_offset must cover H4.start_offset.
1895        assert!(
1896            spans[0].end_offset > spans[1].start_offset,
1897            "outer H3 must contain inner H4 by offset"
1898        );
1899    }
1900
1901    #[test]
1902    fn parser_ignores_headings_in_code_blocks() {
1903        let md = "\
1904---
1905type: spec
1906---
1907# Entity
1908
1909## Identity
1910
1911Body.
1912
1913## Specifies
1914
1915Prefix.
1916
1917```
1918### Not a heading
1919Still code.
1920```
1921
1922Suffix.
1923";
1924        let result = parse_markdown(md, "h3-code.md", &spec_schema(), "specs").unwrap();
1925        let spans = result
1926            .entity
1927            .heading_spans
1928            .get("specifies")
1929            .cloned()
1930            .unwrap_or_default();
1931        assert!(
1932            spans.is_empty(),
1933            "a '### ' inside a fenced block must not register as a heading span: {spans:?}"
1934        );
1935    }
1936
1937    #[test]
1938    fn parser_handles_level_skip() {
1939        let md = "\
1940---
1941type: spec
1942---
1943# Entity
1944
1945## Identity
1946
1947Body.
1948
1949## Specifies
1950
1951#### Skipped To H4
1952
1953Content under a sudden H4 — no virtual H3 is inserted.
1954";
1955        let result = parse_markdown(md, "h2-h4.md", &spec_schema(), "specs").unwrap();
1956        let spans = result.entity.heading_spans.get("specifies").unwrap();
1957        assert_eq!(spans.len(), 1);
1958        assert_eq!(spans[0].level, 4);
1959        assert_eq!(spans[0].title, "Skipped To H4");
1960    }
1961
1962    #[test]
1963    fn parser_handles_duplicate_siblings() {
1964        let md = "\
1965---
1966type: spec
1967---
1968# Entity
1969
1970## Identity
1971
1972Body.
1973
1974## Specifies
1975
1976### Same Title
1977
1978First occurrence body.
1979
1980### Same Title
1981
1982Second occurrence body.
1983";
1984        let result = parse_markdown(md, "h3-dup.md", &spec_schema(), "specs").unwrap();
1985        let spans = result.entity.heading_spans.get("specifies").unwrap();
1986        assert_eq!(spans.len(), 2, "duplicate siblings must produce two spans");
1987        assert_eq!(spans[0].title, spans[1].title);
1988        assert_ne!(
1989            spans[0].start_offset, spans[1].start_offset,
1990            "spans with identical titles must be distinguishable by offset"
1991        );
1992        // Siblings at the same level: neither contains the other.
1993        assert!(
1994            spans[0].end_offset <= spans[1].start_offset,
1995            "first sibling must close before the second starts"
1996        );
1997    }
1998
1999    // Duplicate `## Heading` lines for a schema-declared key collapse to the
2000    // first occurrence's body and emit a `DuplicateSectionHeading` warning.
2001    // Catch-all keys absorb arbitrary headings by design and do not warn.
2002
2003    #[test]
2004    fn duplicate_declared_heading_two_populated_keeps_first_warns() {
2005        let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nfirst body\n\n## Identity\n\nsecond body\n";
2006        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2007        assert_eq!(
2008            result.entity.sections.get("identity").map(String::as_str),
2009            Some("first body"),
2010            "first body must win"
2011        );
2012        assert!(
2013            !result
2014                .entity
2015                .sections
2016                .get("identity")
2017                .unwrap()
2018                .contains("## Identity"),
2019            "storage value must not embed a duplicate heading"
2020        );
2021        assert_eq!(result.parse_warnings.len(), 1);
2022        match &result.parse_warnings[0] {
2023            crate::ops::WarningHint::DuplicateSectionHeading {
2024                section_key,
2025                heading,
2026                occurrences,
2027                ..
2028            } => {
2029                assert_eq!(section_key, "identity");
2030                assert_eq!(heading, "Identity");
2031                assert_eq!(*occurrences, 2);
2032            }
2033            other => panic!("expected DuplicateSectionHeading, got {other:?}"),
2034        }
2035    }
2036
2037    #[test]
2038    fn duplicate_declared_heading_blank_then_populated_keeps_blank() {
2039        // First-wins is mechanical: a blank first occurrence wins over a
2040        // populated second one. The warning surfaces so the operator
2041        // notices content was discarded.
2042        let md =
2043            "---\ntype: spec\n---\n# Title\n\n## Identity\n\n## Identity\n\nleftover content\n";
2044        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2045        assert_eq!(
2046            result.entity.sections.get("identity").map(String::as_str),
2047            Some(""),
2048            "first (blank) occurrence wins; second body is dropped"
2049        );
2050        assert_eq!(result.parse_warnings.len(), 1);
2051    }
2052
2053    #[test]
2054    fn duplicate_declared_heading_three_occurrences() {
2055        let md = "---\ntype: spec\n---\n# Title\n\n## Constraints\n\nA\n\n## Constraints\n\n## Constraints\n\nC\n";
2056        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2057        assert_eq!(
2058            result
2059                .entity
2060                .sections
2061                .get("constraints")
2062                .map(String::as_str),
2063            Some("A"),
2064        );
2065        assert_eq!(result.parse_warnings.len(), 1);
2066        match &result.parse_warnings[0] {
2067            crate::ops::WarningHint::DuplicateSectionHeading { occurrences, .. } => {
2068                assert_eq!(*occurrences, 3);
2069            }
2070            _ => unreachable!(),
2071        }
2072    }
2073
2074    #[test]
2075    fn no_warning_when_each_declared_section_appears_once() {
2076        let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Purpose\n\nP\n\n## Constraints\n\nC\n";
2077        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2078        assert!(result.parse_warnings.is_empty());
2079    }
2080
2081    #[test]
2082    fn no_warning_when_catch_all_section_repeats() {
2083        // `specifies` is the spec schema's catch-all section. Repetition
2084        // there is silent — duplicates only warn for non-catch-all keys.
2085        let md =
2086            "---\ntype: spec\n---\n# Title\n\n## Specifies\n\nfirst\n\n## Specifies\n\nsecond\n";
2087        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2088        assert!(
2089            result.parse_warnings.is_empty(),
2090            "catch-all repetition must not warn"
2091        );
2092    }
2093
2094    // Three `## Realization` headings on a spec entity. The default-schema
2095    // `spec` does not declare `realization`, so it flows to the catch-all
2096    // `specifies` bucket and emits no warning, but the storage must still
2097    // not concatenate duplicate heading bytes — that was the bug being
2098    // fixed. Workspaces that declare `realization` (e.g. `software@0.1.0`)
2099    // additionally surface a `DuplicateSectionHeading` warning.
2100    #[test]
2101    fn duplicate_realization_does_not_concatenate_headers_in_storage() {
2102        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";
2103        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2104        let catch_all = result.entity.sections.get("specifies").unwrap();
2105        let header_count = catch_all.matches("## Realization").count();
2106        assert!(
2107            header_count <= 1,
2108            "catch-all bucket must not contain multiple `## Realization` headers — got {header_count}: {catch_all:?}"
2109        );
2110    }
2111
2112    // After a parse → render round-trip, an entity that was loaded from a
2113    // markdown file with three `## Identity` headings emits exactly one
2114    // `## Identity` heading on re-render. This is the self-heal contract:
2115    // the next read-modify-write of a duplicate-heading entity collapses
2116    // the markdown to one heading per declared section.
2117    #[test]
2118    fn parse_render_round_trip_collapses_duplicate_headings() {
2119        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";
2120        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2121        let rendered = crate::render::render_entity_markdown(&result.entity, None);
2122        let identity_count = rendered.matches("## Identity").count();
2123        assert_eq!(
2124            identity_count, 1,
2125            "rendered output must carry exactly one `## Identity`, got {identity_count}: {rendered}"
2126        );
2127        // First-wins: the rendered Identity body is `A`, not `C`.
2128        assert!(rendered.contains("\n## Identity\n\nA\n"));
2129        assert!(!rendered.contains("C\n"), "second body must not survive");
2130    }
2131}
2132
2133/// End-to-end pins for the six verified code-block misparse classes,
2134/// on the real entity paths: section splitting, title extraction,
2135/// heading spans, and wiki-link extraction. The unit-level pins for
2136/// the mask itself live in [`crate::markdown`]; these assert the
2137/// classes are actually fixed where they did damage.
2138#[cfg(test)]
2139mod commonmark_referee {
2140    use super::*;
2141    use memstead_schema::{builtin_names, type_by_name};
2142    use std::sync::Arc;
2143
2144    fn spec_schema() -> Arc<TypeDefinition> {
2145        type_by_name(builtin_names::SPEC).unwrap()
2146    }
2147
2148    /// Wrap a `## Specifies` body in a minimal, valid spec entity.
2149    fn entity_with_specifies(body: &str) -> ParseResult {
2150        let md = format!(
2151            "---\ntype: spec\n---\n\n# Referee Test\n\n## Identity\n\nx\n\n## Specifies\n\n{body}\n"
2152        );
2153        parse_markdown(&md, "referee-test.md", &spec_schema(), "specs").unwrap()
2154    }
2155
2156    fn headings(result: &ParseResult) -> Vec<&str> {
2157        result
2158            .entity
2159            .raw_section_headings
2160            .iter()
2161            .map(String::as_str)
2162            .collect()
2163    }
2164
2165    fn link_targets(result: &ParseResult) -> Vec<String> {
2166        result.inline_links.iter().map(|id| id.0.clone()).collect()
2167    }
2168
2169    /// The complement first: without it, every assertion below could
2170    /// pass because the paths do nothing at all.
2171    #[test]
2172    fn complement_prose_headings_and_links_still_work() {
2173        let r = entity_with_specifies("See [[real-target]] here.");
2174        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2175        assert_eq!(link_targets(&r), vec!["specs--real-target".to_string()]);
2176        assert_eq!(r.entity.title, "Referee Test");
2177    }
2178
2179    #[test]
2180    fn class_1_indented_code_block() {
2181        let r = entity_with_specifies("Example:\n\n    ## Not A Section\n    [[not-a-link]]\n");
2182        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2183        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2184    }
2185
2186    #[test]
2187    fn class_2_fence_indented_one_to_three_spaces() {
2188        let r = entity_with_specifies(
2189            "- item\n\n   ```\n   ## Not A Section\n   [[not-a-link]]\n   ```\n",
2190        );
2191        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2192        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2193    }
2194
2195    #[test]
2196    fn class_3_tilde_fence() {
2197        let r = entity_with_specifies("~~~\n## Not A Section\n[[not-a-link]]\n~~~\n");
2198        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2199        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2200    }
2201
2202    #[test]
2203    fn class_4_info_string_on_the_closing_line() {
2204        let r = entity_with_specifies(
2205            "```\ncode\n``` still-code\n## Not A Section\n[[not-a-link]]\n```\n",
2206        );
2207        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2208        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2209    }
2210
2211    #[test]
2212    fn class_5_fence_inside_a_blockquote() {
2213        let r = entity_with_specifies("> ```\n> ## Not A Section\n> [[not-a-link]]\n> ```\n");
2214        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2215        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2216    }
2217
2218    #[test]
2219    fn class_6_opening_fence_length_is_honoured_on_close() {
2220        let r = entity_with_specifies("````\n```\n## Not A Section\n[[not-a-link]]\n```\n````\n");
2221        assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2222        assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2223    }
2224
2225    /// The title extractor was the one scanner with no masking at all.
2226    #[test]
2227    fn a_heading_inside_a_code_block_never_becomes_the_title() {
2228        let md =
2229            "---\ntype: spec\n---\n\n```\n# Fake Title\n```\n\n# Real Title\n\n## Identity\n\nx\n";
2230        let r = parse_markdown(md, "title-test.md", &spec_schema(), "specs").unwrap();
2231        assert_eq!(r.entity.title, "Real Title");
2232    }
2233
2234    /// …and when the code block is the whole body, the filename
2235    /// fallback applies rather than a title mined out of code.
2236    #[test]
2237    fn a_code_block_only_body_falls_back_to_the_filename() {
2238        let md = "---\ntype: spec\n---\n\n    # Fake Title\n\n## Identity\n\nx\n";
2239        let r = parse_markdown(md, "fallback-test.md", &spec_schema(), "specs").unwrap();
2240        assert_eq!(r.entity.title, "fallback-test");
2241    }
2242
2243    #[test]
2244    fn heading_spans_ignore_code_block_content() {
2245        let r = entity_with_specifies("### Real Sub\n\n~~~\n### Fake Sub\n~~~\n");
2246        let spans = r.entity.heading_spans.get("specifies").expect("spans");
2247        let titles: Vec<&str> = spans.iter().map(|s| s.title.as_str()).collect();
2248        assert_eq!(titles, vec!["Real Sub"]);
2249    }
2250
2251    /// One definition of "not visible to a link scanner": a link the
2252    /// strict validator cannot see is a link no path turns into an
2253    /// edge. Multi-backtick spans are the case a delimiter-count regex
2254    /// slices through.
2255    #[test]
2256    fn inline_code_spans_hide_links_on_the_extraction_path() {
2257        let r = entity_with_specifies("`[[hidden-one]]` and ``[[hidden-two]]`` but [[visible]].");
2258        assert_eq!(link_targets(&r), vec!["specs--visible".to_string()]);
2259    }
2260
2261    /// The empty-target asymmetry: the strict extractor now *sees*
2262    /// `[[]]` and routes it to the same refusal the validator emits,
2263    /// instead of a pattern that could not match it at all.
2264    #[test]
2265    fn empty_wiki_link_target_is_refused_by_the_strict_extractor() {
2266        let errors = extract_inline_links("an empty [[]] link", "specs")
2267            .expect_err("empty target must refuse");
2268        assert_eq!(errors.len(), 1, "{errors:?}");
2269    }
2270
2271    /// The read side tolerates drift by ignoring what it cannot
2272    /// decode — but it sees the same token the strict side refuses.
2273    #[test]
2274    fn empty_wiki_link_target_yields_no_id_on_the_lenient_path() {
2275        assert!(extract_inline_links_lenient("an empty [[]] link", "specs").is_empty());
2276    }
2277
2278    /// A conflicted file must be refused however its frontmatter is
2279    /// shaped. Masking the whole file let a YAML value that reads as a
2280    /// fence opener blank the body — markers included — so the file
2281    /// loaded as a normal entity with BOTH merge sides fused into one
2282    /// body, which is the exact outcome the guard exists to prevent.
2283    #[test]
2284    fn merge_conflict_markers_are_seen_through_fence_shaped_frontmatter() {
2285        let body = "\n# T\n\n## Identity\n\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n";
2286        for fm in [
2287            "---\ntype: spec\n---",
2288            "---\ntype: spec\nnotes: |\n  ```rust\n  fn x() {}\n---",
2289            "---\ntype: spec\nnotes: |\n   ~~~\n---",
2290            "---\ntype: spec\nnotes: |\n    indented block\n---",
2291        ] {
2292            assert!(
2293                has_merge_conflict_markers(&format!("{fm}{body}")),
2294                "conflict markers must be seen through frontmatter: {fm:?}"
2295            );
2296        }
2297    }
2298
2299    /// …and markers in the frontmatter itself count: git writes them
2300    /// wherever the hunks fall, including above the `---`.
2301    #[test]
2302    fn merge_conflict_markers_in_frontmatter_are_seen() {
2303        let content =
2304            "---\n<<<<<<< HEAD\ntype: spec\n=======\ntype: memo\n>>>>>>> branch\n---\n\n# T\n";
2305        assert!(has_merge_conflict_markers(content));
2306    }
2307
2308    /// Complement: a fenced code example documenting conflict markers
2309    /// in a section body still does not trip the guard.
2310    #[test]
2311    fn a_fenced_conflict_marker_example_still_does_not_trip_the_guard() {
2312        let content = "---\ntype: spec\n---\n\n# T\n\n## Identity\n\n```\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n```\n";
2313        assert!(!has_merge_conflict_markers(content));
2314    }
2315
2316    /// A relationship row inside a code block is an example of the
2317    /// syntax, not a relationship. It used to become a live edge and an
2318    /// auto-stub while the strict validator — which masks — could not
2319    /// see the link at all: one path synthesising an edge from what
2320    /// another path refuses to see.
2321    #[test]
2322    fn a_relationship_row_inside_a_code_block_is_not_a_relationship() {
2323        for body in [
2324            "```\n- **REFERENCES**: [[ghost]]\n```",
2325            "~~~\n- **REFERENCES**: [[ghost]]\n~~~",
2326            "    - **REFERENCES**: [[ghost]]",
2327            "> ```\n> - **REFERENCES**: [[ghost]]\n> ```",
2328            "````\n```\n- **REFERENCES**: [[ghost]]\n```\n````",
2329        ] {
2330            let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2331            assert!(
2332                rels.is_empty(),
2333                "code-block row must not become an edge: {body:?} -> {rels:?}"
2334            );
2335        }
2336    }
2337
2338    /// …and a row hidden inside an INLINE CODE SPAN is not one either.
2339    /// A blocks-only mask left this row invisible to the strict
2340    /// validator and to every link extractor — both of which mask
2341    /// spans — while still building an edge and a stub from it. A
2342    /// multi-line span is the shape that bites: a lazy paragraph
2343    /// continuation keeps the backticks open across the row.
2344    #[test]
2345    fn a_relationship_row_inside_an_inline_code_span_is_not_a_relationship() {
2346        for body in [
2347            // The row is indented, so it does not interrupt the
2348            // paragraph as a list — it is a lazy continuation and the
2349            // backtick pair holds the span open across all three lines.
2350            // (At column 0 a `-` DOES start a list, the span never
2351            // forms, and the row is a real relationship — correctly.)
2352            "Example `open\n    - **REFERENCES**: [[ghost]]\nclose`",
2353            "A `- **REFERENCES**: [[ghost]]` sample.",
2354            "A ``- **REFERENCES**: [[ghost]]`` sample.",
2355        ] {
2356            let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2357            assert!(
2358                rels.is_empty(),
2359                "code-span row must not become an edge: {body:?} -> {rels:?}"
2360            );
2361        }
2362    }
2363
2364    /// Complement: a real row still parses, keeps its type, target and
2365    /// em-dash description, and still warns on an ambiguous delimiter —
2366    /// every captured span is read from the original, not the mask.
2367    #[test]
2368    fn real_relationship_rows_are_unchanged_by_the_mask() {
2369        let body = "- **REFERENCES**: [[alpha]]\n- **uses**: [[beta]] — because it must\n\n```\n- **REFERENCES**: [[ghost]]\n```\n";
2370        let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2371        assert_eq!(rels.len(), 2, "{rels:?}");
2372        assert_eq!(rels[0].rel_type, "REFERENCES");
2373        assert_eq!(rels[0].target.0, "specs--alpha");
2374        assert_eq!(rels[0].description, None);
2375        assert_eq!(
2376            rels[1].rel_type, "USES",
2377            "case is normalised from the original"
2378        );
2379        assert_eq!(rels[1].target.0, "specs--beta");
2380        assert_eq!(rels[1].description.as_deref(), Some("because it must"));
2381    }
2382
2383    #[test]
2384    fn ambiguous_delimiter_warning_still_fires_on_a_real_row() {
2385        let id = file_path_to_id("x.md", "specs");
2386        let (_, warnings) = parse_relationships_with_warnings(
2387            "- **REFERENCES**: [[alpha]] -- not an em dash\n",
2388            "specs",
2389            Some(&id),
2390        );
2391        assert_eq!(warnings.len(), 1, "{warnings:?}");
2392    }
2393
2394    /// Frontmatter is not markdown. Masking the whole file would hand
2395    /// a CommonMark parser YAML it can read as block structure: a
2396    /// value line that looks like a fence opener (legal at 1–3 spaces
2397    /// since the indented-fence class was fixed) would open a code
2398    /// block that runs past the closing `---` and mask the entire
2399    /// body — no title, no sections, no links. The split happens
2400    /// first; only the body is masked.
2401    #[test]
2402    fn frontmatter_never_opens_a_code_block_over_the_body() {
2403        for fm in [
2404            "notes: |\n  ```rust",
2405            "notes: |\n   ~~~",
2406            "notes: |\n  ```\n  still open",
2407            "notes: |\n    indented block\n",
2408        ] {
2409            let md = format!(
2410                "---\ntype: spec\n{fm}\n---\n\n# Real Title\n\n## Identity\n\nSee [[a-link]].\n"
2411            );
2412            let r = parse_markdown(&md, "fm-test.md", &spec_schema(), "specs").unwrap();
2413            assert_eq!(
2414                r.entity.title, "Real Title",
2415                "frontmatter ate the title: {fm:?}"
2416            );
2417            assert_eq!(
2418                headings(&r),
2419                vec!["Identity"],
2420                "frontmatter ate the sections: {fm:?}"
2421            );
2422            assert_eq!(
2423                link_targets(&r),
2424                vec!["specs--a-link".to_string()],
2425                "frontmatter ate the links: {fm:?}"
2426            );
2427        }
2428    }
2429}