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