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('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
/// 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"));
}
}