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