Skip to main content

rto_spec/
spec.rs

1// roteiro:ignore-file — the scaffold templates here intentionally contain the
2// generated document's `- [ ]` interview items and `_TODO_` placeholders; that
3// vocabulary is product content, not debt in this generator.
4//! The authoring pillar (`roteiro spec`, ADR-0004), Tier 0: deterministic,
5//! **graph-grounded** context assembly — no model, no network.
6//!
7//! [`context`] answers "what does the graph already know about <topic>?" by
8//! searching the store for related symbols and docs and gathering each symbol's
9//! neighbourhood (its container, callers/callees, and the ADRs that govern it).
10//! It is the grounding an author or agent starts from before writing an ADR or
11//! blueprint, so generated intent references *real* nodes rather than
12//! hallucinated ones.
13
14use std::collections::BTreeSet;
15
16use rto_graph::{NodeSummary, Store, StoreError, explain, search};
17
18/// Versioned schema tag for authoring outputs, so agents can depend on the shape.
19pub const SPEC_SCHEMA: &str = "roteiro.spec/v1";
20
21/// A code symbol related to the topic, with the slice of its graph neighbourhood
22/// that grounds authoring: what defines it, what it calls / is called by, and the
23/// authored ADRs/sections that govern it.
24#[derive(Debug, Clone, PartialEq, serde::Serialize)]
25pub struct SymbolContext {
26    /// The symbol node.
27    pub node: NodeSummary,
28    /// Key of the node that `contains`/`defines` it (its file or parent), if any.
29    pub container: Option<String>,
30    /// Keys this symbol `calls`.
31    pub calls: Vec<String>,
32    /// Keys that `call` this symbol.
33    pub called_by: Vec<String>,
34    /// Keys of ADR/section nodes with an `authored` edge to this symbol.
35    pub authored_by: Vec<String>,
36}
37
38/// Graph-grounded context for a topic: the related symbols (with neighbourhood),
39/// related docs/ADRs, and the set of ADRs that govern any matched symbol.
40#[derive(Debug, Clone, PartialEq, serde::Serialize)]
41pub struct SpecContext {
42    /// Stable schema tag ([`SPEC_SCHEMA`]).
43    pub schema: &'static str,
44    /// The topic that was searched.
45    pub topic: String,
46    /// Matched code symbols with their neighbourhood, most relevant first.
47    pub symbols: Vec<SymbolContext>,
48    /// Matched docs (ADRs, sections, blueprints, lat/imported docs).
49    pub docs: Vec<NodeSummary>,
50    /// Keys of ADR-layer nodes governing the matched symbols or matched directly.
51    pub related_adrs: Vec<String>,
52}
53
54/// Node kinds treated as code symbols for authoring context.
55const SYMBOL_KINDS: &[&str] = &["fn", "struct", "enum", "trait", "module"];
56/// Node kinds treated as authored/imported documentation.
57const DOC_KINDS: &[&str] = &["adr", "adr_section", "blueprint", "doc", "lat_section"];
58
59/// Assemble graph-grounded [`SpecContext`] for `topic`, keeping up to `limit`
60/// symbols and up to `limit` docs (most relevant first).
61///
62/// # Errors
63/// Returns [`StoreError`] on query failure.
64pub fn context(store: &Store, topic: &str, limit: usize) -> Result<SpecContext, StoreError> {
65    if limit == 0 {
66        return Ok(SpecContext {
67            schema: SPEC_SCHEMA,
68            topic: topic.to_owned(),
69            symbols: Vec::new(),
70            docs: Vec::new(),
71            related_adrs: Vec::new(),
72        });
73    }
74    // Over-fetch candidates so we can keep the top `limit` of each category.
75    let hits = search(store, topic, limit.saturating_mul(3).max(30))?;
76
77    let mut symbols = Vec::new();
78    let mut docs = Vec::new();
79    let mut related_adrs: BTreeSet<String> = BTreeSet::new();
80
81    for hit in hits {
82        let kind = hit.node.kind.as_str();
83        if SYMBOL_KINDS.contains(&kind) {
84            if symbols.len() >= limit {
85                continue;
86            }
87            let Some(ex) = explain(store, &hit.node.key)? else {
88                continue;
89            };
90            let container = ex
91                .incoming
92                .iter()
93                .find(|e| e.kind == "contains" || e.kind == "defines")
94                .map(|e| e.node.clone());
95            let calls = edges_of(&ex.outgoing, "calls");
96            let called_by = edges_of(&ex.incoming, "calls");
97            let authored_by: Vec<String> = ex
98                .incoming
99                .iter()
100                .filter(|e| e.provenance == "authored")
101                .map(|e| e.node.clone())
102                .collect();
103            related_adrs.extend(authored_by.iter().cloned());
104            symbols.push(SymbolContext {
105                node: hit.node,
106                container,
107                calls,
108                called_by,
109                authored_by,
110            });
111        } else if DOC_KINDS.contains(&kind) {
112            if kind == "adr" || kind == "adr_section" {
113                related_adrs.insert(hit.node.key.clone());
114            }
115            if docs.len() < limit {
116                docs.push(hit.node);
117            }
118        }
119    }
120
121    Ok(SpecContext {
122        schema: SPEC_SCHEMA,
123        topic: topic.to_owned(),
124        symbols,
125        docs,
126        related_adrs: related_adrs.into_iter().collect(),
127    })
128}
129
130/// The other-end keys of `edges` whose kind is `kind`.
131fn edges_of(edges: &[rto_graph::EdgeRef], kind: &str) -> Vec<String> {
132    edges
133        .iter()
134        .filter(|e| e.kind == kind)
135        .map(|e| e.node.clone())
136        .collect()
137}
138
139/// Generate a **house-style ADR skeleton** for `topic`, grounded in `ctx`:
140/// correct frontmatter (id `adr_id`, `Draft`), the house section headings with
141/// placeholders, a clarify **interview checklist**, and a build-plan outline. The
142/// `[[…]]` links it emits — affected symbols and related ADRs — are drawn from
143/// the graph, so they resolve and the scaffold is `roteiro check`-clean by
144/// construction. `date` is `YYYY-MM-DD` (the caller supplies today's date).
145#[must_use]
146pub fn scaffold_adr(
147    topic: &str,
148    title: Option<&str>,
149    adr_id: &str,
150    date: &str,
151    ctx: &SpecContext,
152) -> String {
153    use std::fmt::Write as _;
154
155    let title = title.unwrap_or(topic);
156    let Grounded {
157        symbol_links,
158        adr_links,
159        files,
160    } = grounded(ctx);
161
162    let mut out = String::new();
163    let _ = write!(
164        out,
165        "---\n\
166         Title: {title}\n\
167         Space: ARCH\n\
168         Parent: ADRs\n\n\
169         # ADR-specific metadata (unknown keys are ignored; used for indexing/search)\n\
170         type: adr\n\
171         adr-id: \"{adr_id}\"\n\
172         status: Draft                       # Draft | For Review | Accepted | Rejected | Superseded\n\
173         architectural-significance: MEDIUM  # SOFT | LOW | MEDIUM | HIGH | VERY HIGH\n\
174         domain: Developer Tooling\n\
175         decision-makers: [\"The Roteiro Project Team\"]\n\
176         superseded-by:\n\
177         version: \"0.1\"\n\
178         last-modified: {date}\n\
179         confluence-url:\n\
180         ---\n\n\
181         # ADR-{adr_id}: {title}\n\n\
182         | | |\n|---|---|\n\
183         | **State** | Draft |\n\
184         | **Architectural Significance** | MEDIUM |\n\
185         | **Domain** | Developer Tooling |\n\
186         | **Document version** | 0.1 |\n\n\
187         ## Reference\n\n\
188         _Scaffolded by `roteiro spec` and grounded in the graph — the links below\n\
189         already resolve against real nodes; fill in the prose._\n\n"
190    );
191
192    if !adr_links.is_empty() {
193        let _ = writeln!(out, "Related decisions: {}.\n", adr_links.join(", "));
194    }
195    if !symbol_links.is_empty() {
196        let _ = writeln!(out, "Affected code: {}.\n", symbol_links.join(", "));
197    }
198
199    out.push_str(
200        "## Summary\n\n\
201         _TODO: the decision in a sentence or two._\n\n\
202         ## Context\n\n\
203         _TODO: the forces at play and why a decision is needed now._\n\n\
204         ## Interview — clarify before writing\n\n\
205         - [ ] What problem does this solve, and who has it?\n\
206         - [ ] Which existing ADRs does this relate to or supersede? (see Reference)\n\
207         - [ ] Are the affected symbols above the right scope — anything missing?\n\
208         - [ ] What options were considered, and why this one?\n\
209         - [ ] What are the consequences, costs, and risks?\n\n\
210         ## Decision makers\n\n\
211         - The Roteiro Project Team\n\n\
212         ## Recommended option\n\n_TODO._\n\n\
213         ## Options considered + consequences\n\n_TODO._\n\n\
214         ## Consequences\n\n_TODO._\n\n\
215         ## Build-plan outline (grounded)\n\n",
216    );
217
218    if files.is_empty() && adr_links.is_empty() {
219        out.push_str("_No related graph facts found for this topic yet._\n\n");
220    } else {
221        for f in &files {
222            let _ = writeln!(out, "- Touches `{f}`");
223        }
224        if !adr_links.is_empty() {
225            let _ = writeln!(out, "- Reconcile with: {}", adr_links.join(", "));
226        }
227        out.push('\n');
228    }
229
230    let _ = write!(
231        out,
232        "## Document version history\n\n\
233         | Version | Date | Notes |\n\
234         |---------|------|-------|\n\
235         | 0.1 | {date} | Draft scaffold generated by `roteiro spec scaffold`. |\n"
236    );
237    out
238}
239
240/// Generate a **house-style blueprint (technical implementation plan)** skeleton
241/// for `topic`, grounded in `ctx`. Blueprints have no YAML frontmatter: an
242/// `— Technical Implementation Plan` H1, a grounding intro citing related ADRs
243/// and affected code, a `Status` blockquote, then numbered sections (scope →
244/// crate placement → design → testing → phased build order → risks) plus a
245/// clarify interview. Grounded `[[…]]` links resolve against real nodes.
246#[must_use]
247pub fn scaffold_blueprint(topic: &str, title: Option<&str>, ctx: &SpecContext) -> String {
248    use std::fmt::Write as _;
249
250    let title = title.unwrap_or(topic);
251    let Grounded {
252        symbol_links,
253        adr_links,
254        files,
255    } = grounded(ctx);
256
257    let mut out = String::new();
258    let _ = write!(
259        out,
260        "# {title} — Technical Implementation Plan\n\n\
261         _Scaffolded by `roteiro spec` and grounded in the graph — a build plan\n\
262         for {topic}. The links below resolve against real nodes; fill in the\n\
263         design._\n\n"
264    );
265    if !adr_links.is_empty() {
266        let _ = writeln!(out, "Grounded in: {}.\n", adr_links.join(", "));
267    }
268    if !symbol_links.is_empty() {
269        let _ = writeln!(out, "Touches: {}.\n", symbol_links.join(", "));
270    }
271
272    out.push_str("> **Status.** Design → build.\n\n---\n\n");
273    out.push_str(
274        "## 0. What this plan covers\n\n\
275         _TODO: the operator-facing surface (CLI/API) and scope._\n\n\
276         ## 1. Crate placement\n\n",
277    );
278    if files.is_empty() {
279        out.push_str("_TODO: which crates/modules this touches._\n\n");
280    } else {
281        for f in &files {
282            let _ = writeln!(out, "- `{f}`");
283        }
284        out.push('\n');
285    }
286    out.push_str(
287        "## 2. Design\n\n_TODO: the load-bearing decisions and how the pieces fit._\n\n\
288         ## 3. Interview — clarify before building\n\n\
289         - [ ] What is the operator-facing surface (CLI/API)?\n\
290         - [ ] Which crates/modules does this touch? (see Crate placement)\n\
291         - [ ] Which ADRs/decisions does it realise? (see grounding)\n\
292         - [ ] What are the phases / build order?\n\
293         - [ ] What are the risks and the invariants it must always satisfy?\n\n\
294         ## 4. Testing\n\n_TODO._\n\n\
295         ## 5. Phased build order\n\n_TODO._\n\n\
296         ## 6. Risks & invariants\n\n_TODO._\n",
297    );
298    out
299}
300
301/// The grounded `[[…]]` links and affected files for a scaffold, drawn from the
302/// graph so they resolve.
303struct Grounded {
304    symbol_links: Vec<String>,
305    adr_links: Vec<String>,
306    files: Vec<String>,
307}
308
309/// Build the grounded links from `ctx`: affected symbols as `[[path#Symbol]]`,
310/// related ADRs as `[[docs/adr/…md]]`, and the deduped affected file paths.
311fn grounded(ctx: &SpecContext) -> Grounded {
312    let symbol_links = ctx
313        .symbols
314        .iter()
315        .filter_map(|s| symbol_link_target(&s.node.key))
316        .map(|t| format!("[[{t}]]"))
317        .collect();
318    let adr_links = ctx
319        .docs
320        .iter()
321        .filter(|d| d.kind == "adr")
322        .filter_map(|d| d.path.clone())
323        .map(|p| format!("[[{p}]]"))
324        .collect();
325    let mut files: Vec<String> = ctx
326        .symbols
327        .iter()
328        .filter_map(|s| s.node.path.clone())
329        .collect();
330    files.sort();
331    files.dedup();
332    Grounded {
333        symbol_links,
334        adr_links,
335        files,
336    }
337}
338
339/// The `path#Symbol` wiki-link target reconstructed from a `sym:<lang>:<path>#…`
340/// key (dropping the `sym:<lang>:` prefix), or `None` if not a symbol key.
341fn symbol_link_target(key: &str) -> Option<&str> {
342    key.strip_prefix("sym:")
343        .and_then(|rest| rest.split_once(':'))
344        .map(|(_lang, target)| target)
345}
346
347// --- Tier 1 drafting: turn a scaffold's `_TODO_` placeholders into grounded
348// generation prompts, and splice the generated prose back in. Pure and
349// model-agnostic — the caller runs the prompts through whatever generator
350// (a local model, or an agent) and passes the prose back to [`apply_drafts`].
351
352/// The placeholder sections of a scaffold a generator should fill: `(heading,
353/// hint)` for each `_TODO…_` line, in document order. `hint` is the guidance
354/// text after `_TODO:` (empty for a bare `_TODO._`).
355#[must_use]
356pub fn draft_targets(scaffold_md: &str) -> Vec<(String, String)> {
357    let mut out = Vec::new();
358    let mut heading = String::new();
359    for line in scaffold_md.lines() {
360        if let Some(h) = line.strip_prefix("## ") {
361            h.trim().clone_into(&mut heading);
362        } else if let Some(hint) = todo_hint(line) {
363            out.push((heading.clone(), hint));
364        }
365    }
366    out
367}
368
369/// The hint of a `_TODO: hint._` (or `_TODO._`) placeholder line, else `None`.
370fn todo_hint(line: &str) -> Option<String> {
371    let rest = line.trim().strip_prefix("_TODO")?.strip_suffix('_')?;
372    // `rest` is now `.` (bare) or `: <hint>.`; drop the leading `:`/`.`/space
373    // and the trailing `.`/space.
374    Some(
375        rest.trim_start_matches([':', '.', ' '])
376            .trim_end_matches(['.', ' '])
377            .to_owned(),
378    )
379}
380
381/// Build a grounded generation prompt (a plain-text user message) for the
382/// `heading` section of an artifact about `topic`, using `hint` as guidance and
383/// `ctx` as the real symbols/ADRs the model may reference. The prompt constrains
384/// the model to the grounded facts so drafts stay honest.
385#[must_use]
386pub fn draft_prompt(topic: &str, ctx: &SpecContext, heading: &str, hint: &str) -> String {
387    use std::fmt::Write as _;
388
389    let mut p = String::new();
390    let _ = write!(
391        p,
392        "You are drafting the \"{heading}\" section of a house-style technical \
393         document about \"{topic}\" for the Roteiro project (a provenance-tagged \
394         codebase knowledge graph). "
395    );
396    if !hint.is_empty() {
397        let _ = write!(p, "Focus: {hint}. ");
398    }
399    let symbols: Vec<&str> = ctx.symbols.iter().map(|s| s.node.name.as_str()).collect();
400    if !symbols.is_empty() {
401        let _ = write!(p, "Relevant code symbols: {}. ", symbols.join(", "));
402    }
403    if !ctx.related_adrs.is_empty() {
404        let _ = write!(p, "Related decisions: {}. ", ctx.related_adrs.join(", "));
405    }
406    p.push_str(
407        "Write 2–4 precise, technical sentences. Reference the real symbols above \
408         where relevant; do not invent symbols, files, or facts. Output only the \
409         prose, no heading.",
410    );
411    p
412}
413
414/// Splice generated prose back into a scaffold: each `_TODO…_` line under a
415/// heading present in `drafts` (as `(heading, prose)`) is replaced by that prose.
416/// Headings without a draft, and all other lines, are left unchanged.
417#[must_use]
418pub fn apply_drafts(scaffold_md: &str, drafts: &[(String, String)]) -> String {
419    let by_heading: std::collections::BTreeMap<&str, &str> = drafts
420        .iter()
421        .map(|(h, prose)| (h.as_str(), prose.as_str()))
422        .collect();
423    let mut out = String::new();
424    let mut heading = "";
425    for line in scaffold_md.lines() {
426        if let Some(h) = line.strip_prefix("## ") {
427            heading = h.trim();
428        } else if todo_hint(line).is_some()
429            && let Some(prose) = by_heading.get(heading)
430        {
431            out.push_str(prose);
432            out.push('\n');
433            continue;
434        }
435        out.push_str(line);
436        out.push('\n');
437    }
438    out
439}
440
441#[cfg(test)]
442mod tests {
443    use super::{SPEC_SCHEMA, context};
444    use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
445
446    fn seeded() -> Store {
447        let mut store = Store::open_in_memory().expect("store");
448        let facts = FactSet::new()
449            .with_node(Node::new("file:src/auth.rs", NodeKind::File, "auth.rs"))
450            .with_node(Node {
451                path: Some("src/auth.rs".to_owned()),
452                ..Node::new(
453                    "sym:rust:src/auth.rs#validate_token",
454                    NodeKind::Fn,
455                    "validate_token",
456                )
457            })
458            .with_node(Node::new(
459                "sym:rust:src/auth.rs#login",
460                NodeKind::Fn,
461                "login",
462            ))
463            .with_node(Node::new(
464                "adr:0007",
465                NodeKind::Adr,
466                "Authentication design",
467            ))
468            // Structure + calls + an authored ADR link into validate_token.
469            .with_edge(Edge::derived(
470                "file:src/auth.rs",
471                "sym:rust:src/auth.rs#validate_token",
472                EdgeKind::Defines,
473            ))
474            .with_edge(Edge::derived(
475                "sym:rust:src/auth.rs#login",
476                "sym:rust:src/auth.rs#validate_token",
477                EdgeKind::Calls,
478            ))
479            .with_edge(Edge::authored(
480                "adr:0007",
481                "sym:rust:src/auth.rs#validate_token",
482                EdgeKind::References,
483            ));
484        store.apply_factset(&facts).expect("apply");
485        store
486    }
487
488    #[test]
489    fn context_grounds_a_symbol_in_its_neighbourhood() {
490        let store = seeded();
491        let ctx = context(&store, "validate_token", 10).expect("context");
492        assert_eq!(ctx.schema, SPEC_SCHEMA);
493
494        let sym = ctx
495            .symbols
496            .iter()
497            .find(|s| s.node.key == "sym:rust:src/auth.rs#validate_token")
498            .expect("the symbol");
499        assert_eq!(sym.container.as_deref(), Some("file:src/auth.rs"));
500        assert_eq!(sym.called_by, vec!["sym:rust:src/auth.rs#login"]);
501        assert_eq!(sym.authored_by, vec!["adr:0007"]);
502        // The governing ADR is surfaced as related.
503        assert!(ctx.related_adrs.contains(&"adr:0007".to_owned()));
504    }
505
506    #[test]
507    fn context_finds_related_docs_by_topic() {
508        let store = seeded();
509        // "authentication" matches the ADR's name.
510        let ctx = context(&store, "authentication", 10).expect("context");
511        assert!(
512            ctx.docs.iter().any(|d| d.key == "adr:0007"),
513            "the ADR should be a related doc: {:?}",
514            ctx.docs
515        );
516        assert!(ctx.related_adrs.contains(&"adr:0007".to_owned()));
517    }
518
519    #[test]
520    fn empty_topic_yields_empty_context() {
521        let store = seeded();
522        let ctx = context(&store, "   ", 10).expect("context");
523        assert!(ctx.symbols.is_empty() && ctx.docs.is_empty());
524    }
525
526    #[test]
527    fn scaffold_is_grounded_and_check_clean() {
528        use super::scaffold_adr;
529        let mut store = seeded();
530        let ctx = context(&store, "validate_token", 10).expect("context");
531        let md = scaffold_adr(
532            "validate_token",
533            Some("Token validation"),
534            "0099",
535            "2026-08-09",
536            &ctx,
537        );
538
539        // House frontmatter + title with the given id.
540        assert!(md.contains("adr-id: \"0099\""), "{md}");
541        assert!(md.contains("# ADR-0099: Token validation"));
542        // Grounded affected-code link and the interview checklist.
543        assert!(
544            md.contains("[[src/auth.rs#validate_token]]"),
545            "grounded link: {md}"
546        );
547        assert!(md.contains("- [ ] What problem does this solve"));
548
549        // It parses as a house ADR and its links resolve to a real node — so it
550        // is `check`-clean by construction.
551        let doc = crate::parse_adr("docs/adr/0099-token-validation.md", &md).expect("parse");
552        assert!(
553            doc.links
554                .iter()
555                .any(|l| l.target_key == "sym:rust:src/auth.rs#validate_token"),
556            "the scaffold's link must resolve to the real symbol: {:?}",
557            doc.links,
558        );
559        let report = crate::run(&mut store, std::slice::from_ref(&doc), &[], &[]).expect("check");
560        assert_eq!(
561            report.violations.len(),
562            0,
563            "scaffold must be check-clean: {:?}",
564            report.violations
565        );
566    }
567
568    #[test]
569    fn scaffold_has_no_code_block_indentation() {
570        use super::{scaffold_adr, scaffold_blueprint};
571        let store = seeded();
572        let ctx = context(&store, "validate_token", 10).expect("context");
573        // The `\`-line-continuations in the templates strip source indentation, so
574        // no line begins with whitespace; a 4-space indent would (wrongly) render
575        // the frontmatter/headings as a CommonMark code block.
576        let adr = scaffold_adr("validate_token", None, "0099", "2026-08-09", &ctx);
577        let blueprint = scaffold_blueprint("validate_token", None, &ctx);
578        for md in [&adr, &blueprint] {
579            for (i, line) in md.lines().enumerate() {
580                assert!(
581                    !line.starts_with(' ') && !line.starts_with('\t'),
582                    "line {} has leading whitespace: {line:?}",
583                    i + 1
584                );
585            }
586        }
587    }
588
589    #[test]
590    fn draft_round_trip_fills_todo_sections() {
591        use super::{apply_drafts, draft_prompt, draft_targets, scaffold_adr};
592        let store = seeded();
593        let ctx = context(&store, "validate_token", 10).expect("context");
594        let scaffold = scaffold_adr("validate_token", None, "0099", "2026-08-09", &ctx);
595
596        // Targets: every `## Heading` with a `_TODO…_` placeholder.
597        let targets = draft_targets(&scaffold);
598        assert!(targets.iter().any(|(h, _)| h == "Summary"));
599        assert!(targets.iter().any(|(h, _)| h == "Context"));
600
601        // A grounded prompt names the real symbol and forbids invention.
602        let prompt = draft_prompt("validate_token", &ctx, "Summary", "the decision");
603        assert!(prompt.contains("validate_token"), "{prompt}");
604        assert!(prompt.contains("do not invent"));
605
606        // A mock generator fills each target; apply_drafts splices the prose in
607        // and the `_TODO_` placeholders for drafted sections are gone.
608        let drafts: Vec<(String, String)> = targets
609            .iter()
610            .map(|(h, _)| (h.clone(), format!("Drafted prose for {h}.")))
611            .collect();
612        let filled = apply_drafts(&scaffold, &drafts);
613        assert!(filled.contains("Drafted prose for Summary."));
614        assert!(
615            !filled.contains("_TODO: the decision"),
616            "placeholder replaced: {filled}"
617        );
618        // Structure is preserved (headings still present).
619        assert!(filled.contains("## Summary") && filled.contains("## Context"));
620    }
621
622    #[test]
623    fn blueprint_is_grounded_and_house_style() {
624        use super::scaffold_blueprint;
625        let store = seeded();
626        let ctx = context(&store, "validate_token", 10).expect("context");
627        let md = scaffold_blueprint("validate_token", Some("Token flow"), &ctx);
628
629        // House blueprint shape: no YAML frontmatter, the `— Technical
630        // Implementation Plan` H1, a Status blockquote, numbered sections.
631        assert!(
632            md.starts_with("# Token flow — Technical Implementation Plan"),
633            "{md}"
634        );
635        assert!(
636            !md.contains("---\nTitle:"),
637            "blueprints have no frontmatter"
638        );
639        assert!(md.contains("> **Status.** Design → build."));
640        assert!(md.contains("## 1. Crate placement"));
641        // Grounded: the affected code is linked and its file is listed.
642        assert!(
643            md.contains("[[src/auth.rs#validate_token]]"),
644            "grounded link: {md}"
645        );
646        assert!(md.contains("`src/auth.rs`"), "affected file listed: {md}");
647        assert!(md.contains("- [ ] What is the operator-facing surface"));
648    }
649}