Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-docgen/src/ai_context.rs

//! `nornir docs ai-context` — assemble an AI-native context system for a repo.
//!
//! Two markdown files are produced:
//!
//!   * `docs/ai-context.md` — the COMPACT primary context. Read this first.
//!     Repository identity, AI usage rules, system summary, crate/layer +
//!     binary + feature tables, a four-part dependency view, the runtime-wiring
//!     / security / invariant / failure-semantics / change-impact maps, the
//!     ecosystem capability matrix, CLI + RON-config + env inventories, the
//!     test inventory, the task→file map, incomplete-marker scan, reasoning
//!     warnings, and an API summary that links into the full companion.
//!   * `docs/ai-context-full.md` — the EXHAUSTIVE companion. Not for every
//!     task; consult it for exact signatures, complete rustdoc, private
//!     implementation detail, and precise source references.
//!
//! Evidence order (also emitted into the compact pack): executable tests →
//! runtime wiring → implementation → configuration/features → rustdoc → current
//! architecture docs → generated summaries → historical docs → inferred intent.
//! Generated summaries never override active code.
//!
//! Two source layers feed the output:
//!
//!   1. **Derived facts** (deterministic, sorted): crate manifests → features,
//!      binaries, layer classification, and the internal dependency graph;
//!      environment-variable references; TODO/`unimplemented!`/`#[ignore]`
//!      markers; per-crate test inventory; git revision + dirty state.
//!   2. **Maintained metadata** — `.nornir/ai-context.toml`, carrying the facts
//!      that cannot be derived from code (purpose, wiring/security/invariant/
//!      failure/change-impact maps, capability matrix, CLI + config inventories,
//!      document status, per-crate layers, budgets). Every file path and test
//!      command it cites is validated at generation time — a dangling reference,
//!      an unknown status label, or a status contradiction FAILS generation, so
//!      the maintained layer cannot silently drift.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use anyhow::{Context as _, Result, bail};
use serde::Deserialize;

use super::apidoc::{self, ApiCrate, ExtractOpts};
use super::sections::Ctx;

/// The maintained-metadata file, relative to the repo root.
pub const MAINTAINED_METADATA_PATH: &str = ".nornir/ai-context.toml";

/// Status labels an AI agent can trust to mean the same thing everywhere. Any
/// `status = "…"` in the maintained metadata must be one of these (case- and
/// space-insensitive) or generation fails.
pub const ALLOWED_STATUSES: &[&str] = &[
    "Verified implementation",
    "Verified by test",
    "Runtime wired",
    "Feature-gated",
    "Dev-only",
    "Test-only",
    "Compiled but not wired",
    "Partial implementation",
    "Historical documentation",
    "Superseded documentation",
    "Not implemented",
    "Unverified",
    "Documentation mismatch",
];

/// Default token budgets (chars/4 heuristic) when the metadata omits them.
const DEFAULT_COMPACT_TOKENS: usize = 30_000;
const DEFAULT_FULL_TOKENS: usize = 120_000;

/// Knobs for [`build`].
#[derive(Debug, Clone)]
pub struct AiContextOpts {
    /// Include private (`priv`) items too — default: exported surface only.
    pub include_private: bool,
    /// Append current-status prose docs to the full pack.
    pub include_prose: bool,
    /// Per-doc-block truncation (chars; 0 = unlimited).
    pub max_doc_chars: usize,
    /// `development` | `ci` | `release`. Release/CI fail on a dirty tree unless
    /// `allow_dirty` is set; development marks the snapshot non-reproducible.
    pub mode: GenMode,
    /// Explicit override that permits a dirty-tree release/CI generation.
    pub allow_dirty: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GenMode {
    Development,
    Ci,
    Release,
}

impl GenMode {
    fn label(self) -> &'static str {
        match self {
            GenMode::Development => "development",
            GenMode::Ci => "ci",
            GenMode::Release => "release",
        }
    }
    /// Parse from a string (env/CLI); unknown → development.
    pub fn parse(s: &str) -> Self {
        match s.trim().to_ascii_lowercase().as_str() {
            "release" => GenMode::Release,
            "ci" => GenMode::Ci,
            _ => GenMode::Development,
        }
    }
    fn reproducible_required(self) -> bool {
        matches!(self, GenMode::Ci | GenMode::Release)
    }
}

impl Default for AiContextOpts {
    fn default() -> Self {
        Self {
            include_private: false,
            include_prose: true,
            max_doc_chars: 1200,
            mode: GenMode::Development,
            allow_dirty: false,
        }
    }
}

/// Counts for the run summary the CLI prints.
#[derive(Debug, Default, Clone, Copy)]
pub struct BuildStats {
    pub crates: usize,
    pub modules: usize,
    pub items: usize,
    pub documented: usize,
    pub bytes: usize,
    pub full_bytes: usize,
    /// Approx tokens (chars/4) of each pack, and their budgets.
    pub compact_tokens: usize,
    pub full_tokens: usize,
    pub compact_budget: usize,
    pub full_budget: usize,
    pub over_budget: bool,
}

/// The multi-file result of a build.
#[derive(Debug)]
pub struct AiContextOutput {
    pub compact: String,
    pub full: String,
    pub stats: BuildStats,
    /// Human-readable warnings (budget overruns, undocumented env vars, …).
    pub warnings: Vec<String>,
}

// ═══ Maintained metadata (`.nornir/ai-context.toml`) ═════════════════════════

