tests/no_dwarves_edge.rs
//! **THE GUARD**: nornir must never regain a crate dependency on `dwarves`.
//!
//! ## The law
//!
//! `workspace_nordisk/prompt` line 66: *a member embeds only what is DOWNSTREAM of
//! it — dwarves embeds nornir+holger+skade; **nornir embeds holger+skade but
//! reaches dwarves ONLY via gRPC (cycle)***. `.nornir/code-placement.md:18` says the
//! same for the forge view specifically: *"nornir embeds the view via gRPC"*.
//!
//! The parenthetical `(cycle)` is the whole reason. dwarves sits DOWNSTREAM of
//! nornir, so dwarves is the member allowed to compile nornir IN for its fat build.
//! The moment nornir names `dwarves` as a dep, `nornir → dwarves → nornir` is a
//! cargo dependency cycle — which cargo rejects for normal deps *regardless of how
//! the features are arranged* — and the fat forge becomes unbuildable.
//!
//! ## What it cost last time
//!
//! nornir carried `dwarves = { path = "../dwarves/crates/dwarves", features =
//! ["release"] }` with **102 `dwarves::` call sites**. Of those:
//!
//! * **67 (66%) were `release_doctor`** — 26,746 lines containing zero
//! `tonic`/`tokio`/`gix`/`redb`/`skidbladnir` imports. Pure semver and graph
//! arithmetic. To reach that arithmetic nornir dependency-linked **506 crates**:
//! the gRPC server, prost, the async runtime, redb, the SSH git transport — all
//! of it, for semver math. That model is now the `nornir-release-core` edda leaf
//! both repos consume.
//! * **17 were `brokkr`**, run in-process, where the placement doc says gRPC.
//! * **`dwarves::server::serve` ×3** — nornir was booting the forge's gRPC server
//! inside its own process.
//!
//! ## Why a test and not a comment
//!
//! The old `Cargo.toml` already carried a "nornir must NEVER gain a crate dep on
//! dwarves" note directly above the dep line that violated it. A comment cannot
//! fail a build. This can.
use std::path::{Path, PathBuf};
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
/// RED when any `[dependencies]` / `[dev-dependencies]` / `[build-dependencies]` /
/// `[target.*.dependencies]` table in nornir's manifest declares `dwarves` (or any
/// crate whose own graph reaches it — `dwarves-embed`, `dwarves-web-client`).
///
/// Deliberately a TEXT scan of the manifest rather than a `cargo metadata` walk: it
/// is fast, needs no network/lock, and catches the dep the instant it is typed —
/// before anyone has to discover it as a cycle error at the far end of the graph.
#[test]
fn nornir_declares_no_dependency_on_the_forge() {
let manifest =
std::fs::read_to_string(repo_root().join("Cargo.toml")).expect("read nornir/Cargo.toml");
// Crates whose presence would (re)create the `nornir → dwarves` edge. The
// `*-embed` clients count: `dwarves-embed` path-deps `dwarves` itself, so
// consuming it from here is the same cycle wearing a thin-client hat.
const FORBIDDEN: [&str; 3] = ["dwarves", "dwarves-embed", "dwarves-web-client"];
let mut in_dep_table = false;
let mut offenders: Vec<String> = Vec::new();
for (lineno, raw) in manifest.lines().enumerate() {
let line = raw.trim();
if line.starts_with('[') && !line.starts_with("[[") {
// Any table whose name ends in `dependencies` declares deps.
in_dep_table = line
.trim_end_matches(']')
.trim_end()
.ends_with("dependencies");
continue;
}
if !in_dep_table || line.starts_with('#') || line.is_empty() {
continue;
}
// `key = …` / `"key" = …` — take the key only, so a path or comment that
// merely MENTIONS dwarves (they are allowed to; the prose explains the law)
// never trips this.
let Some((key, _)) = line.split_once('=') else {
continue;
};
let key = key.trim().trim_matches('"');
if FORBIDDEN.contains(&key) {
offenders.push(format!("Cargo.toml:{}: `{key}`", lineno + 1));
}
}
assert!(
offenders.is_empty(),
"nornir has regained a crate dependency on the forge:\n {}\n\n\
The law (workspace_nordisk/prompt:66) is: nornir reaches dwarves ONLY via \
gRPC, never as a crate — because dwarves is DOWNSTREAM and must be able to \
compile nornir IN (`--features embed-nornir`). This dep makes \
`nornir -> dwarves -> nornir` a cargo cycle and breaks the fat forge build.\n\n\
If you need release model/analysis: it is in the `nornir-release-core` edda \
leaf, which both repos consume.\n\
If you need the forge to DO something (publish, build, run a pipeline): that \
is the forge's operation. Let dwarves reach in, or call dwarves-server over \
gRPC on :7900. Never link it.",
offenders.join("\n "),
);
}
/// RED when any nornir source file names `dwarves::` in real code.
///
/// Prose is allowed — the modules that lost a call site explain *why* they now go
/// over gRPC, and that history is worth keeping — so comment lines are skipped. What
/// is not allowed is a `use dwarves::…` or a `dwarves::foo()` that would resurrect
/// the crate edge.
#[test]
fn no_nornir_source_file_calls_into_the_forge() {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
let src = repo_root().join("src");
let mut files = Vec::new();
walk(&src, &mut files);
assert!(
!files.is_empty(),
"found no .rs files under {} — the walk is broken",
src.display()
);
let mut offenders: Vec<String> = Vec::new();
for f in &files {
let Ok(text) = std::fs::read_to_string(f) else {
continue;
};
for (lineno, raw) in text.lines().enumerate() {
let line = raw.trim_start();
// Skip prose: `//`, `///`, `//!` and block-comment continuations.
if line.starts_with("//") || line.starts_with('*') {
continue;
}
if line.contains("dwarves::") || line.contains("::dwarves::") {
offenders.push(format!(
"{}:{}: {}",
f.strip_prefix(repo_root()).unwrap_or(f).display(),
lineno + 1,
line.trim(),
));
}
}
}
assert!(
offenders.is_empty(),
"nornir source calls into the forge in real code (comments are fine, these are not):\n {}\n\n\
nornir reaches dwarves ONLY via gRPC (workspace_nordisk/prompt:66). The release \
read model lives in the `nornir-release-core` edda leaf; anything constructive \
is the forge's to perform.",
offenders.join("\n "),
);
}