Skip to main content

memstead_base/engine/
export_llms_txt.rs

1//! Render one mem as a single agent-readable Markdown document — the
2//! `/llms-full.txt` shape, shared by the served endpoint and
3//! `memstead export --format llms-txt`.
4//!
5//! The document is built to be *swallowed whole*: an agent whose goal is
6//! "understand this graph fast" reads one document instead of walking the
7//! graph entity by entity. That is why the shape is what it is — every
8//! non-stub entity exactly once, in stable order, with its type visible and
9//! its references resolved to links that work from inside the flat document.
10//!
11//! **This lives in the engine so the served and exported documents cannot
12//! drift.** It was previously deployment-local, and matching the deployed
13//! shape by copying it would have made "matches" a snapshot rather than a
14//! property: two copies of a document shape is exactly how a divergence
15//! starts. One renderer, two callers.
16//!
17//! Stubs never appear. A stub is the engine's placeholder for an unresolved
18//! reference, not content, and the document's own header promises every
19//! non-stub entity — so rendering one would make the header false.
20//!
21//! Empty sections are kept verbatim. An explicitly empty slot is signal to an
22//! agent: it says the schema asks for this and nobody has answered, which is
23//! different from the section not existing.
24
25use crate::entity::EntityId;
26use crate::render;
27
28/// Deployment-supplied inputs to the document header.
29///
30/// Everything here is context the *renderer* cannot know: who is serving the
31/// document and what else the reader should be pointed at. A CLI export has no
32/// deployment identity, so these are optional rather than defaulted — a header
33/// that invents an authority would be lying about provenance, which is the one
34/// thing this document exists to state plainly.
35#[derive(Debug, Clone, Default)]
36pub struct LlmsTxtContext {
37    /// The serving authority (a host). `None` for a CLI export, whose header
38    /// names the mem instead — no deployment is vouching for the bytes.
39    pub authority: Option<String>,
40    /// Absolute link prefix (origin + any gate prefix). Empty renders entity
41    /// references as the relative `entity/<id>`, which is what a document
42    /// exported to a file wants.
43    pub href_prefix: String,
44    /// Cross-origin links the serving surface wants an agent to see. Empty
45    /// omits the block entirely — a user exporting their own mem is not
46    /// advertising someone else's project.
47    pub wider_project: Vec<(String, String)>,
48}
49
50/// A markdown link for one entity. `[` / `]` in a title would break the link
51/// text, so they are folded to parentheses rather than escaped — the title is
52/// display text here, not data being round-tripped.
53pub fn entity_md_link(href_prefix: &str, id: &str, title: &str) -> String {
54    let text = title.replace('[', "(").replace(']', ")");
55    if href_prefix.is_empty() {
56        format!("[{text}](entity/{id})")
57    } else {
58        format!("[{text}]({href_prefix}/entity/{id})")
59    }
60}
61
62/// Drop the frontmatter block the shared entity render emits. The flat
63/// document surfaces the type as a visible line instead; the rest of the
64/// frontmatter is agent-budget metadata that would be noise repeated once per
65/// entity.
66pub fn strip_frontmatter(md: &str) -> String {
67    let mut lines = md.lines();
68    if lines.next() == Some("---") {
69        let mut closed = false;
70        let mut body: Vec<&str> = Vec::new();
71        for line in lines {
72            if !closed && line == "---" {
73                closed = true;
74                continue;
75            }
76            if closed {
77                body.push(line);
78            }
79        }
80        if closed {
81            return body.join("\n").trim_start_matches('\n').to_string();
82        }
83    }
84    md.to_string()
85}
86
87/// Rewrite `[[…]]` wiki-links to markdown links, resolving each occurrence
88/// under a three-rule precedence:
89///
90/// 1. **Qualified references** — the dash form (`[[mem--slug]]`) and the
91///    canonical colon form (`[[mem:slug]]`, the grammar's cross-mem shape,
92///    which hierarchical mems can only write this way) — unambiguous,
93///    always resolve; a qualified miss never falls through to the slug
94///    passes, because the author named a mem.
95/// 2. **Local bare slugs** — a slug present in the source mem binds there,
96///    however many other mems reuse it. That is the engine's authoring
97///    semantics: a bare wiki-link is a same-mem reference.
98/// 3. **Foreign bare slugs** — a slug owned by exactly one *other* mem
99///    resolves to it; a slug two foreign mems both own **stays raw text**.
100///    Guessing between them would fabricate a reference the author never
101///    made, and a visibly unresolved `[[slug]]` is the honest output.
102///
103/// **Code is never rewritten.** Resolution scans the engine's masked view —
104/// the same one every other reader uses — and slices from the original, so a
105/// fenced or inline code sample documenting wiki-link syntax comes out
106/// byte-identical. A hand-rolled string walk without masking is exactly the
107/// defect the HTML exporter was fixed for; sharing the masked view is what
108/// stops this renderer reintroducing it.
109///
110/// `id_titles` must contain **no stubs**. A stub is an unresolved reference
111/// the engine materialised, not content — and this document excludes stubs,
112/// so resolving a link to one would emit a link to a page the document itself
113/// does not contain. Worse, because an engine-authored bare wiki-link creates
114/// a *local* stub, stubs in the map make rule 2 match every bare slug and
115/// rules 3's foreign passes unreachable.
116pub fn linkify_wikilinks(
117    body: String,
118    id_titles: &[(String, String)],
119    href_prefix: &str,
120    source_mem: &str,
121) -> String {
122    let mut by_id: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
123    let mut local: std::collections::HashMap<&str, (&str, &str)> = std::collections::HashMap::new();
124    let mut foreign: std::collections::HashMap<&str, Option<(&str, &str)>> =
125        std::collections::HashMap::new();
126    for (id, title) in id_titles {
127        by_id.insert(id.as_str(), title.as_str());
128        if let Some((mem, slug)) = id.split_once("--") {
129            if mem == source_mem {
130                local.insert(slug, (id.as_str(), title.as_str()));
131            } else {
132                foreign
133                    .entry(slug)
134                    .and_modify(|e| *e = None)
135                    .or_insert(Some((id.as_str(), title.as_str())));
136            }
137        }
138    }
139
140    let resolve = |target: &str| -> Option<(String, String)> {
141        if let Some(title) = by_id.get(target) {
142            return Some((target.to_string(), title.to_string()));
143        }
144        // The canonical colon form (`mem:slug`) is the same qualified
145        // reference as the dash form one grammar tier over — the entity
146        // parser and both wiki-link decoders read it, and hierarchical mems
147        // can only be referenced this way. Canonicalise and look up; a miss
148        // stays a miss, because the author named a mem and guessing a slug
149        // pass instead would rebind the reference.
150        if !target.contains("::")
151            && let Some((prefix, slug)) = target.split_once(':')
152            && !prefix.is_empty()
153            && !slug.is_empty()
154        {
155            let id = crate::entity::EntityId::new(prefix, slug);
156            return by_id
157                .get(id.as_ref())
158                .map(|title| (id.to_string(), title.to_string()));
159        }
160        if let Some((id, title)) = local.get(target) {
161            return Some((id.to_string(), title.to_string()));
162        }
163        // `Some(None)` is an ambiguous foreign slug — deliberately unresolved.
164        foreign
165            .get(target)
166            .copied()
167            .flatten()
168            .map(|(id, title)| (id.to_string(), title.to_string()))
169    };
170
171    // Offsets come from the masked view; every byte emitted comes from the
172    // original, so masking can only change WHICH spans are rewritten, never
173    // the bytes of the ones that are not.
174    let masked = crate::markdown::mask_code_blocks_and_spans(&body);
175    let mut out = String::with_capacity(body.len());
176    let mut cursor = 0usize;
177    let bytes = masked.as_bytes();
178    let mut i = 0usize;
179    while i + 1 < bytes.len() {
180        if bytes[i] == b'['
181            && bytes[i + 1] == b'['
182            && let Some(rel) = masked[i + 2..].find("]]")
183        {
184            let inner_start = i + 2;
185            let inner_end = inner_start + rel;
186            // The link text is read from the ORIGINAL: the masked view is
187            // only a map of where code is.
188            let inner = &body[inner_start..inner_end];
189            // Only the TARGET half resolves; an author-supplied label wins
190            // as link text, because that is what the author chose a reader
191            // to see. Target normalisation is the decoders' own
192            // `strip_wiki_link_decorations` — `|label`, `#anchor`, `../`,
193            // `.md` — so this renderer reads exactly the grammar the parser
194            // and validators read, no form less. Two rounds of the same
195            // leak (the aliased form, then the colon form) came from
196            // hand-rolling a subset of it here.
197            let label = inner.split_once('|').map(|(_, l)| l.trim());
198            let target = crate::entity::id::strip_wiki_link_decorations(inner);
199            out.push_str(&body[cursor..i]);
200            match resolve(&target) {
201                Some((id, title)) => {
202                    out.push_str(&entity_md_link(href_prefix, &id, label.unwrap_or(&title)))
203                }
204                // Unresolvable: name it as PLAIN TEXT — never a link, never
205                // surviving `[[…]]` syntax.
206                //
207                // A link would invent a target: for an ambiguous foreign slug
208                // it would pick one of two arbitrarily, and for a stub target
209                // it would point at a page this document deliberately
210                // excludes. Leaving the brackets would put internal wiki-link
211                // syntax in front of a reader the header promised a
212                // self-contained document to. Plain text is the third option,
213                // and the only one that is neither a guess nor a leak.
214                // Print the label when the author gave one, else the target —
215                // never the raw `target|label` span, which would put an
216                // internal id and a pipe in front of the reader.
217                None => out.push_str(label.unwrap_or(&target)),
218            }
219            cursor = inner_end + 2;
220            i = inner_end + 2;
221            continue;
222        }
223        i += 1;
224    }
225    out.push_str(&body[cursor..]);
226    out
227}
228
229impl crate::Engine {
230    /// Render `mem` as one Markdown document in the `/llms-full.txt` shape.
231    ///
232    /// Refuses an unmounted mem with [`EngineError::UnknownMem`] rather than
233    /// emitting an empty document — a document that says "Entities: 0" about a
234    /// mem this workspace never mounted is a confident wrong answer, and the
235    /// caller can tell the two apart only if the engine does.
236    ///
237    /// [`EngineError::UnknownMem`]: crate::engine::EngineError::UnknownMem
238    pub fn render_llms_txt(
239        &self,
240        mem: &str,
241        ctx: &LlmsTxtContext,
242    ) -> Result<String, crate::engine::EngineError> {
243        self.render_llms_txt_scoped(mem, ctx, None)
244    }
245
246    /// [`Self::render_llms_txt`] reduced to a chain: only the mem's
247    /// entities in `chain` are rendered, wiki-links to entities outside
248    /// it stay raw (unresolved), and the header names the chain. `None`
249    /// is the whole mem, byte-identical to the unscoped document.
250    pub fn render_llms_txt_scoped(
251        &self,
252        mem: &str,
253        ctx: &LlmsTxtContext,
254        chain: Option<&crate::graph::chain::ChainSet>,
255    ) -> Result<String, crate::engine::EngineError> {
256        let mounted = self
257            .mounts
258            .iter()
259            .find(|m| m.mount.mem == mem)
260            .ok_or_else(|| crate::engine::EngineError::UnknownMem(mem.to_string()))?;
261
262        let schema_pin = mounted
263            .mount
264            .schema
265            .as_ref()
266            .map(|s| s.as_display())
267            .unwrap_or_default();
268        let config = self.mem_config_for(mem);
269        let subject = config
270            .and_then(|c| c.description.clone())
271            .unwrap_or_else(|| mem.to_string());
272        // The provenance line says who vouches. With an authority that is the
273        // deployment; without one it is the workspace the export came from.
274        // Printing "this deployment vouches" into a file exported from a
275        // laptop would make the header's one load-bearing sentence false.
276        let provenance = match (self.mem_origin_class(mem), ctx.authority.is_some()) {
277            (crate::render::OriginClass::FirstParty, true) => {
278                "first-party (this deployment vouches for the content as its own)"
279            }
280            (crate::render::OriginClass::ThirdParty, true) => {
281                "third-party (this deployment does not vouch for the content)"
282            }
283            (crate::render::OriginClass::FirstParty, false) => {
284                "first-party (authored in the workspace this export came from)"
285            }
286            (crate::render::OriginClass::ThirdParty, false) => {
287                "third-party (a read-only mount — someone else's published content)"
288            }
289        };
290
291        // Every non-stub entity of THIS mem, once, in stable id order —
292        // reduced to the chain when one is given. The link table is
293        // reduced the same way, so a link to an entity outside the chain
294        // stays raw text rather than pointing at a page this document
295        // does not contain.
296        let mut id_titles = self.entity_id_titles();
297        if let Some(chain) = chain {
298            id_titles.retain(|(id, _)| chain.contains(&EntityId::canonical(id)));
299        }
300        let mut ids: Vec<String> = self
301            .store
302            .all_entities()
303            .filter(|e| e.mem == mem && !e.stub && chain.is_none_or(|c| c.contains(&e.id)))
304            .map(|e| e.id.to_string())
305            .collect();
306        ids.sort();
307        let count = ids.len();
308        let chain_line = chain
309            .map(|c| format!("Chain: {}\n", c.describe()))
310            .unwrap_or_default();
311
312        // The header names an authority only when one is serving. A CLI export
313        // names the mem: no deployment is vouching for these bytes, and saying
314        // otherwise would put a false provenance line at the top of the one
315        // document written to be read whole and believed.
316        let heading = match &ctx.authority {
317            Some(a) => format!("# {a} — {subject}\n\nAuthority: {a}\n"),
318            // A mem with no description falls back to its own name as the
319            // subject, which would render "# srcmem — srcmem". Say it once.
320            None if subject == mem => format!("# {mem}\n\nMem: {mem}\n"),
321            None => format!("# {mem} — {subject}\n\nMem: {mem}\n"),
322        };
323        let wider = if ctx.wider_project.is_empty() {
324            String::new()
325        } else {
326            let lines: String = ctx
327                .wider_project
328                .iter()
329                .map(|(url, what)| format!("- {url} — {what}.\n"))
330                .collect();
331            // Trailing blank line: the served document has always had one
332            // between the list and the closing sentence, and without it the
333            // sentence becomes a lazy continuation of the last list item in
334            // Markdown. A `contains`-based test cannot see the difference,
335            // which is exactly why it went unnoticed.
336            format!("The wider project:\n{lines}\n")
337        };
338        let links_sentence = if ctx.href_prefix.is_empty() {
339            "Entity references are relative links to that entity's own page."
340        } else {
341            "Entity references are absolute links to that entity's own page."
342        };
343
344        let scope_sentence = if chain.is_some() {
345            "Every non-stub entity of this Memstead graph that the chain above reaches follows, \
346             once, with its type and sections; references to entities outside the chain are left \
347             as raw wiki-links."
348        } else {
349            "Every non-stub entity of this Memstead graph follows, once, with its type and \
350             sections."
351        };
352        let mut out = format!(
353            "{heading}\
354Subject: {subject}\n\
355Schema: {schema_pin}\n\
356{chain_line}\
357Entities: {count}\n\
358Provenance: {provenance}\n\n\
359{wider}\
360{scope_sentence} {links_sentence}\n\n\
361---\n\n"
362        );
363
364        for id in &ids {
365            let Some(entity) = self.get_entity(&EntityId::canonical(id)) else {
366                continue;
367            };
368            let md = strip_frontmatter(&render::render_entity_markdown(entity, None));
369            let typed = match md.split_once('\n') {
370                Some((title_line, rest)) => format!(
371                    "{title_line}\n\n_Type: {}_\n\n{}",
372                    entity.entity_type,
373                    rest.trim_start_matches('\n')
374                ),
375                None => format!("{md}\n\n_Type: {}_", entity.entity_type),
376            };
377            out.push_str(&linkify_wikilinks(typed, &id_titles, &ctx.href_prefix, mem));
378            if !out.ends_with('\n') {
379                out.push('\n');
380            }
381            out.push_str("\n---\n\n");
382        }
383
384        Ok(out)
385    }
386
387    /// `(id, title)` for every entity in the store — the lookup the link
388    /// rewriter needs. Workspace-wide on purpose: a wiki-link may reach into
389    /// another mounted mem, and resolving it is what makes the flat document
390    /// navigable.
391    fn entity_id_titles(&self) -> Vec<(String, String)> {
392        // Stubs are excluded. This document omits stub entities, so linking to
393        // one would point at a page the document does not contain — and since
394        // an engine-authored bare wiki-link materialises a LOCAL stub, leaving
395        // stubs in would make every bare slug resolve locally and the
396        // foreign-slug rules unreachable.
397        let mut out: Vec<(String, String)> = self
398            .store
399            .all_entities()
400            .filter(|e| !e.stub)
401            .map(|e| (e.id.to_string(), e.title.clone()))
402            .collect();
403        out.sort();
404        out
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn titles() -> Vec<(String, String)> {
413        vec![
414            ("engine--mem".to_string(), "Mem".to_string()),
415            ("flagship--mem".to_string(), "Mem".to_string()),
416            ("engine--pipeline".to_string(), "Pipeline".to_string()),
417        ]
418    }
419
420    /// The three passes, and their precedence. A full id always resolves; a
421    /// bare slug binds mem-locally first (the engine's authoring semantics);
422    /// and a slug two FOREIGN mems both own stays raw text rather than being
423    /// guessed — a fabricated reference the author never made is worse than a
424    /// visibly unresolved one.
425    #[test]
426    fn wiki_links_resolve_in_three_passes_and_never_guess() {
427        let t = titles();
428
429        // Pass 1 — full id.
430        assert_eq!(
431            linkify_wikilinks("See [[engine--pipeline]].".to_string(), &t, "", "engine"),
432            "See [Pipeline](entity/engine--pipeline)."
433        );
434
435        // Pass 3 wins over pass 2 — the slug exists locally, so it binds
436        // locally however many other mems reuse it.
437        assert_eq!(
438            linkify_wikilinks("A [[mem]].".to_string(), &t, "", "engine"),
439            "A [Mem](entity/engine--mem)."
440        );
441        assert_eq!(
442            linkify_wikilinks("A [[mem]].".to_string(), &t, "", "flagship"),
443            "A [Mem](entity/flagship--mem)."
444        );
445
446        // Pass 2 — unique in exactly one foreign mem, so it resolves.
447        assert_eq!(
448            linkify_wikilinks("A [[pipeline]].".to_string(), &t, "", "flagship"),
449            "A [Pipeline](entity/engine--pipeline)."
450        );
451
452        // Ambiguous across two FOREIGN mems and absent locally: named, not
453        // guessed — and not left as wiki-link syntax either.
454        assert_eq!(
455            linkify_wikilinks("A [[mem]].".to_string(), &t, "", "plugin"),
456            "A mem.",
457            "an ambiguous foreign slug degrades to plain text, never a guess"
458        );
459    }
460
461    /// Exactly two link forms exist, selected by whether a base is given.
462    /// A third — root-relative `/entity/<id>` — must never appear: the
463    /// document is read both from a file and from a served page, and only
464    /// these two work in both places.
465    #[test]
466    fn only_two_link_forms_are_emitted() {
467        let t = titles();
468        let rel = linkify_wikilinks("[[engine--mem]]".to_string(), &t, "", "engine");
469        assert_eq!(rel, "[Mem](entity/engine--mem)");
470        assert!(!rel.contains("(/entity/"), "never root-relative: {rel}");
471
472        let abs = linkify_wikilinks(
473            "[[engine--mem]]".to_string(),
474            &t,
475            "https://example.com",
476            "engine",
477        );
478        assert_eq!(abs, "[Mem](https://example.com/entity/engine--mem)");
479    }
480
481    /// A title carrying brackets would break the markdown link text, so they
482    /// fold to parentheses. The link target is unaffected.
483    #[test]
484    fn bracketed_titles_cannot_break_the_link() {
485        let t = vec![("m--x".to_string(), "A [bracketed] title".to_string())];
486        assert_eq!(
487            linkify_wikilinks("[[m--x]]".to_string(), &t, "", "m"),
488            "[A (bracketed) title](entity/m--x)"
489        );
490    }
491
492    /// Wiki-link syntax inside code is documentation, not a reference. A
493    /// fenced block or inline span showing `[[slug]]` must come out
494    /// byte-identical — the defect the HTML exporter was fixed for, which a
495    /// hand-rolled string walk reintroduces the moment nobody checks.
496    #[test]
497    fn code_spans_and_fences_are_never_rewritten() {
498        let t = titles();
499        let body = "Prose [[engine--mem]] resolves.\n\n\
500             Inline `[[engine--mem]]` does not.\n\n\
501             ```\n[[engine--mem]]\n```\n";
502        let out = linkify_wikilinks(body.to_string(), &t, "", "engine");
503
504        assert!(
505            out.contains("Prose [Mem](entity/engine--mem) resolves."),
506            "prose still resolves: {out}"
507        );
508        assert!(
509            out.contains("Inline `[[engine--mem]]` does not."),
510            "an inline code span is left alone: {out}"
511        );
512        assert!(
513            out.contains("```\n[[engine--mem]]\n```"),
514            "a fenced block is left alone: {out}"
515        );
516    }
517
518    /// The engine's grammar has three wiki-link forms and this renderer must
519    /// read all of them. Only the target half resolves; an author-supplied
520    /// label is what a reader sees, and a `#Section` suffix addresses within
521    /// the target rather than naming a different one.
522    ///
523    /// Passing the whole span to the resolver made every aliased reference
524    /// miss and fall to the plain-text arm — printing the internal id and the
525    /// pipe into prose, precisely where the author had written a label.
526    #[test]
527    fn alias_and_anchor_wiki_link_forms_resolve() {
528        let t = titles();
529
530        // `[[target|label]]` — resolves, and the LABEL is the link text.
531        assert_eq!(
532            linkify_wikilinks("See [[engine--mem|the mem]].".to_string(), &t, "", "engine"),
533            "See [the mem](entity/engine--mem)."
534        );
535        // `[[target#Section]]` — the anchor addresses within the target.
536        assert_eq!(
537            linkify_wikilinks(
538                "See [[engine--mem#Identity]].".to_string(),
539                &t,
540                "",
541                "engine"
542            ),
543            "See [Mem](entity/engine--mem)."
544        );
545        // Both at once, and on a bare slug rather than a full id.
546        assert_eq!(
547            linkify_wikilinks("See [[mem#Identity|here]].".to_string(), &t, "", "engine"),
548            "See [here](entity/engine--mem)."
549        );
550        // Unresolvable WITH a label: the reader gets the label, never the
551        // internal target or the pipe.
552        assert_eq!(
553            linkify_wikilinks("See [[ghost|that thing]].".to_string(), &t, "", "engine"),
554            "See that thing."
555        );
556    }
557
558    /// The canonical colon cross-mem form (`[[mem:slug]]`) is a qualified
559    /// reference like the dash form — the grammar's own shape for it, and
560    /// the only one a hierarchical mem can be written in. It resolves to
561    /// the same target, an author label still wins as link text, and a miss
562    /// degrades to plain text WITHOUT falling through to the slug passes:
563    /// the author named a mem, and rebinding the slug elsewhere would be a
564    /// guess. The body of a live mem carried exactly this form into prose
565    /// as unresolved text when only `|` and `#` were hand-stripped here.
566    #[test]
567    fn colon_cross_mem_form_resolves_as_a_qualified_reference() {
568        let t = titles();
569
570        assert_eq!(
571            linkify_wikilinks("See [[flagship:mem]].".to_string(), &t, "", "engine"),
572            "See [Mem](entity/flagship--mem)."
573        );
574        assert_eq!(
575            linkify_wikilinks(
576                "See [[engine:mem|the mem]].".to_string(),
577                &t,
578                "",
579                "flagship"
580            ),
581            "See [the mem](entity/engine--mem)."
582        );
583        // A colon miss is a miss: `pipeline` is unique in a foreign mem, but
584        // the author qualified it into a mem that does not own it.
585        assert_eq!(
586            linkify_wikilinks("See [[flagship:pipeline]].".to_string(), &t, "", "engine"),
587            "See flagship:pipeline."
588        );
589    }
590
591    /// A reference to nothing is named in plain text — the same treatment an
592    /// ambiguous foreign slug gets, for the same reason: say what could not be
593    /// resolved without inventing a target, and without leaking internal
594    /// wiki-link syntax into a document promised as self-contained.
595    ///
596    /// The one place `[[…]]` legitimately survives is inside code, which is
597    /// never rewritten at all.
598    #[test]
599    fn an_unresolvable_reference_degrades_to_plain_text() {
600        let t = titles();
601        assert_eq!(
602            linkify_wikilinks("See [[ghost]].".to_string(), &t, "", "engine"),
603            "See ghost."
604        );
605        // A full id whose target is a stub is the same case: stubs are absent
606        // from the map by design, so the reference cannot resolve — and this
607        // is the shape the auto-generated `## Relationships` block emits.
608        assert_eq!(
609            linkify_wikilinks(
610                "- **USES**: [[engine--phantom]]".to_string(),
611                &t,
612                "",
613                "engine"
614            ),
615            "- **USES**: engine--phantom"
616        );
617    }
618
619    /// Frontmatter is dropped; a body that has none is returned untouched.
620    #[test]
621    fn frontmatter_is_stripped_only_when_present() {
622        assert_eq!(
623            strip_frontmatter("---\ntype: spec\n---\n\n# Title\n\nBody."),
624            "# Title\n\nBody."
625        );
626        assert_eq!(strip_frontmatter("# Title\n\nBody."), "# Title\n\nBody.");
627        // An unterminated block is not frontmatter — returning the body
628        // half-eaten would silently lose content.
629        assert_eq!(strip_frontmatter("---\nno close"), "---\nno close");
630    }
631}