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

nordisk/nornir

crates/nornir-introspect/src/lib.rs

//! Code introspection — derive structural docs from the *built* code.
//!
//! All extraction targets the compiled / parsed artifact, never
//! hand-maintained sources. The published documentation is then a
//! pure function of the code:
//!
//! ```text
//!     source code  ──cargo──►  rustdoc JSON ┐
//!     workspace    ──cargo──►  metadata     ├─► nornir::introspect ─► graphs
//!     binary       ──DWARF─►  symbols       ┘                       │
//!                                                                   ▼
//!                                                             nornir::docs
//!                                                                   │
//!                                                                   ▼
//!                                                    README / CLAUDE / docs/*
//! ```

pub mod artifact;
pub mod binsbom;
pub mod binscan;
pub mod depgraph;
pub mod diff;
pub mod gate;
pub mod hardening;
pub mod pathdeps;
pub mod selftest;
pub mod size;

/// **Where the pre-built `nornir` binary really is** — the one writer for every
/// DWARF test in this crate that needs a real binary to read.
///
/// It exists because four tests got this wrong in the same way and every one of
/// them reported `ok` for it. They looked in
/// `$CARGO_MANIFEST_DIR/target/debug/nornir`, i.e.
/// `crates/nornir-introspect/target/debug/nornir` — but this crate is a WORKSPACE
/// MEMBER, so cargo puts every artifact in the workspace target dir and the
/// per-crate `target/` is never created at all. The precondition was
/// **unsatisfiable by construction**, not merely unsatisfied on CI, and each test
/// answered a missing binary with a bare `return`, which cargo counts as a PASS.
/// Four dead tests printing `ok` for as long as this crate has been a member.
///
/// So: one resolver, and it looks in the right place. See
/// [`skip_reason_if_absent`] for the other half — a skip that is at least
/// impossible to mistake for coverage.
#[cfg(test)]
pub(crate) fn workspace_root() -> std::path::PathBuf {
    // crates/nornir-introspect -> crates -> <workspace root>
    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|p| p.parent())
        .expect("nornir-introspect must sit two levels under the workspace root")
        .to_path_buf()
}

/// **The CONSTELLATION root** — the parent of the nornir checkout, i.e. the
/// directory whose children ARE the repos/crates.
///
/// This is NOT [`workspace_root`], and the difference is load-bearing rather than
/// cosmetic. [`Symbol::krate`](crate::artifact::Symbol::krate) is defined as the
/// FIRST path component of a source file relative to the SCAN ROOT, so a scan root
/// only yields correct crate attribution when its children are the crate
/// directories. Production always passes such a root — `loaded.workspace_root`,
/// read from `nornir.toml`, is the constellation root — while
/// [`workspace_root`] here is the nornir REPO root, two levels up from this crate's
/// manifest. Under the repo root, no path can ever begin with `nornir/`, so a test
/// asserting `krate == "nornir"` against it is unsatisfiable by construction. One
/// did, silently, for as long as it was also being skipped for a second
/// unsatisfiable reason (see [`prebuilt_nornir_bin`]); fixing the skip is what
/// finally made it fail.
///
/// Two roots, two meanings, one name — so they now have two names.
#[cfg(test)]
pub(crate) fn constellation_root() -> std::path::PathBuf {
    workspace_root()
        .parent()
        .expect("the nornir checkout must have a parent directory")
        .to_path_buf()
}