#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Maintained {
    #[serde(default)]
    pub repository: MetaRepository,
    #[serde(default)]
    pub output: MetaOutput,
    #[serde(default)]
    pub invariants: Vec<MetaInvariant>,
    #[serde(default)]
    pub tasks: Vec<MetaTask>,
    #[serde(default)]
    pub wiring: Vec<MetaWiring>,
    #[serde(default)]
    pub security: Vec<MetaSecurity>,
    #[serde(default)]
    pub warnings: Vec<String>,
    #[serde(default)]
    pub crates: BTreeMap<String, MetaCrate>,
    #[serde(default)]
    pub documents: Vec<MetaDocument>,
    #[serde(default)]
    pub capabilities: Vec<MetaCapability>,
    #[serde(default)]
    pub change_impact: Vec<MetaChangeImpact>,
    #[serde(default)]
    pub failures: Vec<MetaFailure>,
    #[serde(default)]
    pub cli: Vec<MetaCli>,
    #[serde(default)]
    pub config: Vec<MetaConfig>,
    #[serde(default)]
    pub precedence: Vec<String>,
    #[serde(default)]
    pub hotspots: Vec<MetaHotspot>,
    #[serde(default)]
    pub env: Vec<MetaEnv>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaRepository {
    #[serde(default)]
    pub purpose: String,
    #[serde(default)]
    pub role: String,
    #[serde(default)]
    pub non_goals: Vec<String>,
    #[serde(default)]
    pub system_summary: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaOutput {
    #[serde(default)]
    pub compact: MetaBudget,
    #[serde(default)]
    pub full: MetaBudget,
}
impl Default for MetaOutput {
    fn default() -> Self {
        Self {
            compact: MetaBudget {
                max_tokens: DEFAULT_COMPACT_TOKENS,
            },
            full: MetaBudget {
                max_tokens: DEFAULT_FULL_TOKENS,
            },
        }
    }
}

#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaBudget {
    #[serde(default)]
    pub max_tokens: usize,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaInvariant {
    pub id: String,
    pub statement: String,
    // `enforced` is the preferred key; `implementation` kept as an alias.
    #[serde(default, alias = "implementation")]
    pub enforced: Vec<String>,
    #[serde(default)]
    pub tests: Vec<String>,
    #[serde(default)]
    pub break_risk: String,
    #[serde(default)]
    pub status: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaTask {
    pub name: String,
    #[serde(default)]
    pub start: Vec<String>,
    #[serde(default)]
    pub related: Vec<String>,
    #[serde(default)]
    pub invariants: Vec<String>,
    /// Legacy single test list (rendered as "fast" when fast_tests is empty).
    #[serde(default)]
    pub tests: Vec<String>,
    #[serde(default)]
    pub fast_tests: Vec<String>,
    #[serde(default)]
    pub full_tests: Vec<String>,
    #[serde(default)]
    pub heavy_tests: Vec<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaWiring {
    pub capability: String,
    #[serde(default)]
    pub interface: String,
    #[serde(default)]
    pub implementation: String,
    #[serde(default)]
    pub registration: String,
    #[serde(default)]
    pub configuration: String,
    #[serde(default)]
    pub tests: String,
    #[serde(default)]
    pub status: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaSecurity {
    pub concern: String,
    #[serde(default)]
    pub invariant: String,
    #[serde(default)]
    pub implementation: Vec<String>,
    #[serde(default)]
    pub tests: Vec<String>,
    #[serde(default)]
    pub consequence: String,
}

#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaCrate {
    #[serde(default)]
    pub layer: String,
    #[serde(default)]
    pub note: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaDocument {
    pub path: String,
    pub status: String,
    #[serde(default)]
    pub include_compact: bool,
    #[serde(default)]
    pub include_full: bool,
    #[serde(default)]
    pub reason: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaCapability {
    pub ecosystem: String,
    #[serde(default)]
    pub protocol_fetch: String,
    #[serde(default)]
    pub protocol_upload: String,
    #[serde(default)]
    pub generic_fetch: String,
    #[serde(default)]
    pub browse: String,
    #[serde(default)]
    pub native_index: String,
    #[serde(default)]
    pub writable_backend: String,
    #[serde(default)]
    pub znippy_backend: String,
    #[serde(default)]
    pub proxy_upstream: String,
    #[serde(default)]
    pub limitation: String,
    #[serde(default)]
    pub evidence: Vec<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaChangeImpact {
    pub interface: String,
    #[serde(default)]
    pub defined_in: String,
    #[serde(default)]
    pub implementers: String,
    #[serde(default)]
    pub consumers: String,
    #[serde(default)]
    pub significance: String,
    #[serde(default)]
    pub tests: Vec<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaFailure {
    pub operation: String,
    #[serde(default)]
    pub fatal: String,
    #[serde(default)]
    pub partial: String,
    #[serde(default)]
    pub retry: String,
    #[serde(default)]
    pub cleanup: String,
    #[serde(default)]
    pub observable: String,
    #[serde(default)]
    pub evidence: Vec<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaCli {
    pub binary: String,
    pub command: String,
    #[serde(default)]
    pub flags: String,
    #[serde(default)]
    pub calls: String,
    #[serde(default)]
    pub restrictions: String,
    #[serde(default)]
    pub tests: Vec<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaConfig {
    pub field: String,
    #[serde(default)]
    pub kind: String,
    #[serde(default)]
    pub default: String,
    #[serde(default)]
    pub required: bool,
    #[serde(default)]
    pub sensitive: bool,
    #[serde(default)]
    pub consumer: String,
    #[serde(default)]
    pub restrictions: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaHotspot {
    pub dependency: String,
    #[serde(default)]
    pub used_by: String,
    #[serde(default)]
    pub effect: String,
    #[serde(default)]
    pub tier_impact: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaEnv {
    pub name: String,
    #[serde(default)]
    pub kind: String,
    #[serde(default)]
    pub default: String,
    #[serde(default)]
    pub sensitive: bool,
    /// `runtime` | `build` | `test` | `bench` | `dev`.
    #[serde(default)]
    pub phase: String,
    #[serde(default)]
    pub effect: String,
    #[serde(default)]
    pub evidence: Vec<String>,
}

/// Load `.nornir/ai-context.toml` when present. Parse errors, dangling file
/// references, unknown status labels, and status contradictions are HARD
/// errors — maintained metadata must never drift.
pub fn load_maintained(
    repo_root: &Path,
    workspace_crates: &BTreeSet<String>,
) -> Result<Option<Maintained>> {
    let path = repo_root.join(MAINTAINED_METADATA_PATH);
    if !path.is_file() {
        return Ok(None);
    }
    let txt = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
    let m: Maintained =
        toml::from_str(&txt).with_context(|| format!("parse {}", path.display()))?;

    let mut problems = Vec::new();

    // 1. Every file-ish reference must exist; every cargo-test command must
    //    name a real workspace crate.
    let check = |refs: &[String], ctx: &str, problems: &mut Vec<String>| {
        for r in refs {
            if let Some(p) = path_of_reference(r) {
                if !repo_root.join(&p).exists() {
                    problems.push(format!("dangling path — {ctx}: {r}"));
                }
            } else if let Some(krate) = cargo_test_crate(r) {
                if !workspace_crates.contains(&krate) {
                    problems.push(format!(
                        "test command names unknown crate `{krate}` — {ctx}: {r}"
                    ));
                }
            }
        }
    };
    let one = |r: &str, ctx: &str, problems: &mut Vec<String>| {
        if let Some(p) = path_of_reference(r) {
            if !repo_root.join(&p).exists() {
                problems.push(format!("dangling path — {ctx}: {r}"));
            }
        }
    };
    for inv in &m.invariants {
        check(
            &inv.enforced,
            &format!("invariant `{}` enforced", inv.id),
            &mut problems,
        );
        check(
            &inv.tests,
            &format!("invariant `{}` tests", inv.id),
            &mut problems,
        );
    }
    for t in &m.tasks {
        check(&t.start, &format!("task `{}` start", t.name), &mut problems);
        check(
            &t.related,
            &format!("task `{}` related", t.name),
            &mut problems,
        );
        check(&t.tests, &format!("task `{}` tests", t.name), &mut problems);
        check(
            &t.fast_tests,
            &format!("task `{}` fast_tests", t.name),
            &mut problems,
        );
        check(
            &t.full_tests,
            &format!("task `{}` full_tests", t.name),
            &mut problems,
        );
        check(
            &t.heavy_tests,
            &format!("task `{}` heavy_tests", t.name),
            &mut problems,
        );
    }
    for s in &m.security {
        check(
            &s.implementation,
            &format!("security `{}` implementation", s.concern),
            &mut problems,
        );
        check(
            &s.tests,
            &format!("security `{}` tests", s.concern),
            &mut problems,
        );
    }
    for w in &m.wiring {
        one(
            &w.implementation,
            &format!("wiring `{}` implementation", w.capability),
            &mut problems,
        );
        one(
            &w.registration,
            &format!("wiring `{}` registration", w.capability),
            &mut problems,
        );
        one(
            &w.tests,
            &format!("wiring `{}` tests", w.capability),
            &mut problems,
        );
    }
    for c in &m.change_impact {
        one(
            &c.defined_in,
            &format!("change_impact `{}` defined_in", c.interface),
            &mut problems,
        );
        check(
            &c.tests,
            &format!("change_impact `{}` tests", c.interface),
            &mut problems,
        );
    }
    for f in &m.failures {
        check(
            &f.evidence,
            &format!("failure `{}` evidence", f.operation),
            &mut problems,
        );
    }
    for c in &m.cli {
        check(
            &c.tests,
            &format!("cli `{} {}` tests", c.binary, c.command),
            &mut problems,
        );
    }
    for cap in &m.capabilities {
        check(
            &cap.evidence,
            &format!("capability `{}` evidence", cap.ecosystem),
            &mut problems,
        );
    }
    for e in &m.env {
        check(
            &e.evidence,
            &format!("env `{}` evidence", e.name),
            &mut problems,
        );
    }
    for d in &m.documents {
        one(&d.path, &format!("document `{}`", d.path), &mut problems);
    }

    // 2. Every status label must be from the allowed set.
    let check_status = |s: &str, ctx: &str, problems: &mut Vec<String>| {
        if !s.is_empty() && normalize_status(s).is_none() {
            problems.push(format!(
                "unknown status `{s}` — {ctx} (allowed: {})",
                ALLOWED_STATUSES.join(", ")
            ));
        }
    };
    for w in &m.wiring {
        check_status(
            &w.status,
            &format!("wiring `{}`", w.capability),
            &mut problems,
        );
    }
    for inv in &m.invariants {
        check_status(
            &inv.status,
            &format!("invariant `{}`", inv.id),
            &mut problems,
        );
    }
    for d in &m.documents {
        check_status(&d.status, &format!("document `{}`", d.path), &mut problems);
    }

    // 3. Status contradictions: a capability marked absent in one place but live
    //    in another.
    problems.extend(status_contradictions(&m));

    if !problems.is_empty() {
        bail!(
            "{}: {} problem(s) — fix the metadata:\n  {}",
            MAINTAINED_METADATA_PATH,
            problems.len(),
            problems.join("\n  ")
        );
    }
    Ok(Some(m))
}

/// A capability whose status labels conflict in polarity across sections.
fn status_contradictions(m: &Maintained) -> Vec<String> {
    let mut seen: BTreeMap<String, BTreeSet<&'static str>> = BTreeMap::new();
    let note = |cap: &str, status: &str, seen: &mut BTreeMap<String, BTreeSet<&'static str>>| {
        if let Some(pol) = status_polarity(status) {
            seen.entry(cap_key(cap)).or_default().insert(pol);
        }
    };
    for w in &m.wiring {
        note(&w.capability, &w.status, &mut seen);
    }
    for inv in &m.invariants {
        note(&inv.id, &inv.status, &mut seen);
    }
    seen.into_iter()
        .filter(|(_, pols)| pols.contains("live") && pols.contains("absent"))
        .map(|(cap, _)| {
            format!("status contradiction — capability `{cap}` is labelled both live (wired/verified) and absent (not implemented/superseded)")
        })
        .collect()
}

/// "live" for wired/verified/partial, "absent" for not-implemented/superseded/
/// historical/not-wired, None for neutral (unverified/dev/test/feature-gated).
fn status_polarity(status: &str) -> Option<&'static str> {
    match normalize_status(status)? {
        "Verified implementation"
        | "Verified by test"
        | "Runtime wired"
        | "Partial implementation" => Some("live"),
        "Not implemented"
        | "Superseded documentation"
        | "Historical documentation"
        | "Compiled but not wired" => Some("absent"),
        _ => None,
    }
}

/// Canonicalize a status to its controlled label. A status is `<label>` or
/// `<label><sep><annotation>` (sep = one of `;:,(` or ` — `); the label is the
/// controlled, validated part and an optional trailing annotation is free text.
/// Returns the canonical label, or `None` if the string doesn't START with one.
fn normalize_status(s: &str) -> Option<&'static str> {
    leading_status(s).map(|(label, _)| label)
}

/// Split a status into its controlled `(label, annotation)`. Case-insensitive on
/// the label; matches the longest allowed label that is a prefix at a word
/// boundary. `annotation` is the trimmed remainder (may be empty).
fn leading_status(s: &str) -> Option<(&'static str, String)> {
    let t = s.trim();
    let lower = t.to_ascii_lowercase();
    ALLOWED_STATUSES
        .iter()
        .copied()
        .filter(|a| {
            let al = a.to_ascii_lowercase();
            lower.starts_with(&al)
                && lower[al.len()..]
                    .chars()
                    .next()
                    .map(|c| !c.is_ascii_alphanumeric())
                    .unwrap_or(true)
        })
        .max_by_key(|a| a.len())
        .map(|label| {
            let rest = t[label.len()..]
                .trim_start_matches([';', ':', ',', '(', ' ', '—', '-'])
                .trim();
            (label, rest.to_string())
        })
}

fn cap_key(s: &str) -> String {
    s.trim().to_ascii_lowercase()
}

/// The validatable path inside a metadata reference, when it looks like one.
/// `server/lib/src/auth.rs:445` → `server/lib/src/auth.rs`. Free text (spaces),
/// commands (`cargo test …`), anchors and URLs return `None`.
fn path_of_reference(r: &str) -> Option<String> {
    let r = r.trim();
    if r.is_empty() || r.contains(' ') || r.starts_with("http") || !r.contains('/') {
        return None;
    }
    let base = r.split('#').next().unwrap_or(r);
    let base = match base.rsplit_once(':') {
        Some((head, tail))
            if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit() || c == '-') =>
        {
            head
        }
        _ => base,
    };
    Some(base.to_string())
}

/// If `r` is a `cargo test -p <crate> …` command, return `<crate>`.
fn cargo_test_crate(r: &str) -> Option<String> {
    let r = r.trim();
    if !r.starts_with("cargo ") {
        return None;
    }
    let toks: Vec<&str> = r.split_whitespace().collect();
    toks.iter()
        .position(|t| *t == "-p")
        .and_then(|i| toks.get(i + 1))
        .map(|s| s.to_string())
}

// ═══ Derived facts ═══════════════════════════════════════════════════════════

#[derive(Debug, Default)]
struct CrateMeta {
    rel_dir: String,
    features: BTreeMap<String, Vec<String>>,
    bins: Vec<BinMeta>,
    has_lib: bool,
    /// Workspace-internal dependency edges.
    deps: Vec<DepEdge>,
}

#[derive(Debug, Default)]
struct BinMeta {
    name: String,
    required_features: Vec<String>,
}

#[derive(Debug, Clone)]
struct DepEdge {
    to: String,
    kind: DepKind,
    optional: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum DepKind {
    Runtime,
    Dev,
    Build,
}
impl DepKind {
    fn label(self) -> &'static str {
        match self {
            DepKind::Runtime => "runtime",
            DepKind::Dev => "dev-only",
            DepKind::Build => "build-only",
        }
    }
}

/// Parse features + `[[bin]]` + internal dependency edges out of every manifest.
fn scan_manifests(repo_root: &Path) -> BTreeMap<String, CrateMeta> {
    let manifests = apidoc::discover_manifests(repo_root);
    let mut parsed: Vec<(String, PathBuf, toml::Table)> = Vec::new();
    for manifest in &manifests {
        let Ok(txt) = std::fs::read_to_string(manifest) else {
            continue;
        };
        let Ok(doc) = txt.parse::<toml::Table>() else {
            continue;
        };
        let Some(name) = doc
            .get("package")
            .and_then(|p| p.get("name"))
            .and_then(|n| n.as_str())
        else {
            continue;
        };
        parsed.push((name.to_string(), manifest.clone(), doc));
    }
    let names: BTreeSet<String> = parsed.iter().map(|(n, _, _)| n.clone()).collect();

    let mut out = BTreeMap::new();
    for (name, manifest, doc) in parsed {
        let dir = manifest.parent().unwrap_or(repo_root);
        let rel_dir = dir
            .strip_prefix(repo_root)
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_default();
        let mut meta = CrateMeta {
            rel_dir: if rel_dir.is_empty() {
                ".".into()
            } else {
                rel_dir
            },
            has_lib: dir.join("src/lib.rs").is_file() || doc.contains_key("lib"),
            ..Default::default()
        };
        if let Some(features) = doc.get("features").and_then(|f| f.as_table()) {
            for (k, v) in features {
                let deps = v
                    .as_array()
                    .map(|a| {
                        a.iter()
                            .filter_map(|x| x.as_str().map(str::to_string))
                            .collect()
                    })
                    .unwrap_or_default();
                meta.features.insert(k.clone(), deps);
            }
        }
        if let Some(bins) = doc.get("bin").and_then(|b| b.as_array()) {
            for b in bins {
                let Some(bname) = b.get("name").and_then(|n| n.as_str()) else {
                    continue;
                };
                let req = b
                    .get("required-features")
                    .and_then(|r| r.as_array())
                    .map(|a| {
                        a.iter()
                            .filter_map(|x| x.as_str().map(str::to_string))
                            .collect()
                    })
                    .unwrap_or_default();
                meta.bins.push(BinMeta {
                    name: bname.to_string(),
                    required_features: req,
                });
            }
        }
        if dir.join("src/main.rs").is_file() && !meta.bins.iter().any(|b| b.name == name) {
            meta.bins.push(BinMeta {
                name: name.clone(),
                required_features: Vec::new(),
            });
        }
        meta.bins.sort_by(|a, b| a.name.cmp(&b.name));

        let mut edges: BTreeMap<(String, DepKind), bool> = BTreeMap::new();
        collect_deps(&doc, &names, &name, &mut edges);
        if let Some(targets) = doc.get("target").and_then(|t| t.as_table()) {
            for (_, tv) in targets {
                if let Some(tt) = tv.as_table() {
                    collect_deps(tt, &names, &name, &mut edges);
                }
            }
        }
        // Collapse: a runtime edge supersedes a dev/build edge to the same crate.
        let runtime: BTreeSet<String> = edges
            .keys()
            .filter(|(_, k)| *k == DepKind::Runtime)
            .map(|(c, _)| c.clone())
            .collect();
        edges.retain(|(c, k), _| *k == DepKind::Runtime || !runtime.contains(c));
        for ((to, kind), optional) in edges {
            meta.deps.push(DepEdge { to, kind, optional });
        }
        meta.deps
            .sort_by(|a, b| (&a.to, a.kind as u8).cmp(&(&b.to, b.kind as u8)));
        out.insert(name, meta);
    }
    out
}

/// Pull `[dependencies]`/`[dev-dependencies]`/`[build-dependencies]` edges that
/// point at another workspace crate, deduplicated on (crate, kind).
fn collect_deps(
    table: &toml::Table,
    names: &BTreeSet<String>,
    self_name: &str,
    edges: &mut BTreeMap<(String, DepKind), bool>,
) {
    for (section, kind) in [
        ("dependencies", DepKind::Runtime),
        ("dev-dependencies", DepKind::Dev),
        ("build-dependencies", DepKind::Build),
    ] {
        let Some(deps) = table.get(section).and_then(|d| d.as_table()) else {
            continue;
        };
        for (dep_key, spec) in deps {
            let real = spec
                .as_table()
                .and_then(|t| t.get("package"))
                .and_then(|p| p.as_str())
                .unwrap_or(dep_key.as_str());
            if real == self_name || !names.contains(real) {
                continue;
            }
            let optional = spec
                .as_table()
                .and_then(|t| t.get("optional"))
                .and_then(|o| o.as_bool())
                .unwrap_or(false);
            let e = edges.entry((real.to_string(), kind)).or_insert(optional);
            *e = *e || optional;
        }
    }
}

fn layer_of(name: &str, meta: &CrateMeta, maintained: Option<&Maintained>) -> String {
    if let Some(m) = maintained {
        if let Some(c) = m.crates.get(name) {
            if !c.layer.is_empty() {
                return c.layer.clone();
            }
        }
    }
    let n = name.to_ascii_lowercase();
    let d = meta.rel_dir.to_ascii_lowercase();
    let has = |s: &str| n.contains(s) || d.contains(s);
    if has("mannequin") || has("mock") || n.contains("test") {
        "test-infra".into()
    } else if has("bench") {
        "benchmarks".into()
    } else if has("xtask") {
        "dev-tooling".into()
    } else if has("connector") {
        "connectors".into()
    } else if has("repository") || has("storage") || has("backend") {
        "storage-backends".into()
    } else if has("ui") || has("embed") || has("demo") {
        "ui".into()
    } else if has("airgap") || has("dist") {
        "airgap".into()
    } else if n.ends_with("-cli") || (!meta.bins.is_empty() && !meta.has_lib) {
        "cli".into()
    } else if has("traits") || has("common") || has("core") || has("types") {
        "contracts".into()
    } else {
        "library".into()
    }
}

fn scan_env_vars(repo_root: &Path) -> BTreeMap<String, BTreeSet<String>> {
    let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for (rel, txt) in rust_sources(repo_root) {
        for pat in ["env::var(\"", "env::var_os(\"", "option_env!(\"", "env!(\""] {
            let mut rest = txt.as_str();
            while let Some(i) = rest.find(pat) {
                rest = &rest[i + pat.len()..];
                if let Some(end) = rest.find('"') {
                    let var = &rest[..end];
                    let ok = !var.is_empty()
                        && var.len() < 64
                        && var
                            .chars()
                            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
                    let build_noise =
                        var == "CARGO" || var == "OUT_DIR" || var.starts_with("CARGO_");
                    if ok && !build_noise {
                        out.entry(var.to_string()).or_default().insert(rel.clone());
                    }
                    rest = &rest[end..];
                }
            }
        }
    }
    out
}

#[derive(Debug)]
struct TodoHit {
    file: String,
    line: usize,
    kind: &'static str,
    text: String,
}

fn scan_todo_markers(repo_root: &Path) -> Vec<TodoHit> {
    const KINDS: [(&str, &str); 5] = [
        ("unimplemented!", "unimplemented"),
        ("todo!(", "todo-macro"),
        ("#[ignore", "ignored-test"),
        ("FIXME", "FIXME"),
        ("TODO", "TODO"),
    ];
    let mut out = Vec::new();
    for (rel, txt) in rust_sources(repo_root) {
        for (ln, line) in txt.lines().enumerate() {
            for (needle, kind) in KINDS {
                if line.contains(needle) {
                    let text = line.trim();
                    let text = if text.len() > 120 { &text[..120] } else { text };
                    out.push(TodoHit {
                        file: rel.clone(),
                        line: ln + 1,
                        kind,
                        text: text.to_string(),
                    });
                    break;
                }
            }
        }
    }
    out.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line)));
    out
}

#[derive(Debug, Default)]
struct TestMeta {
    integration_files: Vec<String>,
    test_fns: usize,
    ignored: usize,
}

fn scan_tests(
    repo_root: &Path,
    manifests: &BTreeMap<String, CrateMeta>,
) -> BTreeMap<String, TestMeta> {
    let mut out = BTreeMap::new();
    for (name, meta) in manifests {
        let dir = if meta.rel_dir == "." {
            repo_root.to_path_buf()
        } else {
            repo_root.join(&meta.rel_dir)
        };
        let mut tm = TestMeta::default();
        let tests_dir = dir.join("tests");
        if tests_dir.is_dir() {
            let mut files: Vec<_> = std::fs::read_dir(&tests_dir)
                .into_iter()
                .flatten()
                .flatten()
                .filter(|e| e.path().extension().is_some_and(|x| x == "rs"))
                .map(|e| e.file_name().to_string_lossy().to_string())
                .collect();
            files.sort();
            tm.integration_files = files;
        }
        let sub_dirs: Vec<PathBuf> = manifests
            .values()
            .filter(|m| {
                m.rel_dir != meta.rel_dir
                    && m.rel_dir != "."
                    && repo_root.join(&m.rel_dir).starts_with(&dir)
            })
            .map(|m| repo_root.join(&m.rel_dir))
            .collect();
        for (rel, txt) in rust_sources(&dir) {
            let abs = dir.join(&rel);
            if sub_dirs.iter().any(|s| abs.starts_with(s)) {
                continue;
            }
            tm.test_fns += txt.matches("#[test]").count() + txt.matches("#[tokio::test").count();
            tm.ignored += txt.matches("#[ignore").count();
        }
        out.insert(name.clone(), tm);
    }
    out
}

fn rust_sources(root: &Path) -> Vec<(String, String)> {
    let mut files = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(rd) = std::fs::read_dir(&dir) else {
            continue;
        };
        for e in rd.flatten() {
            let p = e.path();
            let name = e.file_name().to_string_lossy().to_string();
            if p.is_dir() {
                if !matches!(
                    name.as_str(),
                    "target" | ".git" | "node_modules" | "vendor" | "dist" | ".venv"
                ) {
                    stack.push(p);
                }
            } else if p.extension().is_some_and(|x| x == "rs") {
                if let Ok(txt) = std::fs::read_to_string(&p) {
                    let rel = p
                        .strip_prefix(root)
                        .unwrap_or(&p)
                        .to_string_lossy()
                        .to_string();
                    files.push((rel, txt));
                }
            }
        }
    }
    files.sort_by(|a, b| a.0.cmp(&b.0));
    files
}

fn git_identity(repo_root: &Path) -> Option<(String, bool)> {
    // Pure-gix (via the `nornir-git` leaf): short HEAD sha + dirty flag, no
    // `git rev-parse` / `git status` subprocess.
    let sha = nornir_git::gitops::short_head_sha(repo_root).ok()?;
    let dirty = nornir_git::gitops::is_dirty(repo_root).unwrap_or(false);
    Some((sha, dirty))
}

// ═══ Build ═══════════════════════════════════════════════════════════════════

/// Assemble the AI-context system for `repo_root`.
pub fn build(repo_root: &Path, ctx: &Ctx, opts: &AiContextOpts) -> Result<AiContextOutput> {
    let crates = apidoc::extract(
        repo_root,
        &ExtractOpts {
            include_private: opts.include_private,
            max_doc_chars: opts.max_doc_chars,
        },
    )?;
    let manifests = scan_manifests(repo_root);
    let workspace_crates: BTreeSet<String> = manifests.keys().cloned().collect();
    let maintained = load_maintained(repo_root, &workspace_crates)?;
    let env_vars = scan_env_vars(repo_root);
    let todos = scan_todo_markers(repo_root);
    let tests = scan_tests(repo_root, &manifests);
    let git = git_identity(repo_root);
    let dirty = git.as_ref().map(|(_, d)| *d).unwrap_or(false);

    if opts.mode.reproducible_required() && dirty && !opts.allow_dirty {
        bail!(
            "refusing {} generation against a dirty working tree (snapshot would be \
             non-reproducible); commit first or pass --allow-dirty",
            opts.mode.label()
        );
    }

    let repo_name = repo_root
        .file_name()
        .map(|s| s.to_string_lossy().to_string())
        .unwrap_or_else(|| "repo".into());

    let mut stats = BuildStats::default();
    for c in &crates {
        stats.crates += 1;
        for m in &c.modules {
            if m.items.is_empty() && m.doc.is_empty() {
                continue;
            }
            stats.modules += 1;
            for it in &m.items {
                stats.items += 1;
                if !it.doc.is_empty() {
                    stats.documented += 1;
                }
            }
        }
    }
    let budgets = maintained.as_ref().map(|m| &m.output);
    stats.compact_budget = budgets
        .map(|b| b.compact.max_tokens)
        .filter(|&t| t > 0)
        .unwrap_or(DEFAULT_COMPACT_TOKENS);
    stats.full_budget = budgets
        .map(|b| b.full.max_tokens)
        .filter(|&t| t > 0)
        .unwrap_or(DEFAULT_FULL_TOKENS);

    let mut warnings = Vec::new();

    // ══ COMPACT ══
    let mut out = String::new();
    out.push_str(&format!("# {repo_name} — AI context pack (compact)\n\n"));
    emit_banner_compact(&mut out, &repo_name, opts.mode, git.as_ref());
    emit_snapshot(&mut out, opts.mode, git.as_ref(), maintained.is_some());
    emit_identity(
        &mut out,
        &repo_name,
        &crates,
        &manifests,
        maintained.as_ref(),
    );
    emit_usage_rules(&mut out);
    emit_system_summary(&mut out, &crates, maintained.as_ref());
    emit_crates_table(&mut out, &crates, &manifests, maintained.as_ref());
    emit_binaries(&mut out, &manifests);
    emit_workspace_depgraph(&mut out, &manifests);
    emit_hotspots(&mut out, maintained.as_ref());
    emit_high_coupling(&mut out, &manifests);
    emit_features(&mut out, &manifests);
    emit_wiring(&mut out, maintained.as_ref());
    emit_capabilities(&mut out, maintained.as_ref());
    emit_change_impact(&mut out, maintained.as_ref());
    emit_failures(&mut out, maintained.as_ref());
    emit_security(&mut out, maintained.as_ref());
    emit_invariants(&mut out, maintained.as_ref());
    emit_cli(&mut out, maintained.as_ref());
    emit_config(&mut out, maintained.as_ref());
    emit_precedence(&mut out, maintained.as_ref());
    emit_env_vars(&mut out, &env_vars, maintained.as_ref(), &mut warnings);
    emit_tests(&mut out, &tests);
    emit_tasks(&mut out, maintained.as_ref());
    emit_todos(&mut out, &todos);
    emit_status_legend(&mut out);
    emit_warnings(&mut out, maintained.as_ref());
    emit_documents(&mut out, maintained.as_ref());
    emit_api_summary(&mut out, &crates);
    emit_final_index(
        &mut out,
        &crates,
        &manifests,
        maintained.as_ref(),
        &env_vars,
    );

    // ══ FULL ══
    let mut full = String::new();
    full.push_str(&format!(
        "# {repo_name} — AI context pack (full API reference)\n\n"
    ));
    emit_banner_full(&mut full, &repo_name, opts.mode, git.as_ref());
    emit_snapshot(&mut full, opts.mode, git.as_ref(), maintained.is_some());
    emit_full_backlinks(&mut full);
    let anchors = emit_api_reference(&mut full, &crates, repo_root);
    if opts.include_prose {
        emit_prose_current(&mut full, repo_root, ctx, maintained.as_ref());
    }

    // Validate cross-file anchors.
    for c in &crates {
        let slug = format!("crate-{}", slugify(&c.name));
        if out.contains(&format!("ai-context-full.md#{slug}")) && !anchors.contains(&slug) {
            bail!("broken cross-file anchor: compact links #{slug} but full has no such heading");
        }
    }

    stats.bytes = out.len();
    stats.full_bytes = full.len();
    stats.compact_tokens = approx_tokens(&out);
    stats.full_tokens = approx_tokens(&full);
    if stats.compact_tokens > stats.compact_budget {
        stats.over_budget = true;
        warnings.push(format!(
            "compact pack ~{} tokens exceeds budget {} — trim duplicate prose / external deps / trivial APIs (never wiring/security/failure/task/capability/invariant content)",
            stats.compact_tokens, stats.compact_budget
        ));
    }
    if stats.full_tokens > stats.full_budget {
        stats.over_budget = true;
        warnings.push(format!(
            "full pack ~{} tokens exceeds budget {} — reduce rustdoc repetition / generated boilerplate",
            stats.full_tokens, stats.full_budget
        ));
    }

    Ok(AiContextOutput {
        compact: out,
        full,
        stats,
        warnings,
    })
}

fn approx_tokens(s: &str) -> usize {
    s.chars().count().div_ceil(4)
}

// ─── Banners + snapshot ──────────────────────────────────────────────────────

fn source_line(git: Option<&(String, bool)>) -> String {
    match git {
        Some((sha, dirty)) => {
            format!(
                "source {}{}",
                sha,
                if *dirty {
                    " (dirty tree — non-reproducible)"
                } else {
                    ""
                }
            )
        }
        None => "source unknown".into(),
    }
}

fn emit_banner_compact(
    out: &mut String,
    repo_name: &str,
    mode: GenMode,
    git: Option<&(String, bool)>,
) {
    out.push_str(&format!(
        "> **GENERATED — do not edit; regenerate with `nornir docs ai-context {repo}` (nornir {ver}).**\n>\n\
         > This is the **compact primary context — read this file first.** It carries everything an \
         AI coding agent needs to place and make a change safely: architecture, runtime wiring, \
         capability matrix, failure semantics, invariants, and the task→file map.\n>\n\
         > Consult **`docs/ai-context-full.md`** (the exhaustive companion) ONLY when you need exact \
         API signatures, complete rustdoc, private implementation detail, or precise source \
         references. Do not load it for routine reasoning.\n\n\
         _Generated {date} · {src} · mode {mode}._\n\n",
        repo = repo_name,
        ver = env!("CARGO_PKG_VERSION"),
        date = chrono::Utc::now().format("%Y-%m-%d"),
        src = source_line(git),
        mode = mode.label(),
    ));
}

fn emit_banner_full(
    out: &mut String,
    repo_name: &str,
    mode: GenMode,
    git: Option<&(String, bool)>,
) {
    out.push_str(&format!(
        "> **GENERATED — do not edit; regenerate with `nornir docs ai-context {repo}` (nornir {ver}).**\n>\n\
         > This is the **exhaustive companion. Read `docs/ai-context.md` first** for architecture, \
         wiring and invariants. This file is **not intended to be loaded for every task** — its \
         purpose is exact lookup (signatures, rustdoc, source `file:line`), not initial \
         architectural orientation.\n\n\
         _Generated {date} · {src} · mode {mode}._\n\n",
        repo = repo_name,
        ver = env!("CARGO_PKG_VERSION"),
        date = chrono::Utc::now().format("%Y-%m-%d"),
        src = source_line(git),
        mode = mode.label(),
    ));
}

fn emit_snapshot(out: &mut String, mode: GenMode, git: Option<&(String, bool)>, has_meta: bool) {
    let (rev, dirty) = match git {
        Some((s, d)) => (s.clone(), *d),
        None => ("unknown".into(), false),
    };
    out.push_str("## Snapshot\n\n");
    out.push_str("| field | value |\n|---|---|\n");
    out.push_str(&format!("| source revision | `{rev}` |\n"));
    out.push_str(&format!(
        "| working tree | {} |\n",
        if dirty { "dirty" } else { "clean" }
    ));
    out.push_str(&format!(
        "| reproducible snapshot | {} |\n",
        if dirty { "no" } else { "yes" }
    ));
    out.push_str(&format!("| generation mode | {} |\n", mode.label()));
    if dirty && mode.reproducible_required() {
        out.push_str("| dirty override | yes (--allow-dirty) |\n");
    }
    out.push_str(&format!(
        "| generator | `nornir {}` |\n",
        env!("CARGO_PKG_VERSION")
    ));
    out.push_str(&format!(
        "| maintained metadata | {} |\n\n",
        if has_meta {
            format!("`{MAINTAINED_METADATA_PATH}` (validated)")
        } else {
            "none".into()
        }
    ));
    if dirty {
        out.push_str("> ⚠ Working tree is dirty — this snapshot is **not reproducible**; `file:line` references may drift from committed code.\n\n");
    }
}

// ─── Identity / usage / summary ──────────────────────────────────────────────

fn emit_identity(
    out: &mut String,
    repo_name: &str,
    crates: &[ApiCrate],
    manifests: &BTreeMap<String, CrateMeta>,
    maintained: Option<&Maintained>,
) {
    out.push_str("## Repository identity\n\n");
    let bins: usize = manifests.values().map(|m| m.bins.len()).sum();
    out.push_str("| | |\n|---|---|\n");
    out.push_str(&format!("| repository | `{repo_name}` |\n"));
    if let Some(m) = maintained {
        if !m.repository.purpose.is_empty() {
            out.push_str(&format!(
                "| purpose | {} |\n",
                md_cell(&m.repository.purpose)
            ));
        }
        if !m.repository.role.is_empty() {
            out.push_str(&format!(
                "| system role | {} |\n",
                md_cell(&m.repository.role)
            ));
        }
    }
    out.push_str(&format!(
        "| language / workspace | Rust · {} crates · {} binaries |\n",
        crates.len(),
        bins
    ));
    out.push('\n');
    if let Some(m) = maintained {
        if !m.repository.non_goals.is_empty() {
            out.push_str("Non-goals:\n\n");
            for g in &m.repository.non_goals {
                out.push_str(&format!("- {g}\n"));
            }
            out.push('\n');
        }
    }
}

fn emit_usage_rules(out: &mut String) {
    out.push_str("## AI usage instructions\n\n");
    out.push_str(
        "**Source-of-truth hierarchy** (highest first): (1) executable tests, (2) active runtime \
         wiring, (3) implementation, (4) configuration and feature definitions, (5) rustdoc, \
         (6) current architecture documents, (7) generated summaries (this pack), (8) historical \
         documents, (9) inferred intent. Lower-priority evidence never overrides verified code.\n\n\
         Rules:\n\n\
         - Read this compact file first; open `docs/ai-context-full.md` only for exact signatures/rustdoc/source refs.\n\
         - Inspect the referenced source files before modifying behavior; `file:line` refs are exact at generation time.\n\
         - An exported API does not prove it is called; check runtime registration before assuming a backend/connector/auth mode is available.\n\
         - Check feature gates before concluding code is active; feature-gated code may be compiled out of a tier.\n\
         - Read and write support are not symmetric; verify writability separately (see the capability matrix).\n\
         - Where partial failure is represented as data (tallies/reports), a successful return can still carry failures (see failure semantics).\n\
         - Preserve path, size, origin, and immutability protections in the security map and invariants.\n\
         - Do not edit generated files (this pack included); regenerate them.\n\
         - Run the subsystem's tests (Task-to-file map + Tests inventory) after changes.\n\n",
    );
}

fn emit_system_summary(out: &mut String, crates: &[ApiCrate], maintained: Option<&Maintained>) {
    out.push_str("## System summary\n\n");
    if let Some(m) = maintained {
        if !m.repository.system_summary.is_empty() {
            out.push_str(m.repository.system_summary.trim());
            out.push_str("\n\n");
            return;
        }
    }
    for c in crates {
        if !c.crate_doc.is_empty() {
            out.push_str(&format!(
                "**`{}`** — {}\n\n",
                c.name,
                strip_lead_name(&c.name, &first_para(&c.crate_doc))
            ));
        }
    }
}

fn emit_crates_table(
    out: &mut String,
    crates: &[ApiCrate],
    manifests: &BTreeMap<String, CrateMeta>,
    maintained: Option<&Maintained>,
) {
    out.push_str("## Crates and layers\n\n");
    if crates.is_empty() {
        out.push_str("_No Rust crates with a `[package]` were found under this repo._\n\n");
        return;
    }
    out.push_str("| crate | layer | path | description | modules | items | note |\n");
    out.push_str("|---|---|---|---|---:|---:|---|\n");
    let empty = CrateMeta::default();
    for c in crates {
        let meta = manifests.get(&c.name).unwrap_or(&empty);
        let layer = layer_of(&c.name, meta, maintained);
        let note = maintained
            .and_then(|m| m.crates.get(&c.name))
            .map(|mc| mc.note.clone())
            .unwrap_or_default();
        let n_items: usize = c.modules.iter().map(|m| m.items.len()).sum();
        out.push_str(&format!(
            "| `{}` | {} | `{}` | {} | {} | {} | {} |\n",
            c.name,
            layer,
            meta.rel_dir,
            md_cell(&first_line(&c.description).unwrap_or_default()),
            c.modules.len(),
            n_items,
            md_cell(&note),
        ));
    }
    out.push('\n');
}

fn emit_binaries(out: &mut String, manifests: &BTreeMap<String, CrateMeta>) {
    let mut rows = Vec::new();
    for (name, meta) in manifests {
        for b in &meta.bins {
            rows.push((
                b.name.clone(),
                name.clone(),
                meta.rel_dir.clone(),
                b.required_features.join(", "),
            ));
        }
    }
    if rows.is_empty() {
        return;
    }
    rows.sort();
    out.push_str("## Binaries\n\n");
    out.push_str("| binary | crate | path | required features |\n|---|---|---|---|\n");
    for (bin, krate, dir, feats) in rows {
        out.push_str(&format!(
            "| `{bin}` | `{krate}` | `{dir}` | {} |\n",
            if feats.is_empty() {
                "—".into()
            } else {
                format!("`{feats}`")
            }
        ));
    }
    out.push('\n');
}

// ─── Dependency views (3.1–3.4) ──────────────────────────────────────────────

fn emit_workspace_depgraph(out: &mut String, manifests: &BTreeMap<String, CrateMeta>) {
    if !manifests.values().any(|m| !m.deps.is_empty()) {
        return;
    }
    out.push_str("## Workspace dependency graph\n\n");
    out.push_str("Edges BETWEEN workspace crates only (external deps are summarized separately). Deduplicated; a runtime edge supersedes a dev/build edge to the same crate.\n\n");
    out.push_str("| crate | depends on (workspace) | kind | feature-gated |\n|---|---|---|---|\n");
    for (name, meta) in manifests {
        if meta.deps.is_empty() {
            continue;
        }
        for kind in [DepKind::Runtime, DepKind::Dev, DepKind::Build] {
            let of_kind: Vec<&DepEdge> = meta.deps.iter().filter(|d| d.kind == kind).collect();
            if of_kind.is_empty() {
                continue;
            }
            let deps = of_kind
                .iter()
                .map(|d| format!("`{}`", d.to))
                .collect::<Vec<_>>()
                .join(", ");
            let gated: Vec<String> = of_kind
                .iter()
                .filter(|d| d.optional)
                .map(|d| d.to.clone())
                .collect();
            let gated_cell = if gated.is_empty() {
                "—".into()
            } else {
                gated
                    .iter()
                    .map(|g| format!("`{g}`"))
                    .collect::<Vec<_>>()
                    .join(", ")
            };
            out.push_str(&format!(
                "| `{name}` | {deps} | {} | {gated_cell} |\n",
                kind.label()
            ));
        }
    }
    out.push('\n');
}

fn emit_hotspots(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.hotspots.is_empty() {
        return;
    }
    out.push_str("## Central dependency hotspots\n\n");
    out.push_str("External dependencies that materially shape architecture, security, build tiers, binary size, async behavior, storage, or portability. Utility deps are omitted.\n\n");
    out.push_str("| dependency | used by | architectural effect | feature/tier impact |\n|---|---|---|---|\n");
    for h in &m.hotspots {
        out.push_str(&format!(
            "| `{}` | {} | {} | {} |\n",
            md_cell(&h.dependency),
            md_cell(&h.used_by),
            md_cell(&h.effect),
            md_cell(&h.tier_impact)
        ));
    }
    out.push('\n');
}

fn emit_high_coupling(out: &mut String, manifests: &BTreeMap<String, CrateMeta>) {
    let mut incoming: BTreeMap<String, usize> = BTreeMap::new();
    for meta in manifests.values() {
        let mut seen = BTreeSet::new();
        for d in &meta.deps {
            if seen.insert(d.to.clone()) {
                *incoming.entry(d.to.clone()).or_default() += 1;
            }
        }
    }
    let mut rows: Vec<(String, usize, usize)> = manifests
        .iter()
        .map(|(name, meta)| {
            let out_edges = meta
                .deps
                .iter()
                .map(|d| d.to.clone())
                .collect::<BTreeSet<_>>()
                .len();
            (name.clone(), *incoming.get(name).unwrap_or(&0), out_edges)
        })
        .filter(|(_, inc, _)| *inc >= 3)
        .collect();
    if rows.is_empty() {
        return;
    }
    rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
    out.push_str("## High-coupling crates\n\n");
    out.push_str("Workspace crates with ≥3 downstream consumers — a signature change here ripples widely. See the change-impact map for the exact contracts.\n\n");
    out.push_str("| crate | incoming edges | outgoing edges | why changes are high impact |\n|---|---:|---:|---|\n");
    for (name, inc, outg) in rows {
        out.push_str(&format!("| `{name}` | {inc} | {outg} | {inc} workspace crates depend on it; changing its exported types/traits breaks them at compile time |\n"));
    }
    out.push('\n');
}

fn emit_features(out: &mut String, manifests: &BTreeMap<String, CrateMeta>) {
    if !manifests.values().any(|m| !m.features.is_empty()) {
        return;
    }
    out.push_str("## Feature flags\n\n");
    out.push_str("Feature-gated code may be compiled out of a given binary — check a tier's build flags before assuming a path is active.\n\n");
    out.push_str("| crate | feature | enables |\n|---|---|---|\n");
    for (name, meta) in manifests {
        for (feat, deps) in &meta.features {
            let enables = if deps.is_empty() {
                "—".to_string()
            } else {
                deps.join(", ")
            };
            out.push_str(&format!(
                "| `{name}` | `{feat}` | {} |\n",
                md_cell(&enables)
            ));
        }
    }
    out.push('\n');
}

// ─── Maintained maps ─────────────────────────────────────────────────────────

fn emit_wiring(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.wiring.is_empty() {
        return;
    }
    out.push_str("## Runtime wiring map\n\n");
    out.push_str("Where each capability is implemented, registered at runtime, configured, and tested. `status` distinguishes wired behavior from code that merely exists.\n\n");
    out.push_str("| capability | interface | implementation | registration | configuration | tests | status |\n|---|---|---|---|---|---|---|\n");
    for w in &m.wiring {
        out.push_str(&format!(
            "| {} | {} | {} | {} | {} | {} | {} |\n",
            md_cell(&w.capability),
            md_code(&w.interface),
            md_code(&w.implementation),
            md_code(&w.registration),
            md_cell(&w.configuration),
            md_code(&w.tests),
            md_status(&w.status),
        ));
    }
    out.push('\n');
}

fn emit_capabilities(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.capabilities.is_empty() {
        return;
    }
    out.push_str("## Package ecosystem capability matrix\n\n");
    out.push_str("Verified per-ecosystem support. Values: `Yes` / `No` / `Partial` / `Feature-gated` / `Dev-only` / `Unverified`. Read and write are NOT symmetric — do not infer one from the other.\n\n");
    out.push_str("| ecosystem | protocol fetch | protocol upload | generic fetch | browse/list | native index | writable backend | znippy backend | proxy/upstream | limitation | evidence |\n");
    out.push_str("|---|---|---|---|---|---|---|---|---|---|---|\n");
    for c in &m.capabilities {
        out.push_str(&format!(
            "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
            md_cell(&c.ecosystem),
            cap(&c.protocol_fetch),
            cap(&c.protocol_upload),
            cap(&c.generic_fetch),
            cap(&c.browse),
            cap(&c.native_index),
            cap(&c.writable_backend),
            cap(&c.znippy_backend),
            cap(&c.proxy_upstream),
            md_cell(&c.limitation),
            md_code_list(&c.evidence),
        ));
    }
    out.push('\n');
}

fn emit_change_impact(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.change_impact.is_empty() {
        return;
    }
    out.push_str("## Change impact map\n\n");
    out.push_str("Shared interfaces/types and what a signature change ripples into. Consult before altering a central trait.\n\n");
    out.push_str("| interface / type | defined in | implementers | main consumers | runtime significance | required tests |\n|---|---|---|---|---|---|\n");
    for c in &m.change_impact {
        out.push_str(&format!(
            "| {} | {} | {} | {} | {} | {} |\n",
            md_code(&c.interface),
            md_code(&c.defined_in),
            md_cell(&c.implementers),
            md_cell(&c.consumers),
            md_cell(&c.significance),
            md_code_list(&c.tests),
        ));
    }
    out.push('\n');
}

fn emit_failures(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.failures.is_empty() {
        return;
    }
    out.push_str("## Failure semantics\n\n");
    out.push_str("Per-operation: what aborts, what is counted-and-skipped, whether an `Ok` result still carries failures, retries, cleanup, and how the outcome becomes observable.\n\n");
    out.push_str("| operation | fatal errors | partial success | retry | cleanup / rollback | observable result | evidence |\n|---|---|---|---|---|---|---|\n");
    for f in &m.failures {
        out.push_str(&format!(
            "| {} | {} | {} | {} | {} | {} | {} |\n",
            md_cell(&f.operation),
            md_cell(&f.fatal),
            md_cell(&f.partial),
            md_cell(&f.retry),
            md_cell(&f.cleanup),
            md_cell(&f.observable),
            md_code_list(&f.evidence),
        ));
    }
    out.push('\n');
}

fn emit_security(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.security.is_empty() {
        return;
    }
    out.push_str("## Security-sensitive code map\n\n");
    out.push_str("| concern | invariant | implementation | tests | consequence if broken |\n|---|---|---|---|---|\n");
    for s in &m.security {
        out.push_str(&format!(
            "| {} | {} | {} | {} | {} |\n",
            md_cell(&s.concern),
            md_cell(&s.invariant),
            md_code_list(&s.implementation),
            md_code_list(&s.tests),
            md_cell(&s.consequence),
        ));
    }
    out.push('\n');
}

fn emit_invariants(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.invariants.is_empty() {
        return;
    }
    out.push_str("## Non-negotiable invariants\n\n");
    for inv in &m.invariants {
        let st = if inv.status.is_empty() {
            String::new()
        } else {
            format!(" · {}", md_status(&inv.status))
        };
        out.push_str(&format!("**`{}`**{st} — {}\n\n", inv.id, inv.statement));
        if !inv.enforced.is_empty() {
            out.push_str(&format!("- enforced: {}\n", md_code_list(&inv.enforced)));
        }
        if !inv.tests.is_empty() {
            out.push_str(&format!("- verified: {}\n", md_code_list(&inv.tests)));
        }
        if !inv.break_risk.is_empty() {
            out.push_str(&format!("- break risk: {}\n", inv.break_risk));
        }
        out.push('\n');
    }
}

fn emit_cli(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.cli.is_empty() {
        return;
    }
    out.push_str("## CLI command inventory\n\n");
    out.push_str("| binary | command | important flags | calls into | feature/tier | tests |\n|---|---|---|---|---|---|\n");
    for c in &m.cli {
        out.push_str(&format!(
            "| `{}` | `{}` | {} | {} | {} | {} |\n",
            md_cell(&c.binary),
            md_cell(&c.command),
            md_cell(&c.flags),
            md_code(&c.calls),
            md_cell(&c.restrictions),
            md_code_list(&c.tests),
        ));
    }
    out.push('\n');
}

fn emit_config(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.config.is_empty() {
        return;
    }
    out.push_str("## RON configuration inventory\n\n");
    out.push_str("| field | type | default | required | sensitive | runtime consumer | feature/tier |\n|---|---|---|---|---|---|---|\n");
    for c in &m.config {
        out.push_str(&format!(
            "| `{}` | {} | {} | {} | {} | {} | {} |\n",
            md_cell(&c.field),
            md_cell(&c.kind),
            md_cell(&c.default),
            yn(c.required),
            yn(c.sensitive),
            md_code(&c.consumer),
            md_cell(&c.restrictions),
        ));
    }
    out.push('\n');
}

fn emit_precedence(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.precedence.is_empty() {
        return;
    }
    out.push_str("## Configuration precedence\n\n");
    out.push_str("Verified precedence where inputs overlap (lowest → highest). Unverified overlaps are marked.\n\n");
    for (i, step) in m.precedence.iter().enumerate() {
        out.push_str(&format!("{}. {}\n", i + 1, step));
    }
    out.push('\n');
}

fn emit_env_vars(
    out: &mut String,
    scanned: &BTreeMap<String, BTreeSet<String>>,
    maintained: Option<&Maintained>,
    warnings: &mut Vec<String>,
) {
    out.push_str("## Environment variables\n\n");
    let documented: BTreeSet<String> = maintained
        .map(|m| m.env.iter().map(|e| e.name.clone()).collect())
        .unwrap_or_default();

    if let Some(m) = maintained {
        if !m.env.is_empty() {
            out.push_str("Maintained inventory (effect/sensitivity are not derivable). `HOME`/`USER`/`XDG_*` are excluded unless they materially change behavior.\n\n");
            out.push_str("| variable | kind | default | sensitive | phase | effect | evidence |\n|---|---|---|---|---|---|---|\n");
            for e in &m.env {
                out.push_str(&format!(
                    "| `{}` | {} | {} | {} | {} | {} | {} |\n",
                    md_cell(&e.name),
                    md_cell(&e.kind),
                    md_cell(&e.default),
                    yn(e.sensitive),
                    md_cell(&e.phase),
                    md_cell(&e.effect),
                    md_code_list(&e.evidence),
                ));
            }
            out.push('\n');
        }
    }

    const BENIGN: &[&str] = &[
        "HOME",
        "USER",
        "XDG_RUNTIME_DIR",
        "TMPDIR",
        "TMP",
        "PATH",
        "LANG",
        "TERM",
        "SHELL",
        "PWD",
        "HOSTNAME",
    ];
    let undocumented: Vec<String> = scanned
        .keys()
        .filter(|v| !documented.contains(*v) && !BENIGN.contains(&v.as_str()))
        .cloned()
        .collect();
    if !undocumented.is_empty() {
        out.push_str("Found in source but not in the maintained inventory (add them to `.nornir/ai-context.toml [[env]]`):\n\n");
        for v in &undocumented {
            let files: Vec<_> = scanned[v]
                .iter()
                .take(2)
                .map(|f| format!("`{f}`"))
                .collect();
            out.push_str(&format!("- `{v}` — {}\n", files.join(", ")));
        }
        out.push('\n');
        warnings.push(format!(
            "{} env var(s) found in source but undocumented in metadata",
            undocumented.len()
        ));
    }
    if let Some(m) = maintained {
        let stale: Vec<&str> = m
            .env
            .iter()
            .map(|e| e.name.as_str())
            .filter(|n| !scanned.contains_key(*n))
            .collect();
        if !stale.is_empty() {
            warnings.push(format!(
                "documented env var(s) not found in source scan: {}",
                stale.join(", ")
            ));
        }
    }
}

fn emit_tests(out: &mut String, tests: &BTreeMap<String, TestMeta>) {
    if !tests
        .values()
        .any(|t| t.test_fns > 0 || !t.integration_files.is_empty())
    {
        return;
    }
    out.push_str("## Tests inventory\n\n");
    out.push_str("Run a crate's tests with `cargo test -p <crate>` (some need features/env — see the Task-to-file map). `#[ignore]`d arms are opt-in heavy tests.\n\n");
    out.push_str("| crate | test fns | ignored | integration test files |\n|---|---:|---:|---|\n");
    for (name, t) in tests {
        if t.test_fns == 0 && t.integration_files.is_empty() {
            continue;
        }
        let mut files = t.integration_files.clone();
        let extra = files.len().saturating_sub(6);
        files.truncate(6);
        let mut cell = files
            .iter()
            .map(|f| format!("`{f}`"))
            .collect::<Vec<_>>()
            .join(", ");
        if extra > 0 {
            cell.push_str(&format!(" (+{extra} more)"));
        }
        if cell.is_empty() {
            cell = "—".into();
        }
        out.push_str(&format!(
            "| `{name}` | {} | {} | {cell} |\n",
            t.test_fns, t.ignored
        ));
    }
    out.push('\n');
}

fn emit_tasks(out: &mut String, maintained: Option<&Maintained>) {
    let Some(m) = maintained else { return };
    if m.tasks.is_empty() {
        return;
    }
    out.push_str("## Task-to-file map\n\n");
    out.push_str("Test columns are runnable commands. Prefer `fast`; run `full` before landing; `heavy` needs containers/network or `-- --ignored`.\n\n");
    out.push_str("| task | start here | follow-up | invariants | fast test | full subsystem test | heavy/optional |\n|---|---|---|---|---|---|---|\n");
    for t in &m.tasks {
        let fast = if t.fast_tests.is_empty() {
            &t.tests
        } else {
            &t.fast_tests
        };
        out.push_str(&format!(
            "| {} | {} | {} | {} | {} | {} | {} |\n",
            md_cell(&t.name),
            md_code_list(&t.start),
            md_code_list(&t.related),
            md_cell(&t.invariants.join(", ")),
            md_code_list(fast),
            md_code_list(&t.full_tests),
            md_code_list(&t.heavy_tests),
        ));
    }
    out.push('\n');
}

fn emit_todos(out: &mut String, todos: &[TodoHit]) {
    if todos.is_empty() {
        return;
    }
    out.push_str("## Incomplete-work markers\n\n");
    out.push_str("Derived scan: TODO / FIXME / `unimplemented!` / `todo!` / `#[ignore]`. An `#[ignore]`d test is opt-in (often heavy), not broken. Treat these as spots where rustdoc may promise more than the code delivers.\n\n");
    const CAP: usize = 120;
    out.push_str("| kind | site | text |\n|---|---|---|\n");
    for t in todos.iter().take(CAP) {
        out.push_str(&format!(
            "| {} | `{}:{}` | {} |\n",
            t.kind,
            t.file,
            t.line,
            md_cell(&t.text)
        ));
    }
    if todos.len() > CAP {
        out.push_str(&format!(
            "\n_({} more markers not shown.)_\n",
            todos.len() - CAP
        ));
    }
    out.push('\n');
}

fn emit_status_legend(out: &mut String) {
    out.push_str("## Status legend\n\n");
    out.push_str("Statuses used across this pack mean exactly:\n\n");
    for s in ALLOWED_STATUSES {
        out.push_str(&format!("- `{s}`\n"));
    }
    out.push('\n');
}

fn emit_warnings(out: &mut String, maintained: Option<&Maintained>) {
    out.push_str("## AI reasoning warnings\n\n");
    for w in [
        "An enum variant does not prove runtime support; check the dispatch site.",
        "A dependency in Cargo.toml does not prove wiring; check construction/registration.",
        "An exported function does not prove it is called.",
        "A backend may support reads but not writes (or vice versa) — see the capability matrix.",
        "A successful operation result may still carry failed-item tallies — see failure semantics.",
        "Test-only/mock behavior (mannequins, fixtures, dev pairs) is not production behavior.",
        "A crate description may be stale relative to feature wiring.",
        "Generated files must be regenerated, never hand-edited.",
    ] {
        out.push_str(&format!("- {w}\n"));
    }
    if let Some(m) = maintained {
        for w in &m.warnings {
            out.push_str(&format!("- {w}\n"));
        }
    }
    out.push('\n');
}

fn emit_documents(out: &mut String, maintained: Option<&Maintained>) {
    out.push_str("## Prose documents\n\n");
    out.push_str(
        "Human-facing documentation excluded from compact AI context.\n\
         Relevant verified implementation facts are extracted into the system summary,\n\
         runtime wiring, capability matrices, warnings, and invariants.\n\n",
    );
    let Some(m) = maintained else { return };
    if m.documents.is_empty() {
        return;
    }
    out.push_str("Status classification of the repo's prose (drives full-pack inclusion; historical/superseded/marketing prose never overrides code):\n\n");
    out.push_str("| document | status | in compact | in full | reason |\n|---|---|---|---|---|\n");
    for d in &m.documents {
        out.push_str(&format!(
            "| `{}` | {} | {} | {} | {} |\n",
            md_cell(&d.path),
            md_status(&d.status),
            yn(d.include_compact),
            yn(d.include_full),
            md_cell(&d.reason),
        ));
    }
    out.push('\n');
}

fn emit_api_summary(out: &mut String, crates: &[ApiCrate]) {
    out.push_str("## API summary\n\n");
    out.push_str("Per-crate exported surface. Exact signatures + docs are in `docs/ai-context-full.md` under the linked crate heading.\n\n");
    out.push_str(
        "| crate | modules | items | largest modules | full reference |\n|---|---:|---:|---|---|\n",
    );
    for c in crates {
        let n_items: usize = c.modules.iter().map(|m| m.items.len()).sum();
        let mut mods: Vec<_> = c
            .modules
            .iter()
            .filter(|m| !m.items.is_empty())
            .map(|m| (m.items.len(), m.path.clone()))
            .collect();
        mods.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
        let top = mods
            .iter()
            .take(4)
            .map(|(n, p)| format!("`{p}` ({n})"))
            .collect::<Vec<_>>()
            .join(", ");
        let slug = format!("crate-{}", slugify(&c.name));
        out.push_str(&format!(
            "| `{}` | {} | {} | {} | [full](docs/ai-context-full.md#{slug}) |\n",
            c.name,
            c.modules.len(),
            n_items,
            if top.is_empty() { "—".into() } else { top },
        ));
    }
    out.push('\n');
}

fn emit_final_index(
    out: &mut String,
    crates: &[ApiCrate],
    manifests: &BTreeMap<String, CrateMeta>,
    maintained: Option<&Maintained>,
    env_vars: &BTreeMap<String, BTreeSet<String>>,
) {
    out.push_str("## Compact index\n\n");
    let mut by_layer: BTreeMap<String, Vec<String>> = BTreeMap::new();
    let empty = CrateMeta::default();
    for c in crates {
        let meta = manifests.get(&c.name).unwrap_or(&empty);
        by_layer
            .entry(layer_of(&c.name, meta, maintained))
            .or_default()
            .push(c.name.clone());
    }
    for (layer, names) in &by_layer {
        out.push_str(&format!(
            "- **{layer}**: {}\n",
            names
                .iter()
                .map(|n| format!("`{n}`"))
                .collect::<Vec<_>>()
                .join(", ")
        ));
    }
    let bins: Vec<String> = manifests
        .values()
        .flat_map(|m| m.bins.iter().map(|b| format!("`{}`", b.name)))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect();
    if !bins.is_empty() {
        out.push_str(&format!("- **binaries**: {}\n", bins.join(", ")));
    }
    if !env_vars.is_empty() {
        out.push_str(&format!(
            "- **env vars (scanned)**: {}\n",
            env_vars
                .keys()
                .map(|v| format!("`{v}`"))
                .collect::<Vec<_>>()
                .join(", ")
        ));
    }
    if let Some(m) = maintained {
        if !m.tasks.is_empty() {
            out.push_str(&format!(
                "- **tasks mapped**: {}\n",
                m.tasks
                    .iter()
                    .map(|t| t.name.clone())
                    .collect::<Vec<_>>()
                    .join(" · ")
            ));
        }
        if !m.invariants.is_empty() {
            out.push_str(&format!(
                "- **invariants**: {}\n",
                m.invariants
                    .iter()
                    .map(|i| format!("`{}`", i.id))
                    .collect::<Vec<_>>()
                    .join(", ")
            ));
        }
        if !m.capabilities.is_empty() {
            out.push_str(&format!(
                "- **ecosystems**: {}\n",
                m.capabilities
                    .iter()
                    .map(|c| c.ecosystem.clone())
                    .collect::<Vec<_>>()
                    .join(", ")
            ));
        }
    }
    out.push('\n');
}

// ─── Full-pack emitters ──────────────────────────────────────────────────────

fn emit_full_backlinks(out: &mut String) {
    out.push_str("## Orientation\n\n");
    out.push_str(
        "Start in the compact pack for reasoning context, then return here for exact API detail:\n\n\
         - Runtime wiring → `docs/ai-context.md#runtime-wiring-map`\n\
         - Security invariants → `docs/ai-context.md#non-negotiable-invariants`\n\
         - Failure semantics → `docs/ai-context.md#failure-semantics`\n\
         - Capability matrix → `docs/ai-context.md#package-ecosystem-capability-matrix`\n\
         - Task-to-file map → `docs/ai-context.md#task-to-file-map`\n\n",
    );
}

/// Emit the exhaustive API reference. Returns the set of crate-heading anchors
/// emitted. Trivial accessors are dropped; the generated protobuf module is
/// summarized rather than dumped; `file:line` refs are made repo-relative.
fn emit_api_reference(out: &mut String, crates: &[ApiCrate], repo_root: &Path) -> BTreeSet<String> {
    out.push_str("## API reference\n\n");
    out.push_str("Every exported item, grouped by crate then module, with signature, repo-relative `file:line`, and documentation. Trivial accessors are omitted; generated protobuf is summarized (see `proto/holger.proto`).\n\n");
    let mut anchors = BTreeSet::new();
    for c in crates {
        let slug = format!("crate-{}", slugify(&c.name));
        anchors.insert(slug);
        out.push_str(&format!("### Crate `{}`\n\n", c.name));
        let crate_rel = c
            .root
            .strip_prefix(repo_root)
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_default();
        if !c.description.is_empty() {
            out.push_str(&format!(
                "_{}_\n\n",
                first_line(&c.description).unwrap_or_default()
            ));
        }
        if !c.crate_doc.is_empty() {
            out.push_str(&c.crate_doc);
            out.push_str("\n\n");
        }
        let mut proto_done = false;
        for m in &c.modules {
            if is_generated_proto(&m.path) {
                if !proto_done {
                    emit_proto_summary(out, c, &crate_rel);
                    proto_done = true;
                }
                continue;
            }
            let visible: Vec<_> = m.items.iter().filter(|it| !is_trivial_item(it)).collect();
            if visible.is_empty() && m.doc.is_empty() {
                continue;
            }
            out.push_str(&format!("#### Module `{}`\n\n", m.path));
            if !m.doc.is_empty() {
                out.push_str(&blockquote(&m.doc));
                out.push_str("\n\n");
            }
            for it in visible {
                let visprefix = if it.visibility == "pub" || it.visibility == "priv" {
                    String::new()
                } else {
                    format!("{} ", it.visibility)
                };
                let file = repo_rel_file(&crate_rel, &it.file);
                out.push_str(&format!(
                    "**`{}{}`** · `{}:{}`\n\n",
                    visprefix, it.signature, file, it.line
                ));
                if !it.doc.is_empty() {
                    out.push_str(&it.doc);
                    out.push_str("\n\n");
                }
            }
        }
    }
    anchors
}

/// True for any module inside the generated protobuf tree (`…generated::…v1…`).
fn is_generated_proto(module_path: &str) -> bool {
    module_path.contains("generated::") && module_path.contains(".v1")
}

fn emit_proto_summary(out: &mut String, c: &ApiCrate, crate_rel: &str) {
    let mut services = BTreeSet::new();
    let mut messages = BTreeSet::new();
    for m in &c.modules {
        if !is_generated_proto(&m.path) {
            continue;
        }
        if let Some(svc) = m
            .path
            .split("::")
            .find(|s| s.ends_with("_service_server") || s.ends_with("_service_client"))
        {
            let base = svc.trim_end_matches("_server").trim_end_matches("_client");
            services.insert(base.to_string());
        }
        for it in &m.items {
            if it.kind == "struct" && m.path.ends_with(".v1") {
                messages.insert(it.name.clone());
            }
        }
    }
    out.push_str("#### Generated protobuf (summarized)\n\n");
    out.push_str(&format!(
        "The proto-generated Rust lives at `{}` (do not edit — regenerate via `cargo xtask gen-proto` from `proto/holger.proto`). Summary instead of a full dump:\n\n",
        repo_rel_file(crate_rel, "src/generated/holger.v1.rs")
    ));
    if !services.is_empty() {
        let svc = services
            .iter()
            .map(|s| format!("`{s}`"))
            .collect::<Vec<_>>()
            .join(", ");
        out.push_str(&format!("- **services**: {svc}\n"));
    }
    if !messages.is_empty() {
        let msg = messages
            .iter()
            .map(|s| format!("`{s}`"))
            .collect::<Vec<_>>()
            .join(", ");
        out.push_str(&format!("- **messages**: {msg}\n"));
    }
    out.push_str("- source of truth: `proto/holger.proto`\n\n");
}

/// Drop no-signal accessors: undocumented getters/setters/`is_*`/`as_*`/`len`/
/// `is_empty`/`default`/`fmt`/`clone`. Anything documented is kept.
fn is_trivial_item(it: &apidoc::ApiItem) -> bool {
    if !it.doc.is_empty() || it.kind != "fn" {
        return false;
    }
    let n = it.name.as_str();
    n == "len"
        || n == "is_empty"
        || n == "default"
        || n == "fmt"
        || n == "clone"
        || n.starts_with("get_")
        || n.starts_with("set_")
        || (n.starts_with("is_") && it.signature.contains("-> bool"))
        || (n.starts_with("as_") && it.signature.contains('&'))
}

fn repo_rel_file(crate_rel: &str, file: &str) -> String {
    if crate_rel.is_empty() || crate_rel == "." || file.starts_with(crate_rel) {
        file.to_string()
    } else {
        format!("{crate_rel}/{file}")
    }
}

fn emit_prose_current(
    out: &mut String,
    repo_root: &Path,
    ctx: &Ctx,
    maintained: Option<&Maintained>,
) {
    let Ok(chapters) = super::book::collect_chapters(repo_root, ctx) else {
        return;
    };
    let allow = |src: &Path| -> bool {
        let rel = src
            .strip_prefix(repo_root)
            .unwrap_or(src)
            .to_string_lossy()
            .to_string();
        match maintained {
            Some(m) => m
                .documents
                .iter()
                .find(|d| rel.ends_with(&d.path) || d.path.ends_with(&rel))
                .map(|d| d.include_full)
                .unwrap_or_else(|| {
                    src.file_name()
                        .is_some_and(|f| f.to_string_lossy().eq_ignore_ascii_case("readme.md"))
                }),
            None => src
                .file_name()
                .is_some_and(|f| f.to_string_lossy().eq_ignore_ascii_case("readme.md")),
        }
    };
    let kept: Vec<_> = chapters.iter().filter(|c| allow(&c.source)).collect();
    if kept.is_empty() {
        return;
    }
    out.push_str("## Project documentation (current status only)\n\n");
    out.push_str("Only prose classified `current implementation`/`current architecture` is reproduced. Historical/superseded/marketing/human-guide prose is excluded and, per the source-of-truth hierarchy, never overrides code.\n\n");
    for ch in kept {
        out.push_str(&demote_headings(&ch.markdown, 2));
        out.push_str("\n\n");
    }
}

// ─── Small helpers ───────────────────────────────────────────────────────────

fn first_line(s: &str) -> Option<String> {
    s.lines()
        .map(str::trim)
        .find(|l| !l.is_empty())
        .map(str::to_string)
}

fn strip_lead_name(name: &str, para: &str) -> String {
    let p = para.trim().trim_start_matches('`').trim_start();
    if let Some(rest) = p.strip_prefix(name) {
        let rest = rest.trim_start_matches('`').trim_start();
        for sep in [" — ", " – ", " - ", ": "] {
            if let Some(r) = rest.strip_prefix(sep) {
                return r.trim().to_string();
            }
        }
    }
    para.trim().to_string()
}

fn first_para(s: &str) -> String {
    let mut buf = String::new();
    for line in s.lines() {
        if line.trim().is_empty() {
            break;
        }
        if !buf.is_empty() {
            buf.push(' ');
        }
        buf.push_str(line.trim());
    }
    buf
}

fn md_cell(s: &str) -> String {
    s.replace('|', "\\|").replace('\n', " ")
}

fn md_code(s: &str) -> String {
    let s = s.trim();
    if s.is_empty() {
        "—".into()
    } else if s.contains(' ') {
        md_cell(s)
    } else {
        format!("`{}`", md_cell(s))
    }
}

fn md_code_list(items: &[String]) -> String {
    if items.is_empty() {
        return "—".into();
    }
    items
        .iter()
        .map(|i| md_code(i))
        .collect::<Vec<_>>()
        .join(", ")
}

/// Render a capability-matrix cell value, keeping the controlled vocabulary.
fn cap(s: &str) -> String {
    let s = s.trim();
    if s.is_empty() {
        "Unverified".into()
    } else {
        md_cell(s)
    }
}

fn yn(b: bool) -> &'static str {
    if b { "yes" } else { "no" }
}

/// Render a status as `` `label` annotation`` (the label controlled, the rest
/// free text). Empty → em-dash; unrecognized (shouldn't reach here — validated
/// upstream) → escaped as-is.
fn md_status(s: &str) -> String {
    match leading_status(s) {
        Some((label, rest)) if rest.is_empty() => format!("`{label}`"),
        Some((label, rest)) => format!("`{label}` — {}", md_cell(&rest)),
        None if s.trim().is_empty() => "—".into(),
        None => md_cell(s),
    }
}

/// GitHub-style heading slug: lowercase, non-alnum → '-', collapse, trim.
fn slugify(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut prev_dash = false;
    for ch in s.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    out.trim_matches('-').to_string()
}

fn blockquote(s: &str) -> String {
    s.lines()
        .map(|l| {
            if l.is_empty() {
                ">".to_string()
            } else {
                format!("> {l}")
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn demote_headings(s: &str, by: usize) -> String {
    let mut out = String::with_capacity(s.len());
    let mut in_fence = false;
    for line in s.lines() {
        let t = line.trim_start();
        if t.starts_with("```") || t.starts_with("~~~") {
            in_fence = !in_fence;
            out.push_str(line);
            out.push('\n');
            continue;
        }
        if !in_fence && t.starts_with('#') {
            let hashes = t.chars().take_while(|&c| c == '#').count();
            if (1..=6).contains(&hashes) {
                let add = "#".repeat((hashes + by).min(6) - hashes);
                out.push_str(add.as_str());
                out.push_str(line);
                out.push('\n');
                continue;
            }
        }
        out.push_str(line);
        out.push('\n');
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn widget_repo(tmp: &std::path::Path) -> PathBuf {
        let dir = tmp.join("widget");
        std::fs::create_dir_all(dir.join("src")).unwrap();
        std::fs::write(
            dir.join("Cargo.toml"),
            "[package]\nname = \"widget\"\ndescription = \"a widget\"\n\n\
             [features]\ndefault = []\nextra = [\"dep:serde\"]\n\n\
             [dependencies]\nserde = { version = \"1\", optional = true }\ngadget = { path = \"../gadget\" }\n",
        )
        .unwrap();
        std::fs::write(
            dir.join("src/lib.rs"),
            "//! The widget crate.\n\
             /// Build a widget.\n\
             pub fn make(size: u32) -> u32 {\n\
                 // TODO: sizes\n\
                 let _ = std::env::var(\"WIDGET_HOME\");\n\
                 size\n\
             }\n\
             pub fn get_size() -> u32 { 0 }\n\
             #[cfg(test)]\nmod tests {\n    #[test]\n    fn t() {}\n}\n",
        )
        .unwrap();
        let g = tmp.join("gadget");
        std::fs::create_dir_all(g.join("src")).unwrap();
        std::fs::write(g.join("Cargo.toml"), "[package]\nname = \"gadget\"\n").unwrap();
        std::fs::write(g.join("src/lib.rs"), "//! gadget\n").unwrap();
        tmp.to_path_buf()
    }

    fn opts() -> AiContextOpts {
        AiContextOpts {
            include_prose: false,
            ..Default::default()
        }
    }

    #[test]
    fn builds_compact_and_full_with_expected_sections() {
        let tmp = tempfile::tempdir().unwrap();
        let root = widget_repo(tmp.path());
        let dir = root.join("widget");
        let ctx = Ctx::new(&dir, &dir, None);
        let o = build(&dir, &ctx, &opts()).unwrap();
        let md = &o.compact;
        for want in [
            "AI context pack (compact)",
            "compact primary context",
            "## Snapshot",
            "## Repository identity",
            "## AI usage instructions",
            "## Crates and layers",
            "## Feature flags",
            "## Status legend",
            "## Environment variables",
            "## Incomplete-work markers",
            "## Tests inventory",
            "## AI reasoning warnings",
            "## Prose documents",
            "## API summary",
            "## Compact index",
            "Human-facing documentation excluded from compact AI context",
        ] {
            assert!(md.contains(want), "compact missing {want:?}");
        }
        assert!(!md.contains("#### Module"), "no api dump in compact");
        let full = &o.full;
        assert!(full.contains("full API reference"));
        assert!(full.contains("exact lookup"));
        assert!(full.contains("Crate `widget`"));
        assert!(full.contains("fn make(size: u32) -> u32"));
        assert!(
            !full.contains("get_size"),
            "trivial getter should be filtered: {full}"
        );
        assert_eq!(o.stats.crates, 1);
        assert!(o.stats.compact_tokens > 0 && o.stats.full_tokens > 0);
    }

    #[test]
    fn deterministic_across_runs() {
        let tmp = tempfile::tempdir().unwrap();
        let root = widget_repo(tmp.path());
        let dir = root.join("widget");
        let ctx = Ctx::new(&dir, &dir, None);
        let a = build(&dir, &ctx, &opts()).unwrap();
        let b = build(&dir, &ctx, &opts()).unwrap();
        assert_eq!(a.compact, b.compact);
        assert_eq!(a.full, b.full);
    }

    #[test]
    fn maintained_metadata_emitted_and_validated() {
        let tmp = tempfile::tempdir().unwrap();
        let root = widget_repo(tmp.path());
        let dir = root.join("widget");
        std::fs::create_dir_all(dir.join(".nornir")).unwrap();
        std::fs::write(
            dir.join(MAINTAINED_METADATA_PATH),
            r#"
[repository]
purpose = "test widgetry"

[[capabilities]]
ecosystem = "Widget"
protocol_fetch = "Yes"
writable_backend = "No"

[[failures]]
operation = "make"
fatal = "no"

[[cli]]
binary = "widget"
command = "make"

[[change_impact]]
interface = "Widgetize"
defined_in = "src/lib.rs:3"

[[invariants]]
id = "w1"
statement = "widgets never shrink"
enforced = ["src/lib.rs:3"]
status = "Verified implementation"

[[tasks]]
name = "resize"
start = ["src/lib.rs"]
fast_tests = ["cargo test -p widget"]
"#,
        )
        .unwrap();
        let ctx = Ctx::new(&dir, &dir, None);
        let md = build(&dir, &ctx, &opts()).unwrap().compact;
        for want in [
            "test widgetry",
            "## Package ecosystem capability matrix",
            "## Failure semantics",
            "## CLI command inventory",
            "## Change impact map",
            "## Non-negotiable invariants",
            "`w1`",
            "## Task-to-file map",
        ] {
            assert!(md.contains(want), "missing {want:?}");
        }
    }

    #[test]
    fn dangling_reference_fails() {
        let tmp = tempfile::tempdir().unwrap();
        let root = widget_repo(tmp.path());
        let dir = root.join("widget");
        std::fs::create_dir_all(dir.join(".nornir")).unwrap();
        std::fs::write(
            dir.join(MAINTAINED_METADATA_PATH),
            "[[invariants]]\nid = \"bad\"\nstatement = \"x\"\nenforced = [\"src/nope.rs\"]\n",
        )
        .unwrap();
        let ctx = Ctx::new(&dir, &dir, None);
        let err = build(&dir, &ctx, &opts()).unwrap_err().to_string();
        assert!(
            err.contains("dangling") && err.contains("src/nope.rs"),
            "{err}"
        );
    }

    #[test]
    fn unknown_status_fails() {
        let tmp = tempfile::tempdir().unwrap();
        let root = widget_repo(tmp.path());
        let dir = root.join("widget");
        std::fs::create_dir_all(dir.join(".nornir")).unwrap();
        std::fs::write(
            dir.join(MAINTAINED_METADATA_PATH),
            "[[wiring]]\ncapability = \"x\"\nstatus = \"totally made up\"\n",
        )
        .unwrap();
        let ctx = Ctx::new(&dir, &dir, None);
        let err = build(&dir, &ctx, &opts()).unwrap_err().to_string();
        assert!(err.contains("unknown status"), "{err}");
    }

    #[test]
    fn status_contradiction_fails() {
        let tmp = tempfile::tempdir().unwrap();
        let root = widget_repo(tmp.path());
        let dir = root.join("widget");
        std::fs::create_dir_all(dir.join(".nornir")).unwrap();
        std::fs::write(
            dir.join(MAINTAINED_METADATA_PATH),
            "[[wiring]]\ncapability = \"Promote\"\nstatus = \"Runtime wired\"\n\n\
             [[invariants]]\nid = \"Promote\"\nstatement = \"x\"\nstatus = \"Not implemented\"\n",
        )
        .unwrap();
        let ctx = Ctx::new(&dir, &dir, None);
        let err = build(&dir, &ctx, &opts()).unwrap_err().to_string();
        assert!(err.contains("contradiction"), "{err}");
    }

    #[test]
    fn bad_test_command_crate_fails() {
        let tmp = tempfile::tempdir().unwrap();
        let root = widget_repo(tmp.path());
        let dir = root.join("widget");
        std::fs::create_dir_all(dir.join(".nornir")).unwrap();
        std::fs::write(
            dir.join(MAINTAINED_METADATA_PATH),
            "[[tasks]]\nname = \"t\"\nfast_tests = [\"cargo test -p not-a-crate\"]\n",
        )
        .unwrap();
        let ctx = Ctx::new(&dir, &dir, None);
        let err = build(&dir, &ctx, &opts()).unwrap_err().to_string();
        assert!(
            err.contains("unknown crate") && err.contains("not-a-crate"),
            "{err}"
        );
    }

    #[test]
    fn mode_semantics() {
        assert!(GenMode::Release.reproducible_required());
        assert!(GenMode::Ci.reproducible_required());
        assert!(!GenMode::Development.reproducible_required());
        assert_eq!(GenMode::parse("release"), GenMode::Release);
        assert_eq!(GenMode::parse("whatever"), GenMode::Development);
    }

    #[test]
    fn workspace_edges_deduped_and_internal_only() {
        let tmp = tempfile::tempdir().unwrap();
        let root = widget_repo(tmp.path());
        let manifests = scan_manifests(&root);
        let w = &manifests["widget"];
        let gadget_edges: Vec<_> = w.deps.iter().filter(|d| d.to == "gadget").collect();
        assert_eq!(gadget_edges.len(), 1, "one deduped internal edge to gadget");
        assert!(
            !w.deps.iter().any(|d| d.to == "serde"),
            "external dep is not a workspace edge"
        );
    }

    #[test]
    fn slugify_matches_github() {
        assert_eq!(slugify("holger-server-lib"), "holger-server-lib");
        assert_eq!(slugify("Crate foo"), "crate-foo");
    }

    #[test]
    fn helpers_behave() {
        assert_eq!(
            path_of_reference("src/lib.rs:42"),
            Some("src/lib.rs".into())
        );
        assert_eq!(path_of_reference("cargo test -p x"), None);
        assert_eq!(
            cargo_test_crate("cargo test -p holger-ui --features gui"),
            Some("holger-ui".into())
        );
        assert_eq!(normalize_status("runtime wired"), Some("Runtime wired"));
        assert_eq!(status_polarity("Not implemented"), Some("absent"));
        assert_eq!(status_polarity("Runtime wired"), Some("live"));
    }

    #[test]
    fn demote_headings_skips_code_fences() {
        let src = "# Title\n\n```\n# not a heading\n```\n## Sub\n";
        let got = demote_headings(src, 2);
        assert!(got.contains("### Title") && got.contains("#### Sub"));
        assert!(got.contains("\n# not a heading\n"));
    }
}