/// The workspace `nornir` debug binary, or `None` with a NOISY explanation.
///
/// A missing binary here is a real coverage hole, so the message says
/// `NOT COVERED` and names the command that closes it. This is deliberately not a
/// hard `panic!`: `cargo test -p nornir-introspect` does not build the root bin,
/// and reddening that legitimate invocation would only teach people to ignore it.
/// The honest middle is a skip nobody can mistake for a pass — and, more
/// importantly, the `*_selfexe` siblings that gate the same properties against the
/// test executable itself and therefore can never skip at all.
#[cfg(test)]
pub(crate) fn prebuilt_nornir_bin(what: &str) -> Option<std::path::PathBuf> {
    // `CARGO_TARGET_DIR` FIRST, because this constellation's own build law puts
    // every scratch target dir on a separate disk
    // (`~/scratch/cargo/<name>`). A resolver that only knew about
    // `<workspace>/target` would have been correct-looking and still dark for
    // every build the fleet actually runs.
    let candidates = [
        std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from),
        Some(workspace_root().join("target")),
    ];
    for target in candidates.into_iter().flatten() {
        let bin = target.join("debug/nornir");
        if bin.is_file() {
            return Some(bin);
        }
    }
    eprintln!(
        "[nornir-introspect] NOT COVERED: {what} needs a real `nornir` binary and none was \
         found under $CARGO_TARGET_DIR or {}. Build it with \
         `cargo build -p nornir --bin nornir` and re-run. \
         (This test asserted NOTHING on this run.)",
        workspace_root().join("target").display()
    );
    None
}

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
    pub from: String,
    pub to: String,
    pub kind: EdgeKind,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum EdgeKind {
    Calls,
    DependsOn,
    Reexports,
    Implements,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Graph {
    pub nodes: Vec<String>,
    pub edges: Vec<Edge>,
}

impl Graph {
    /// Render the graph as a **self-contained, static SVG** — a layered
    /// left-to-right DAG. No JavaScript, no diagram engine, no build step:
    /// the markup is emitted directly in Rust so it is available in every
    /// feature set (no `docs-export`/typst dependency) and renders verbatim
    /// in any markdown/web viewer (NUKE-MERMAID house style).
    pub fn to_svg(&self) -> String {
        render_svg(&self.drawable_nodes(), &self.edges)
    }

    /// **Every node that must be DRAWN** — [`Graph::nodes`] in declaration order,
    /// followed by any edge endpoint that is not already among them.
    ///
    /// ⚠ WHY THIS EXISTS (SK296, measured 2026-08-01). `depgraph::extract` runs
    /// `cargo metadata --no-deps`, so `nodes` holds only the WORKSPACE packages
    /// while `edges` names every declared dependency — including external crates
    /// that are therefore edge endpoints and not nodes. Both SVG renderers
    /// (`render_svg` here and nornir's typst `docs::svg::render_graph_svg`) map
    /// endpoints through a name→index table built from `nodes` alone and `continue`
    /// on a miss, so a two-crate graph `widget_lib -.-> serde` rendered as ONE box
    /// and ZERO arrows: a "workspace dependency graph" that drew no dependency.
    /// `to_text()` printed the same edge fine, so the two renderers of one graph
    /// disagreed, and the doc-render test that would have caught it
    /// (`docs::layout::tests::render_sources_in_place_fills_depgraph_marker`) only
    /// asserted the crate names in its `#[cfg(not(feature = "docs-export"))]` arm —
    /// compiled OUT of every default build.
    ///
    /// Deriving the drawn set here, once, is the by-construction fix: an edge can
    /// no longer point at something the picture omits, and both renderers get the
    /// same node set because they both call this.
    pub fn drawable_nodes(&self) -> Vec<String> {
        let mut seen: std::collections::HashSet<&str> =
            self.nodes.iter().map(String::as_str).collect();
        let mut out = self.nodes.clone();
        // Deterministic: endpoints are appended in sorted order, so the same graph
        // always renders the same bytes (the docs render must be idempotent).
        let mut extra: Vec<&str> = Vec::new();
        for e in &self.edges {
            for end in [e.from.as_str(), e.to.as_str()] {
                if seen.insert(end) {
                    extra.push(end);
                }
            }
        }
        extra.sort_unstable();
        out.extend(extra.into_iter().map(str::to_string));
        out
    }

    /// Render the graph as a plain-text adjacency list — the no-pixels
    /// fallback used where an inline SVG is undesirable (e.g. terminal output
    /// or `.nornir/` source docs). Nodes first, then `from → to` edges with the
    /// edge kind noted inline.
    pub fn to_text(&self) -> String {
        let mut s = String::new();
        s.push_str("nodes:\n");
        for n in &self.nodes {
            s.push_str("  ");
            s.push_str(n);
            s.push('\n');
        }
        if self.edges.is_empty() {
            s.push_str("edges: (none)\n");
        } else {
            s.push_str("edges:\n");
            for e in &self.edges {
                let kind = match e.kind {
                    EdgeKind::Calls => "calls",
                    EdgeKind::DependsOn => "depends-on",
                    EdgeKind::Reexports => "reexports",
                    EdgeKind::Implements => "implements",
                };
                s.push_str(&format!("  {} → {} ({kind})\n", e.from, e.to));
            }
        }
        s
    }
}

/// XML-escape a label for safe inclusion in SVG text.
fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

/// Compute layered columns (BFS levels) for a layered-DAG layout. Layer 0 holds
/// the sources (no incoming edge); cycles are broken deterministically so the
/// output is idempotent.
///
/// `pub` because nornir's typst renderer (`docs::svg::render_graph_svg`) carried a
/// BYTE-IDENTICAL private copy of this function (LAW #5): two layout engines for
/// one graph model, free to drift, and both carrying the same dropped-endpoint bug
/// [`Graph::drawable_nodes`] documents. One layout, one place.
pub fn svg_layers(nodes: &[String], edges: &[Edge]) -> Vec<Vec<usize>> {
    use std::collections::{HashMap, HashSet};
    let n = nodes.len();
    let idx: HashMap<&str, usize> = nodes
        .iter()
        .enumerate()
        .map(|(i, s)| (s.as_str(), i))
        .collect();
    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
    let mut indeg: Vec<usize> = vec![0; n];
    for e in edges {
        if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
            if f != t {
                adj[f].push(t);
                indeg[t] += 1;
            }
        }
    }
    let mut layer_of = vec![0usize; n];
    let mut remaining: HashSet<usize> = (0..n).collect();
    let mut level = 0usize;
    while !remaining.is_empty() {
        let ready: Vec<usize> = remaining
            .iter()
            .copied()
            .filter(|&i| indeg[i] == 0)
            .collect();
        if ready.is_empty() {
            for &i in &remaining {
                layer_of[i] = level;
            }
            break;
        }
        for &i in &ready {
            layer_of[i] = level;
            remaining.remove(&i);
        }
        for &i in &ready {
            for &j in &adj[i] {
                if indeg[j] > 0 {
                    indeg[j] -= 1;
                }
            }
        }
        level += 1;
    }
    let max_level = *layer_of.iter().max().unwrap_or(&0);
    let mut layers: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
    let mut order: Vec<usize> = (0..n).collect();
    order.sort_by(|&a, &b| nodes[a].cmp(&nodes[b]));
    for i in order {
        layers[layer_of[i]].push(i);
    }
    layers
}

/// Emit a self-contained SVG for a layered DAG. Pure string building — no
/// external renderer.
pub(crate) fn render_svg(nodes: &[String], edges: &[Edge]) -> String {
    use std::collections::HashMap;
    if nodes.is_empty() {
        return String::from(
            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"160\" height=\"40\">\
             <text x=\"8\" y=\"24\" font-family=\"sans-serif\" font-size=\"12\">(empty graph)</text></svg>\n",
        );
    }
    let layers = svg_layers(nodes, edges);
    let col_w = 180.0f64;
    let row_h = 40.0f64;
    let box_w = 150.0f64;
    let box_h = 24.0f64;
    let margin = 14.0f64;

    let cols = layers.len().max(1);
    let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
    let width = margin * 2.0 + col_w * cols as f64;
    let height = margin * 2.0 + row_h * rows as f64;

    let mut pos: HashMap<usize, (f64, f64)> = HashMap::new();
    for (ci, layer) in layers.iter().enumerate() {
        let total_h = layer.len() as f64 * row_h;
        let start_y = margin + (height - margin * 2.0 - total_h) / 2.0;
        for (ri, &node_i) in layer.iter().enumerate() {
            let x = margin + ci as f64 * col_w;
            let y = start_y + ri as f64 * row_h;
            pos.insert(node_i, (x, y));
        }
    }
    let name_to_idx: HashMap<&str, usize> = nodes
        .iter()
        .enumerate()
        .map(|(i, s)| (s.as_str(), i))
        .collect();

    let mut s = String::new();
    s.push_str(&format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" \
         viewBox=\"0 0 {width:.0} {height:.0}\" font-family=\"sans-serif\" font-size=\"11\">\n"
    ));
    s.push_str(
        "<defs><marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"3\" \
         orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#5a6876\"/></marker></defs>\n",
    );

    for e in edges {
        let (Some(&fi), Some(&ti)) = (
            name_to_idx.get(e.from.as_str()),
            name_to_idx.get(e.to.as_str()),
        ) else {
            continue;
        };
        let (Some(&(fx, fy)), Some(&(tx, ty))) = (pos.get(&fi), pos.get(&ti)) else {
            continue;
        };
        let x1 = fx + box_w;
        let y1 = fy + box_h / 2.0;
        let x2 = tx;
        let y2 = ty + box_h / 2.0;
        let dash = match e.kind {
            EdgeKind::DependsOn => " stroke-dasharray=\"4 3\"",
            _ => "",
        };
        let color = match e.kind {
            EdgeKind::Calls => "#2850a0",
            EdgeKind::DependsOn => "#787882",
            EdgeKind::Reexports => "#28823c",
            EdgeKind::Implements => "#a05028",
        };
        s.push_str(&format!(
            "<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
             stroke=\"{color}\" stroke-width=\"1\"{dash} marker-end=\"url(#arrow)\"/>\n"
        ));
    }
    for (i, name) in nodes.iter().enumerate() {
        let Some(&(x, y)) = pos.get(&i) else { continue };
        let label = xml_escape(name);
        s.push_str(&format!(
            "<rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"{box_w:.0}\" height=\"{box_h:.0}\" rx=\"3\" \
             fill=\"#f5f7fc\" stroke=\"#3c3c50\" stroke-width=\"0.7\"/>\n"
        ));
        s.push_str(&format!(
            "<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\">{label}</text>\n",
            x + box_w / 2.0,
            y + box_h / 2.0 + 4.0,
        ));
    }
    s.push_str("</svg>\n");
    s
}

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

    fn g() -> Graph {
        Graph {
            nodes: vec!["a-1".into(), "b".into(), "c".into()],
            edges: vec![
                Edge {
                    from: "a-1".into(),
                    to: "b".into(),
                    kind: EdgeKind::DependsOn,
                },
                Edge {
                    from: "b".into(),
                    to: "c".into(),
                    kind: EdgeKind::Calls,
                },
            ],
        }
    }

    #[test]
    fn to_svg_is_self_contained_and_has_nodes() {
        let svg = g().to_svg();
        assert!(svg.starts_with("<svg"), "{svg}");
        assert!(svg.contains("</svg>"));
        // No mermaid anywhere; labels are the raw node names (xml-escaped).
        assert!(!svg.contains("mermaid"), "{svg}");
        assert!(svg.contains(">a-1<"), "node label a-1: {svg}");
        assert!(svg.contains(">c<"), "node label c: {svg}");
        // Edges become <line> elements.
        assert!(svg.contains("<line "), "edges: {svg}");
    }

    #[test]
    fn to_svg_empty_graph_placeholder() {
        let svg = Graph::default().to_svg();
        assert!(svg.starts_with("<svg"), "{svg}");
        assert!(svg.contains("empty graph"), "{svg}");
    }

    /// ★ **SK296 — AN EXTERNAL DEPENDENCY MUST BE DRAWN, NOT DROPPED.**
    ///
    /// This is the EXACT shape `depgraph::extract` produces: it runs `cargo
    /// metadata --no-deps`, so `nodes` holds the workspace package(s) only while
    /// `edges` names every declared dependency. Before
    /// [`Graph::drawable_nodes`] the renderer indexed endpoints through `nodes`
    /// alone and `continue`d on a miss, so this graph rendered as ONE box and ZERO
    /// arrows and the `depgraph` doc section published a dependency graph with no
    /// dependencies in it. Measured red, on `origin/main`, 2026-08-01:
    ///
    /// ```text
    /// panicked at src/docs/layout.rs:769:13:
    /// inline depgraph must show the injected dependency; got: …
    /// <rect x="14" y="14" width="150" height="24" … />
    /// <text x="89" y="30" text-anchor="middle">widget_lib</text>
    /// </svg>
    /// ```
    ///
    /// It lives HERE, in a `#[cfg(test)]` module with no feature gate, because the
    /// test that first caught it is inside `#[cfg(not(feature = "docs-export"))]`
    /// and `docs-export` is in nornir's `default` — so a plain `cargo test`
    /// compiled the only assertion about it clean out of the binary. This one runs
    /// in EVERY `cargo test`, default features included.
    ///
    /// It asserts APPLIED OUTPUT — the emitted markup — in three independent ways
    /// (the external box, its label, and the arrow that connects them), and pins
    /// `to_text()` beside it: `to_text` always printed this edge, so the two
    /// renderers of one graph disagreeing is itself the finding.
    #[test]
    fn to_svg_draws_an_edge_endpoint_that_is_not_a_declared_node() {
        let g = Graph {
            // `cargo metadata --no-deps` over `widget_lib` + `serde = "1"`.
            nodes: vec!["widget_lib".into()],
            edges: vec![Edge {
                from: "widget_lib".into(),
                to: "serde".into(),
                kind: EdgeKind::DependsOn,
            }],
        };

        // The text renderer always showed the dependency — the SVG must agree.
        assert!(
            g.to_text().contains("widget_lib → serde (depends-on)"),
            "{}",
            g.to_text()
        );

        let svg = g.to_svg();
        assert!(
            svg.contains(">widget_lib<"),
            "the workspace crate must be labelled: {svg}"
        );
        assert!(
            svg.contains(">serde<"),
            "the external dependency `serde` is an EDGE ENDPOINT and not a declared \
             node — it must still get a labelled box, or the depgraph publishes a \
             dependency graph with no dependencies: {svg}"
        );
        assert_eq!(
            svg.matches("marker-end=\"url(#arrow)\"").count(),
            1,
            "exactly one dependency arrow must be drawn; a box without the arrow is \
             the same information loss one level down: {svg}"
        );
        // Two boxes, not one. `>serde<` alone could in principle be matched by a
        // stray label, so pin the geometry that carries it.
        assert_eq!(
            svg.matches("<rect ").count(),
            2,
            "two nodes ⇒ two boxes: {svg}"
        );

        // …and the drawn set is exactly `nodes ∪ endpoints`, in that order.
        assert_eq!(
            g.drawable_nodes(),
            vec!["widget_lib".to_string(), "serde".to_string()]
        );
    }

    /// The companion that stops [`Graph::drawable_nodes`] from becoming "add
    /// everything twice": a graph whose edges only ever name declared nodes must
    /// render exactly the declared node set, unchanged and un-reordered.
    #[test]
    fn drawable_nodes_adds_nothing_when_every_endpoint_is_already_a_node() {
        let g = g();
        assert_eq!(g.drawable_nodes(), g.nodes);
        assert_eq!(g.to_svg().matches("<rect ").count(), 3);
    }

    #[test]
    fn to_text_lists_nodes_and_edges() {
        let t = g().to_text();
        assert!(t.contains("a-1\n"), "{t}");
        assert!(t.contains("a-1 → b (depends-on)"), "{t}");
        assert!(t.contains("b → c (calls)"), "{t}");
        assert!(!t.contains("mermaid"));
    }
}