use std::collections::BTreeMap;
use std::path::Path;
use cordage::{
Arity, CyclePolicy, Direction, EdgeAttrs, Graph, GraphBuilder, OverlayConfig, OverlayId,
};
use crate::catalog::hydrate::{CatalogEdgeLabel, CatalogKey, EdgeTarget};
use crate::dep_seq;
use crate::entity;
use crate::integrity;
use crate::listing::{self, Format};
use crate::projection::Projection;
use crate::relation::{RELATION_RULES, RelationEdge, RelationLabel, Role, TargetSpec};
use crate::catalog::scan::ScanMode;
pub(crate) use crate::catalog::scan::{EntityKey, ScannedEntity, outbound_for, scan_entities};
pub(crate) fn dep_seq_for(
root: &Path,
kind: &entity::Kind,
id: u32,
) -> anyhow::Result<(dep_seq::DepSeq, bool)> {
match kind.prefix {
"SL" => {
let name = format!("{id:03}");
let path = root
.join(kind.dir)
.join(&name)
.join(format!("slice-{name}.toml"));
Ok((dep_seq::read(&path)?, false))
}
"REV" => {
let name = format!("{id:03}");
let path = root
.join(kind.dir)
.join(&name)
.join(format!("revision-{name}.toml"));
Ok((dep_seq::read(&path)?, false))
}
other => {
if let Some(item_kind) = crate::backlog::kind_from_prefix(other) {
let bl = crate::backlog::dep_seq_for(root, item_kind, id)?;
let after = bl
.after
.into_iter()
.map(|(to, rank)| dep_seq::AfterEdge { to, rank })
.collect();
Ok((
dep_seq::DepSeq {
needs: bl.needs,
after,
},
bl.promoted,
))
} else {
Ok((dep_seq::DepSeq::default(), false))
}
}
}
}
pub(crate) fn require_minted(
projection: &Projection<EntityKey>,
key: EntityKey,
) -> anyhow::Result<()> {
if projection.resolve(key).is_none() {
anyhow::bail!("{}: no such entity", key.canonical());
}
Ok(())
}
pub(crate) struct OverlayMap {
pub(crate) by_label: BTreeMap<RelationLabel, OverlayId>,
pub(crate) by_overlay: BTreeMap<OverlayId, RelationLabel>,
}
impl OverlayMap {
fn build(builder: &mut GraphBuilder) -> Self {
let mut by_label = BTreeMap::new();
let mut by_overlay = BTreeMap::new();
for rule in RELATION_RULES {
if matches!(rule.target, TargetSpec::Unvalidated) {
continue;
}
if by_label.contains_key(&rule.label) {
continue;
}
let ov = builder.overlay(OverlayConfig::new(CyclePolicy::Reject, Arity::Unbounded));
by_label.insert(rule.label, ov);
by_overlay.insert(ov, rule.label);
}
Self {
by_label,
by_overlay,
}
}
fn overlay_for(&self, label: RelationLabel) -> Option<OverlayId> {
self.by_label.get(&label).copied()
}
}
pub(crate) struct RelationGraph {
pub(crate) graph: Graph,
pub(crate) projection: Projection<EntityKey>,
pub(crate) overlays: OverlayMap,
pub(crate) danglers: BTreeMap<EntityKey, Vec<(RelationLabel, String)>>,
}
pub(crate) fn build_relation_graph_from(
scanned: &[ScannedEntity],
) -> anyhow::Result<RelationGraph> {
let mut builder = GraphBuilder::new();
let overlays = OverlayMap::build(&mut builder);
let mut projection: Projection<EntityKey> = Projection::new();
for entity in scanned {
projection.intern(&mut builder, entity.key);
}
let mut danglers: BTreeMap<EntityKey, Vec<(RelationLabel, String)>> = BTreeMap::new();
for entity in scanned {
let Some(src) = projection.resolve(entity.key) else {
debug_assert!(
false,
"build_relation_graph: pass-2 key not interned in pass 1"
);
continue;
};
for edge in &entity.outbound {
if let Some(dst) = resolve_target(&projection, edge)
&& let Some(ov) = overlays.overlay_for(edge.label)
{
builder.edge(ov, src, dst, EdgeAttrs::new(0, 0));
} else {
danglers
.entry(entity.key)
.or_default()
.push((edge.label, edge.target.clone()));
}
}
}
let graph = builder.build().map_err(|e| {
anyhow::anyhow!(
"relation_graph: cordage rejected well-formed adapter input (internal bug): {e:?}"
)
})?;
Ok(RelationGraph {
graph,
projection,
overlays,
danglers,
})
}
fn inbound_role_index(
scanned: &[ScannedEntity],
) -> BTreeMap<(EntityKey, RelationLabel, EntityKey), Option<Role>> {
let mut index = BTreeMap::new();
for entity in scanned {
for edge in &entity.outbound {
let Ok((kref, tid)) = integrity::parse_canonical_ref(&edge.target) else {
continue;
};
let target = EntityKey {
prefix: kref.kind.prefix,
id: tid,
};
index.insert((entity.key, edge.label, target), edge.role);
}
}
index
}
pub(crate) fn inbound_degree_index(
scanned: &[ScannedEntity],
) -> BTreeMap<(EntityKey, RelationLabel, EntityKey), Option<crate::relation::Degree>> {
let mut index = BTreeMap::new();
for entity in scanned {
for edge in &entity.outbound {
let Ok((kref, tid)) = integrity::parse_canonical_ref(&edge.target) else {
continue;
};
let target = EntityKey {
prefix: kref.kind.prefix,
id: tid,
};
index.insert((entity.key, edge.label, target), edge.degree);
}
}
index
}
fn resolve_target(
projection: &Projection<EntityKey>,
edge: &RelationEdge,
) -> Option<cordage::NodeId> {
let (kref, tid) = integrity::parse_canonical_ref(&edge.target).ok()?;
projection.resolve(EntityKey {
prefix: kref.kind.prefix,
id: tid,
})
}
pub(crate) fn validate_relations(root: &Path) -> anyhow::Result<Vec<String>> {
let mut findings = Vec::new();
let catalog = crate::catalog::hydrate::scan_catalog(root, ScanMode::default())?;
let entity_kinds: BTreeMap<EntityKey, &'static entity::Kind> = catalog
.entities
.iter()
.filter_map(|e| {
if let CatalogKey::Numbered(key) = &e.key {
e.kind.map(|k| (*key, k))
} else {
None
}
})
.collect();
for edge in &catalog.edges {
if let EdgeTarget::UnresolvedRef { raw } = &edge.target {
let CatalogKey::Numbered(source_key) = &edge.source else {
continue;
};
let CatalogEdgeLabel::Validated(label) = &edge.label else {
findings.push(format!(
"internal: numbered edge {} has Raw label {:?}",
source_key.canonical(),
edge.label.name()
));
continue;
};
let Some(kind) = entity_kinds.get(source_key) else {
findings.push(format!(
"internal: edge source {} not in entity-kind map",
source_key.canonical()
));
continue;
};
let validated = crate::relation::lookup(kind, *label, edge.role)
.is_some_and(|r| !matches!(r.target, TargetSpec::Unvalidated));
if validated {
findings.push(format!(
"{}: `{}` target `{}` does not resolve (dangling [[relation]] edge)",
edge.source.canonical(),
edge.label.name(),
raw
));
}
}
}
for kref in integrity::KINDS {
let mut ids = entity::scan_ids(&root.join(kref.kind.dir))?;
ids.sort_unstable();
for id in ids {
let toml_path = crate::entity::id_path(root, kref.kind, id, crate::entity::Ext::Toml);
let text = std::fs::read_to_string(&toml_path)
.map_err(|e| anyhow::anyhow!("read {} for validate: {e}", toml_path.display()))?;
let doc = crate::relation::RelationDoc::parse(&text)?;
let (_edges, illegal) = crate::relation::read_block(kref.kind, &doc);
for row in illegal {
let why = match row.reason {
crate::relation::IllegalReason::UnknownLabel => "unknown label",
crate::relation::IllegalReason::IllegalForSource => "label illegal for source",
crate::relation::IllegalReason::IllegalRole => "missing or illegal role",
crate::relation::IllegalReason::IllegalDegree => "illegal degree",
crate::relation::IllegalReason::DuplicateEdge => "duplicate edge",
};
findings.push(format!(
"{}: [[relation]] row `{}` -> `{}` is illegal ({why})",
listing::canonical_id(kref.kind.prefix, id),
row.label,
row.target
));
}
}
}
findings.extend(validate_supersession(root)?);
Ok(findings)
}
fn validate_supersession(root: &Path) -> anyhow::Result<Vec<String>> {
use std::collections::{BTreeMap, BTreeSet};
let gov_kinds: &[&crate::governance::GovKind] = &[
&crate::adr::ADR_KIND,
&crate::policy::POLICY_KIND,
&crate::standard::STANDARD_KIND,
];
let mut findings = Vec::new();
for g in gov_kinds {
let prefix = g.kind.prefix;
let mut ids = entity::scan_ids(&root.join(g.kind.dir))?;
ids.sort_unstable();
let mut stored: BTreeMap<u32, (Vec<String>, Vec<String>)> = BTreeMap::new();
for id in &ids {
stored.insert(*id, crate::governance::supersession_pair(g, root, *id)?);
}
let mut derived: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (y, (sup, _)) in &stored {
let y_ref = listing::canonical_id(prefix, *y);
for x_ref in sup {
derived
.entry(x_ref.clone())
.or_default()
.insert(y_ref.clone());
}
}
for (x, (_sup, stored_by)) in &stored {
let x_ref = listing::canonical_id(prefix, *x);
let stored_set: BTreeSet<String> = stored_by.iter().cloned().collect();
let derived_set = derived.get(&x_ref).cloned().unwrap_or_default();
for missing in derived_set.difference(&stored_set) {
findings.push(format!(
"{x_ref}: `{missing}` supersedes it (derived) but `{x_ref}` does not list \
it in `superseded_by` (supersession drift)"
));
}
for extra in stored_set.difference(&derived_set) {
findings.push(format!(
"{x_ref}: lists `{extra}` in `superseded_by` but `{extra}` does not \
`supersede` it (supersession drift)"
));
}
}
}
Ok(findings)
}
pub(crate) type RelationKey = (RelationLabel, Option<Role>);
type InboundSrc = (EntityKey, Option<crate::relation::Degree>);
#[derive(Debug, Clone)]
pub(crate) struct RelationTargetView {
pub(crate) target: String,
pub(crate) degree: Option<crate::relation::Degree>,
}
type RelationGroup = (RelationKey, Vec<RelationTargetView>);
#[derive(Debug)]
pub(crate) struct InspectView {
pub(crate) id: String,
pub(crate) outbound: Vec<RelationGroup>,
pub(crate) inbound: Vec<RelationGroup>,
pub(crate) danglers: Vec<(RelationLabel, String)>,
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "own-scan convenience wrapper for the unit suite; the F2 command layer \
calls inspect_from with the shared scan, so it is test-only"
)
)]
pub(crate) fn inspect(root: &Path, id: &str) -> anyhow::Result<InspectView> {
inspect_from(
&scan_entities(root, &mut vec![], ScanMode::default())?,
root,
id,
)
}
pub(crate) fn inspect_from(
scanned: &[ScannedEntity],
root: &Path,
id: &str,
) -> anyhow::Result<InspectView> {
let (kref, qid) = integrity::parse_canonical_ref(id)?;
let query_key = EntityKey {
prefix: kref.kind.prefix,
id: qid,
};
let rg = build_relation_graph_from(scanned)?;
require_minted(&rg.projection, query_key)?;
let Some(node) = rg.projection.resolve(query_key) else {
debug_assert!(false, "inspect_from: gate passed but key not resolvable");
anyhow::bail!("{}: no such entity", query_key.canonical());
};
let mut outbound_by_key: BTreeMap<(RelationLabel, Option<Role>), Vec<RelationTargetView>> =
BTreeMap::new();
for edge in outbound_for(root, kref.kind, qid)? {
outbound_by_key
.entry((edge.label, edge.role))
.or_default()
.push(RelationTargetView {
target: edge.target,
degree: edge.degree,
});
}
let outbound: Vec<RelationGroup> = outbound_by_key.into_iter().collect();
let role_index = inbound_role_index(scanned);
let degree_index = inbound_degree_index(scanned);
let mut inbound_by_key: BTreeMap<RelationKey, Vec<InboundSrc>> = BTreeMap::new();
for (&overlay, &label) in &rg.overlays.by_overlay {
for (src_node, _attrs) in rg.graph.in_edges(overlay, node) {
let Some(src_key) = rg.projection.key_of(src_node) else {
continue;
};
let role = role_index
.get(&(src_key, label, query_key))
.copied()
.unwrap_or(None);
let degree = degree_index
.get(&(src_key, label, query_key))
.copied()
.unwrap_or(None);
inbound_by_key
.entry((label, role))
.or_default()
.push((src_key, degree));
}
}
let inbound: Vec<RelationGroup> = inbound_by_key
.into_iter()
.map(|(key, mut srcs)| {
srcs.sort_by_key(|(a, _)| *a);
(
key,
srcs.into_iter()
.map(|(k, deg)| RelationTargetView {
target: k.canonical(),
degree: deg,
})
.collect(),
)
})
.collect();
let danglers = rg.danglers.get(&query_key).cloned().unwrap_or_default();
Ok(InspectView {
id: query_key.canonical(),
outbound,
inbound,
danglers,
})
}
pub(crate) fn render_from(
scanned: &[ScannedEntity],
root: &Path,
id: &str,
format: Format,
) -> anyhow::Result<String> {
let view = inspect_from(scanned, root, id)?;
match format {
Format::Table => render_human(root, &view),
Format::Json => render_json(&view),
}
}
fn render_human(root: &Path, view: &InspectView) -> anyhow::Result<String> {
let interaction_types = match integrity::parse_canonical_ref(&view.id) {
Ok((kref, qid)) if kref.kind.prefix == "SPEC" => crate::spec::interaction_types(root, qid)?,
_ => BTreeMap::new(),
};
let mut parts: Vec<String> = Vec::new();
parts.push(format!("{} — relations\n", view.id));
render_outbound(&mut parts, view, &interaction_types);
render_inbound(&mut parts, view);
render_danglers(&mut parts, view);
if view.outbound.is_empty() && view.inbound.is_empty() && view.danglers.is_empty() {
parts.push("\n(no relations)\n".to_string());
}
Ok(parts.concat())
}
fn render_outbound(
parts: &mut Vec<String>,
view: &InspectView,
interaction_types: &BTreeMap<String, String>,
) {
if view.outbound.is_empty() {
return;
}
parts.push("\noutbound:\n".to_string());
for ((label, role), targets) in &view.outbound {
let rendered: Vec<String> = if *label == RelationLabel::Interactions {
targets
.iter()
.map(|t| match interaction_types.get(&t.target) {
Some(ty) => format!("{} ({})", t.target, ty),
None => t.target.clone(),
})
.collect()
} else {
targets
.iter()
.map(|t| match t.degree {
Some(crate::relation::Degree::Partial) => format!("{} (partial)", t.target),
_ => t.target.clone(),
})
.collect()
};
parts.push(format!(
" {}: {}\n",
outbound_label(*label, *role),
rendered.join(", ")
));
}
}
fn outbound_label(label: RelationLabel, role: Option<Role>) -> String {
match role {
Some(role) => format!("{}({})", label.name(), role.name()),
None => label.name().to_string(),
}
}
fn render_inbound(parts: &mut Vec<String>, view: &InspectView) {
if view.inbound.is_empty() {
return;
}
parts.push("\ninbound:\n".to_string());
for ((label, role), srcs) in &view.inbound {
let word = crate::relation::inbound_name(*label, *role);
let rendered: Vec<String> = srcs
.iter()
.map(|t| match t.degree {
Some(crate::relation::Degree::Partial) => format!("{} (partial)", t.target),
_ => t.target.clone(),
})
.collect();
parts.push(format!(" {word}: {}\n", rendered.join(", ")));
}
}
fn render_danglers(parts: &mut Vec<String>, view: &InspectView) {
if view.danglers.is_empty() {
return;
}
parts.push("\ndanglers:\n".to_string());
for (label, target) in &view.danglers {
parts.push(format!(" {}: {target}\n", label.name()));
}
}
fn render_json(view: &InspectView) -> anyhow::Result<String> {
serde_json::to_string_pretty(&inspect_value(view))
.map_err(|e| anyhow::anyhow!("failed to serialize inspect JSON: {e}"))
}
pub(crate) fn inspect_value(view: &InspectView) -> serde_json::Value {
let is_degree_bearing = |label: RelationLabel| -> bool {
crate::relation::RELATION_RULES
.iter()
.any(|r| r.label == label && r.degree_bearing)
};
let target_value = |label: RelationLabel, t: &RelationTargetView| -> serde_json::Value {
if is_degree_bearing(label) {
match t.degree {
Some(deg) => {
serde_json::json!({ "ref": t.target, "degree": deg.name() })
}
None => serde_json::json!({ "ref": t.target }),
}
} else {
serde_json::Value::String(t.target.clone())
}
};
let group = |label: RelationLabel, role: Option<Role>, targets: &[RelationTargetView]| {
let ts: Vec<serde_json::Value> = targets.iter().map(|t| target_value(label, t)).collect();
match role {
Some(role) => {
serde_json::json!({ "label": label.name(), "role": role.name(), "targets": ts })
}
None => serde_json::json!({ "label": label.name(), "targets": ts }),
}
};
let outbound: Vec<serde_json::Value> = view
.outbound
.iter()
.map(|((l, r), t)| group(*l, *r, t))
.collect();
let inbound: Vec<serde_json::Value> = view
.inbound
.iter()
.map(|((l, r), t)| group(*l, *r, t))
.collect();
let danglers: Vec<serde_json::Value> = view
.danglers
.iter()
.map(|(l, t)| serde_json::json!({ "label": l.name(), "target": t }))
.collect();
serde_json::json!({
"kind": "inspect",
"id": view.id,
"outbound": outbound,
"inbound": inbound,
"danglers": danglers,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TransitiveDir {
Inbound,
Outbound,
Both,
}
impl TransitiveDir {
fn has_inbound(self) -> bool {
matches!(self, Self::Inbound | Self::Both)
}
fn has_outbound(self) -> bool {
matches!(self, Self::Outbound | Self::Both)
}
}
#[derive(Debug)]
pub(crate) struct TransitiveGroup {
pub label: RelationLabel,
pub targets: Vec<String>,
pub truncated: bool,
}
#[derive(Debug)]
pub(crate) struct TransitiveView {
pub id: String,
pub max_depth: Option<usize>,
pub truncated: bool,
pub inbound: Option<Vec<TransitiveGroup>>,
pub outbound: Option<Vec<TransitiveGroup>>,
}
fn not_walkable_message(bad: &[&str], overlays: &OverlayMap) -> String {
let mut walkable: Vec<&str> = overlays.by_label.keys().map(|label| label.name()).collect();
walkable.sort_unstable();
format!(
"not transitively walkable: {}; overlay-backed labels are: {}",
bad.join(", "),
walkable.join(", ")
)
}
pub(crate) fn resolve_transitive_label_names(
names: &[String],
) -> anyhow::Result<Option<Vec<RelationLabel>>> {
if names.is_empty() {
return Ok(None);
}
let mut builder = GraphBuilder::new();
let overlays = OverlayMap::build(&mut builder);
let mut good = Vec::with_capacity(names.len());
let mut bad = Vec::new();
for name in names {
match RelationLabel::from_name(name) {
Some(label) if overlays.overlay_for(label).is_some() => good.push(label),
_ => bad.push(name.as_str()),
}
}
if !bad.is_empty() {
anyhow::bail!("{}", not_walkable_message(&bad, &overlays));
}
Ok(Some(good))
}
fn transitive_labels(
overlays: &OverlayMap,
labels: Option<&[RelationLabel]>,
) -> anyhow::Result<Vec<RelationLabel>> {
let mut selected: Vec<RelationLabel> = match labels {
None => overlays.by_label.keys().copied().collect(),
Some(requested) => {
let bad: Vec<&str> = requested
.iter()
.filter(|label| overlays.overlay_for(**label).is_none())
.map(|label| label.name())
.collect();
if !bad.is_empty() {
anyhow::bail!("{}", not_walkable_message(&bad, overlays));
}
requested.to_vec()
}
};
selected.sort_by_key(|label| label.name());
Ok(selected)
}
fn walk_transitive(
rg: &RelationGraph,
node: cordage::NodeId,
direction: Direction,
labels: &[RelationLabel],
max_depth: Option<usize>,
) -> Vec<TransitiveGroup> {
let mut groups = Vec::new();
for &label in labels {
let Some(overlay) = rg.overlays.overlay_for(label) else {
continue;
};
let reach = rg
.graph
.reachable_bounded(overlay, node, direction, max_depth);
let mut targets: Vec<EntityKey> = reach
.depths
.keys()
.filter_map(|reached| rg.projection.key_of(*reached))
.collect();
if targets.is_empty() {
continue;
}
targets.sort();
groups.push(TransitiveGroup {
label,
targets: targets.into_iter().map(EntityKey::canonical).collect(),
truncated: reach.truncated,
});
}
groups
}
pub(crate) fn transitive_from(
scanned: &[ScannedEntity],
_root: &Path,
id: &str,
dir: TransitiveDir,
labels: Option<&[RelationLabel]>,
max_depth: Option<usize>,
) -> anyhow::Result<TransitiveView> {
let (kref, qid) = integrity::parse_canonical_ref(id)?;
let query_key = EntityKey {
prefix: kref.kind.prefix,
id: qid,
};
let rg = build_relation_graph_from(scanned)?;
require_minted(&rg.projection, query_key)?;
let Some(node) = rg.projection.resolve(query_key) else {
debug_assert!(false, "transitive_from: gate passed but key not resolvable");
anyhow::bail!("{}: no such entity", query_key.canonical());
};
let selected = transitive_labels(&rg.overlays, labels)?;
let inbound = dir
.has_inbound()
.then(|| walk_transitive(&rg, node, Direction::Against, &selected, max_depth));
let outbound = dir
.has_outbound()
.then(|| walk_transitive(&rg, node, Direction::Along, &selected, max_depth));
let truncated = inbound
.iter()
.chain(outbound.iter())
.flatten()
.any(|group| group.truncated);
Ok(TransitiveView {
id: query_key.canonical(),
max_depth,
truncated,
inbound,
outbound,
})
}
pub(crate) fn render_transitive_human(view: &TransitiveView) -> String {
let depth = view
.max_depth
.map_or_else(|| "all".to_string(), |d| d.to_string());
let mut parts: Vec<String> = vec![format!("{} — transitive (depth {depth})\n", view.id)];
if let Some(groups) = &view.inbound {
parts.push("\ndepends on this (inbound):\n".to_string());
push_transitive_groups(&mut parts, groups);
}
if let Some(groups) = &view.outbound {
parts.push("\nthis depends on (outbound):\n".to_string());
push_transitive_groups(&mut parts, groups);
}
if view.truncated {
parts.push(format!(
"\n… some chains truncated at depth {depth} — re-run with --max-depth all\n"
));
}
parts.concat()
}
fn push_transitive_groups(parts: &mut Vec<String>, groups: &[TransitiveGroup]) {
if groups.is_empty() {
parts.push(" (none)\n".to_string());
return;
}
for group in groups {
parts.push(format!(
" {}: {}\n",
group.label.name(),
group.targets.join(", ")
));
}
}
pub(crate) fn render_transitive_json(view: &TransitiveView) -> anyhow::Result<String> {
serde_json::to_string_pretty(&transitive_value(view))
.map_err(|e| anyhow::anyhow!("failed to serialize transitive JSON: {e}"))
}
pub(crate) fn transitive_value(view: &TransitiveView) -> serde_json::Value {
let group = |group: &TransitiveGroup| {
serde_json::json!({
"label": group.label.name(),
"truncated": group.truncated,
"targets": group.targets,
})
};
let direction =
|groups: &[TransitiveGroup]| serde_json::Value::Array(groups.iter().map(&group).collect());
let mut obj = serde_json::Map::new();
obj.insert("kind".to_string(), serde_json::json!("inspect-transitive"));
obj.insert("id".to_string(), serde_json::json!(view.id));
obj.insert(
"max_depth".to_string(),
view.max_depth
.map_or(serde_json::Value::Null, |d| serde_json::json!(d)),
);
obj.insert("truncated".to_string(), serde_json::json!(view.truncated));
if let Some(groups) = &view.inbound {
obj.insert("inbound".to_string(), direction(groups));
}
if let Some(groups) = &view.outbound {
obj.insert("outbound".to_string(), direction(groups));
}
serde_json::Value::Object(obj)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::integrity::KINDS;
use crate::relation::RelationLabel;
use crate::test_support::{SCHEMA_BACKLOG, SCHEMA_KNOWLEDGE};
use std::fs;
fn write(root: &Path, rel: &str, body: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
fn kind_for(prefix: &str) -> &'static entity::Kind {
KINDS.iter().find(|k| k.kind.prefix == prefix).unwrap().kind
}
fn pairs(edges: &[RelationEdge]) -> Vec<(RelationLabel, &str)> {
edges.iter().map(|e| (e.label, e.target.as_str())).collect()
}
fn tmp() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
#[test]
fn slice_outbound_references_supersedes() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"a\"\ntitle = \"A\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"PRD-010\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"REQ-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"REQ-002\"\n\
[[relation]]\nlabel = \"supersedes\"\ntarget = \"SL-000\"\n",
);
write(&root, ".doctrine/slice/001/slice-001.md", "scope\n");
let edges = outbound_for(&root, kind_for("SL"), 1).unwrap();
assert_eq!(
pairs(&edges),
vec![
(RelationLabel::References, "PRD-010"),
(RelationLabel::References, "REQ-001"),
(RelationLabel::References, "REQ-002"),
(RelationLabel::Supersedes, "SL-000"),
]
);
}
#[test]
fn governance_outbound_supersedes_related_only() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/adr/002/adr-002.toml",
"id = 2\nslug = \"a\"\ntitle = \"A\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsuperseded_by = [\"ADR-009\"]\n\
tags = [\"layering\"]\n\
[[relation]]\nlabel = \"supersedes\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"related\"\ntarget = \"ADR-004\"\n",
);
write(&root, ".doctrine/adr/002/adr-002.md", "body\n");
let edges = outbound_for(&root, kind_for("ADR"), 2).unwrap();
assert_eq!(
pairs(&edges),
vec![
(RelationLabel::Supersedes, "ADR-001"),
(RelationLabel::Related, "ADR-004"),
],
"governance emits supersedes + related ONLY (no superseded_by, no tags)"
);
}
#[test]
fn spec_outbound_lineage_members_interactions() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/spec/tech/001/spec-001.toml",
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"draft\"\nkind = \"tech\"\n\
descends_from = \"PRD-005\"\nparent = \"SPEC-000\"\n",
);
write(&root, ".doctrine/spec/tech/001/spec-001.md", "b\n");
write(
&root,
".doctrine/spec/tech/001/members.toml",
"[[member]]\nrequirement = \"REQ-009\"\nlabel = \"FR\"\norder = 1\n",
);
write(
&root,
".doctrine/spec/tech/001/interactions.toml",
"[[edge]]\ntarget = \"SPEC-002\"\ntype = \"calls\"\nnotes = \"sync\"\n",
);
let edges = outbound_for(&root, kind_for("SPEC"), 1).unwrap();
assert_eq!(
pairs(&edges),
vec![
(RelationLabel::DescendsFrom, "PRD-005"),
(RelationLabel::Parent, "SPEC-000"),
(RelationLabel::Members, "REQ-009"),
(RelationLabel::Interactions, "SPEC-002"),
]
);
}
#[test]
fn product_spec_lineage_options_absent_emit_nothing() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/spec/product/003/spec-003.toml",
"id = 3\nslug = \"p\"\ntitle = \"P\"\nstatus = \"draft\"\nkind = \"product\"\n",
);
write(&root, ".doctrine/spec/product/003/spec-003.md", "b\n");
write(&root, ".doctrine/spec/product/003/members.toml", "");
let edges = outbound_for(&root, kind_for("PRD"), 3).unwrap();
assert!(
edges.is_empty(),
"absent Options + empty members emit nothing"
);
}
#[test]
fn backlog_outbound_references_drift_only() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/backlog/issue/001/backlog-001.toml",
"id = 1\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nneeds = [\"ISS-002\"]\n\
[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"PRD-009\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"originates_from\"\ntarget = \"SL-020\"\n\
[[relation]]\nlabel = \"drift\"\ntarget = \"some-free-text\"\n",
);
write(&root, ".doctrine/backlog/issue/001/backlog-001.md", "b\n");
let edges = outbound_for(&root, kind_for("ISS"), 1).unwrap();
assert_eq!(
pairs(&edges),
vec![
(RelationLabel::References, "SL-020"),
(RelationLabel::References, "PRD-009"),
(RelationLabel::Drift, "some-free-text"),
],
"backlog emits references/drift ONLY (no needs/after/triggers), canonical order"
);
}
#[test]
fn review_outbound_single_reviews_edge() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/review/001/review-001.toml",
"id = 1\nslug = \"r\"\ntitle = \"R\"\n\
[review]\nfacet = \"reconciliation\"\nraiser = \"a\"\nresponder = \"b\"\n\
[target]\nref = \"SL-046\"\n",
);
let edges = outbound_for(&root, kind_for("RV"), 1).unwrap();
assert_eq!(pairs(&edges), vec![(RelationLabel::Reviews, "SL-046")]);
}
#[test]
fn rec_outbound_owning_slice_and_decision_ref() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/rec/001/rec-001.toml",
"id = 1\nslug = \"r\"\ntitle = \"R\"\n\
[rec]\nmove = \"accept\"\nowning_slice = \"SL-046\"\ndecision_ref = \"DEC-005-C\"\n",
);
let edges = outbound_for(&root, kind_for("REC"), 1).unwrap();
assert_eq!(
pairs(&edges),
vec![
(RelationLabel::OwningSlice, "SL-046"),
(RelationLabel::DecisionRef, "DEC-005-C"),
]
);
}
#[test]
fn requirement_authors_no_outbound() {
let dir = tmp();
let root = dir.path();
let edges = outbound_for(&root, kind_for("REQ"), 1).unwrap();
assert!(edges.is_empty());
}
#[test]
fn revision_outbound_arm_reads_change_rows() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/revision/001/revision-001.toml",
"id = 1\nslug = \"r\"\ntitle = \"R\"\nstatus = \"proposed\"\napproval = \"none\"\n",
);
let empty = outbound_for(&root, kind_for("REV"), 1).unwrap();
assert!(
empty.is_empty(),
"a REV with no change rows authors no outbound"
);
write(
&root,
".doctrine/revision/002/revision-002.toml",
"id = 2\nslug = \"r\"\ntitle = \"R\"\nstatus = \"proposed\"\napproval = \"none\"\n\
[[change]]\ntarget = \"ADR-006\"\naction = \"modify\"\nprimary = true\n",
);
let edges = outbound_for(&root, kind_for("REV"), 2).unwrap();
assert_eq!(edges.len(), 1, "one change row → one revises edge");
assert_eq!(edges[0].label, RelationLabel::Revises);
assert_eq!(edges[0].target, "ADR-006");
}
#[test]
fn revision_dep_seq_arm_reads_its_own_toml() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/revision/001/revision-001.toml",
"id = 1\nslug = \"r\"\ntitle = \"R\"\nstatus = \"proposed\"\napproval = \"none\"\n\
[relationships]\nneeds = [\"SL-046\"]\nafter = []\n",
);
let (ds, promoted) = dep_seq_for(&root, kind_for("REV"), 1).unwrap();
assert_eq!(ds.needs, vec!["SL-046"], "REV needs reach the blocker view");
assert!(!promoted, "REV carries no backlog-only promoted projection");
}
#[test]
fn knowledge_kinds_author_outbound_edges() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/knowledge/assumption/001/record-001.toml",
&format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\n\
id = 1\nslug = \"a\"\ntitle = \"A\"\n\
record_kind = \"assumption\"\nstatus = \"held\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
tags = []\n\n\
[facet]\n\
claim = \"\"\nconfidence = \"\"\nbasis = \"\"\n\
validation_plan = \"\"\nvalidated_by = \"\"\nvalidated_on = \"\"\n\
invalidated_by = \"\"\ninvalidated_on = \"\"\n\n\
[evidence]\n\
supports = []\ncontradicts = []\nnotes = []\n\
[[relation]]\nlabel = \"shapes\"\ntarget = \"SL-001\"\n\
[[relation]]\nlabel = \"spawns\"\ntarget = \"ISS-001\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"SL-001\"\n"
),
);
write(
&root,
".doctrine/knowledge/assumption/001/record-001.md",
"body\n",
);
let edges = outbound_for(&root, kind_for("ASM"), 1).unwrap();
assert_eq!(edges.len(), 4);
assert!(
edges
.iter()
.any(|e| e.label == RelationLabel::Shapes && e.target == "SL-001")
);
assert!(
edges
.iter()
.any(|e| e.label == RelationLabel::Spawns && e.target == "ISS-001")
);
assert!(
edges
.iter()
.any(|e| e.label == RelationLabel::GovernedBy && e.target == "ADR-001")
);
assert!(edges.iter().any(|e| e.label == RelationLabel::References
&& e.role == Some(crate::relation::Role::Concerns)
&& e.target == "SL-001"));
}
#[test]
fn knowledge_rows_present_but_no_record_tree_leaves_the_graph_unchanged() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/requirement/001/requirement-001.toml",
"id = 1\nslug = \"r\"\ntitle = \"R\"\nstatus = \"active\"\n",
);
write(&root, ".doctrine/requirement/001/requirement-001.md", "b\n");
let scanned = scan_entities(&root, &mut vec![], ScanMode::default()).unwrap();
let keys: Vec<_> = scanned.iter().map(|e| e.key.canonical()).collect();
assert_eq!(keys, vec!["REQ-001"], "no record kind contributes a node");
}
#[test]
fn rec_decision_ref_carried_as_free_text_not_dropped() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/rec/002/rec-002.toml",
"id = 2\nslug = \"r\"\ntitle = \"R\"\n\
[rec]\nmove = \"accept\"\ndecision_ref = \"DEC-001-A\"\n",
);
let edges = outbound_for(&root, kind_for("REC"), 2).unwrap();
assert_eq!(
pairs(&edges),
vec![(RelationLabel::DecisionRef, "DEC-001-A")]
);
}
#[test]
fn dep_seq_for_slice_arm_reads_needs_after_promoted_false() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"a\"\ntitle = \"A\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nneeds = [\"SL-002\"]\n\
after = [{ to = \"SL-003\", rank = 4 }]\n",
);
write(&root, ".doctrine/slice/001/slice-001.md", "scope\n");
let (ds, promoted) = dep_seq_for(&root, kind_for("SL"), 1).unwrap();
assert_eq!(ds.needs, vec!["SL-002"]);
assert_eq!(
ds.after,
vec![dep_seq::AfterEdge {
to: "SL-003".to_string(),
rank: 4,
}]
);
assert!(!promoted, "a slice is never promoted");
}
#[test]
fn dep_seq_for_backlog_arm_one_parse_carries_promoted() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/backlog/issue/001/backlog-001.toml",
"id = 1\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"resolved\"\n\
resolution = \"promoted\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nneeds = [\"ISS-002\"]\n\
after = [{ to = \"RSK-001\", rank = 2 }]\n",
);
write(&root, ".doctrine/backlog/issue/001/backlog-001.md", "b\n");
let (ds, promoted) = dep_seq_for(&root, kind_for("ISS"), 1).unwrap();
assert_eq!(ds.needs, vec!["ISS-002"]);
assert_eq!(
ds.after,
vec![dep_seq::AfterEdge {
to: "RSK-001".to_string(),
rank: 2,
}]
);
assert!(
promoted,
"resolution=promoted carried through the backlog arm"
);
}
#[test]
fn dep_seq_for_non_authoring_kind_short_circuits_before_any_read() {
let dir = tmp();
let root = dir.path();
let (ds, promoted) = dep_seq_for(&root, kind_for("ADR"), 1).unwrap();
assert_eq!(
ds,
dep_seq::DepSeq::default(),
"non-authoring kind yields empty dep/seq with no disk read"
);
assert!(!promoted);
write(
&root,
".doctrine/requirement/001/requirement-001.toml",
"this is not valid toml at all = = =\n",
);
let (ds2, _promoted2) = dep_seq_for(&root, kind_for("REQ"), 1).unwrap();
assert_eq!(
ds2,
dep_seq::DepSeq::default(),
"garbage toml for a non-authoring kind is never read → still empty"
);
}
#[test]
fn interactions_collapse_to_single_class_label() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/spec/tech/004/spec-004.toml",
"id = 4\nslug = \"s\"\ntitle = \"S\"\nstatus = \"draft\"\nkind = \"tech\"\n",
);
write(&root, ".doctrine/spec/tech/004/spec-004.md", "b\n");
write(&root, ".doctrine/spec/tech/004/members.toml", "");
write(
&root,
".doctrine/spec/tech/004/interactions.toml",
"[[edge]]\ntarget = \"SPEC-009\"\ntype = \"depends-on\"\nnotes = \"n\"\n\
[[edge]]\ntarget = \"SPEC-010\"\ntype = \"calls\"\n",
);
let edges = outbound_for(&root, kind_for("SPEC"), 4).unwrap();
assert_eq!(
pairs(&edges),
vec![
(RelationLabel::Interactions, "SPEC-009"),
(RelationLabel::Interactions, "SPEC-010"),
]
);
}
fn inbound_for(view: &InspectView, label: RelationLabel) -> Vec<&str> {
view.inbound
.iter()
.filter(|((l, _), _)| *l == label)
.flat_map(|(_, v)| v.iter().map(|t| t.target.as_str()))
.collect()
}
fn outbound_targets(view: &InspectView, label: RelationLabel) -> Vec<&str> {
view.outbound
.iter()
.filter(|((l, _), _)| *l == label)
.flat_map(|(_, v)| v.iter().map(|t| t.target.as_str()))
.collect()
}
fn slice_toml(id: u32, axes: &[(&str, &[&str])]) -> String {
format!(
"id = {id}\nslug = \"s\"\ntitle = \"S\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n{}",
crate::relation::rels_block(kind_for("SL"), axes)
)
}
fn seed_slice(root: &Path, id: u32, axes: &[(&str, &[&str])]) {
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.toml"),
&slice_toml(id, axes),
);
write(
root,
&format!(".doctrine/slice/{id:03}/slice-{id:03}.md"),
"scope\n",
);
}
fn seed_adr(root: &Path, id: u32, axes: &[(&str, &[&str])]) {
write(
root,
&format!(".doctrine/adr/{id:03}/adr-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"a\"\ntitle = \"A\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n{}",
crate::relation::rels_block(kind_for("ADR"), axes)
),
);
write(
root,
&format!(".doctrine/adr/{id:03}/adr-{id:03}.md"),
"body\n",
);
}
#[test]
fn inbound_derived_from_in_edges_including_supersedes_reciprocal() {
let dir = tmp();
let root = dir.path();
seed_slice(&root, 1, &[]);
seed_slice(
&root,
2,
&[
("references(implements)", &["REQ-005"]),
("supersedes", &["SL-001"]),
],
);
write(
&root,
".doctrine/requirement/005/requirement-005.toml",
"id = 5\nslug = \"r\"\ntitle = \"R\"\nstatus = \"active\"\n",
);
write(&root, ".doctrine/requirement/005/requirement-005.md", "r\n");
let pred = inspect(&root, "SL-001").unwrap();
assert_eq!(pred.id, "SL-001");
assert!(pred.outbound.is_empty(), "predecessor authors no outbound");
assert_eq!(
inbound_for(&pred, RelationLabel::Supersedes),
vec!["SL-002"],
"supersedes-overlay inbound is the derived reciprocal (renders 'superseded by')"
);
let req = inspect(&root, "REQ-005").unwrap();
assert_eq!(inbound_for(&req, RelationLabel::References), vec!["SL-002"]);
let succ = inspect(&root, "SL-002").unwrap();
assert_eq!(
outbound_targets(&succ, RelationLabel::Supersedes),
vec!["SL-001"]
);
assert!(succ.inbound.is_empty(), "successor has no inbound");
}
#[test]
fn duplicate_authored_ref_collapses_to_single_inbound_no_panic() {
let dir = tmp();
let root = dir.path();
seed_slice(&root, 1, &[]);
seed_slice(&root, 2, &[("supersedes", &["SL-001", "SL-001"])]);
let view = inspect(&root, "SL-001").unwrap();
assert_eq!(
inbound_for(&view, RelationLabel::Supersedes),
vec!["SL-002"],
"two identical (label,src,dst) rows collapse to one inbound edge"
);
}
#[test]
fn inbound_render_is_permutation_invariant() {
let dir = tmp();
let root = dir.path();
seed_slice(&root, 1, &[]);
seed_slice(&root, 4, &[("supersedes", &["SL-001"])]);
seed_slice(&root, 2, &[("supersedes", &["SL-001"])]);
seed_slice(&root, 3, &[("supersedes", &["SL-001"])]);
let view = inspect(&root, "SL-001").unwrap();
assert_eq!(
inbound_for(&view, RelationLabel::Supersedes),
vec!["SL-002", "SL-003", "SL-004"],
"inbound renders in ascending canonical-ref order, not filesystem order"
);
let dir2 = tmp();
let root2 = dir2.path();
seed_slice(&root2, 1, &[]);
seed_slice(&root2, 1001, &[("supersedes", &["SL-001"])]);
seed_slice(&root2, 998, &[("supersedes", &["SL-001"])]);
seed_slice(&root2, 1000, &[("supersedes", &["SL-001"])]);
seed_slice(&root2, 999, &[("supersedes", &["SL-001"])]);
let view2 = inspect(&root2, "SL-001").unwrap();
assert_eq!(
inbound_for(&view2, RelationLabel::Supersedes),
vec!["SL-998", "SL-999", "SL-1000", "SL-1001"],
"inbound sort is numeric-within-prefix, not lexical (RSK-007)"
);
}
#[test]
fn stored_superseded_by_without_reciprocal_yields_no_inbound() {
let dir = tmp();
let root = dir.path();
seed_adr(&root, 2, &[("superseded_by", &["ADR-009"])]);
seed_adr(&root, 9, &[]);
let view = inspect(&root, "ADR-002").unwrap();
assert!(
view.inbound.is_empty(),
"a lone stored superseded_by produces no derived inbound"
);
let nine = inspect(&root, "ADR-009").unwrap();
assert!(nine.inbound.is_empty());
}
#[test]
fn dangling_and_free_text_targets_surface_as_danglers() {
let dir = tmp();
let root = dir.path();
seed_slice(&root, 1, &[]);
write(
&root,
".doctrine/backlog/issue/001/backlog-001.toml",
"id = 1\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"originates_from\"\ntarget = \"SL-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"originates_from\"\ntarget = \"SL-099\"\n\
[[relation]]\nlabel = \"drift\"\ntarget = \"some-free-text\"\n",
);
write(&root, ".doctrine/backlog/issue/001/backlog-001.md", "b\n");
let view = inspect(&root, "ISS-001").unwrap();
assert_eq!(
outbound_targets(&view, RelationLabel::References),
vec!["SL-001", "SL-099"],
"outbound lists every authored target regardless of resolution"
);
assert!(
view.danglers
.contains(&(RelationLabel::References, "SL-099".to_string())),
"an unresolved canonical ref dangles"
);
assert!(
view.danglers
.contains(&(RelationLabel::Drift, "some-free-text".to_string())),
"a free-text drift target dangles (no DRIFT kind / overlay)"
);
std::os::unix::fs::symlink("001", root.join(".doctrine/slice/a-slug")).unwrap();
let still = inspect(&root, "ISS-001").unwrap();
assert_eq!(
outbound_targets(&still, RelationLabel::References),
vec!["SL-001", "SL-099"]
);
let empty = inspect(&root, "SL-001").unwrap();
seed_slice(&root, 50, &[]);
let lone = inspect(&root, "SL-050").unwrap();
assert!(lone.outbound.is_empty());
assert!(lone.inbound.is_empty());
assert!(lone.danglers.is_empty());
assert_eq!(
inbound_for(&empty, RelationLabel::References),
vec!["ISS-001"]
);
}
#[test]
fn nonexistent_id_is_no_such_entity_error() {
let dir = tmp();
let root = dir.path();
seed_slice(&root, 1, &[]);
let err = inspect(&root, "SL-999").unwrap_err();
assert_eq!(
err.to_string(),
"SL-999: no such entity",
"the exact existence-gate message"
);
}
#[test]
fn unknown_prefix_clean_error() {
let dir = tmp();
let root = dir.path();
seed_slice(&root, 1, &[]);
let err = inspect(&root, "ZZZ-001").unwrap_err();
assert!(
err.to_string().contains("ZZZ"),
"unknown prefix surfaces a clean error mentioning the prefix"
);
}
#[test]
fn overlay_set_equals_resolvable_graph_labels_table_driven() {
use crate::relation::{RELATION_RULES, TargetSpec};
use std::collections::BTreeSet;
let resolvable_from_table: BTreeSet<RelationLabel> = RELATION_RULES
.iter()
.filter(|r| !matches!(r.target, TargetSpec::Unvalidated))
.map(|r| r.label)
.collect();
let mut builder = GraphBuilder::new();
let overlays = OverlayMap::build(&mut builder);
let overlay_backed: BTreeSet<RelationLabel> = overlays.by_label.keys().copied().collect();
assert_eq!(
overlay_backed, resolvable_from_table,
"the allocated overlay set must equal the table's resolvable (non-Unvalidated) labels"
);
let unvalidated: BTreeSet<RelationLabel> = RELATION_RULES
.iter()
.filter(|r| matches!(r.target, TargetSpec::Unvalidated))
.map(|r| r.label)
.collect();
assert_eq!(
unvalidated,
BTreeSet::from([
RelationLabel::Contextualizes,
RelationLabel::Drift,
RelationLabel::DecisionRef,
]),
"the no-overlay set is exactly contextualizes + drift + decision_ref"
);
for label in [
RelationLabel::Contextualizes,
RelationLabel::Drift,
RelationLabel::DecisionRef,
] {
assert!(
overlays.overlay_for(label).is_none(),
"{label:?} (Unvalidated) must have no overlay"
);
}
assert_eq!(overlay_backed.len(), 18, "overlay-backed label count is 18");
}
fn table_labels_for(
prefix: &str,
) -> std::collections::BTreeSet<(RelationLabel, Option<crate::relation::Role>)> {
use crate::relation::RELATION_RULES;
RELATION_RULES
.iter()
.filter(|r| r.sources.iter().any(|k| *k == prefix))
.map(|r| (r.label, r.role))
.collect()
}
fn emitted_labels(
root: &Path,
prefix: &str,
id: u32,
) -> std::collections::BTreeSet<(RelationLabel, Option<crate::relation::Role>)> {
outbound_for(root, kind_for(prefix), id)
.unwrap()
.iter()
.map(|e| (e.label, e.role))
.collect()
}
#[test]
fn reader_emitted_labels_equal_table_labels_per_source() {
let dir = tmp();
let root = dir.path();
write(
&root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[[relation]]\nlabel = \"specs\"\ntarget = \"PRD-010\"\n\
[[relation]]\nlabel = \"requirements\"\ntarget = \"REQ-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"SPEC-018\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"originates_from\"\ntarget = \"IMP-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"RFC-003\"\n\
[[relation]]\nlabel = \"supersedes\"\ntarget = \"SL-002\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"related\"\ntarget = \"ADR-010\"\n\
[[relation]]\nlabel = \"fulfils\"\ntarget = \"IMP-005\"\n",
);
write(&root, ".doctrine/slice/001/slice-001.md", "s\n");
assert_eq!(
emitted_labels(root, "SL", 1),
table_labels_for("SL"),
"slice reader emits exactly its table labels"
);
write(
&root,
".doctrine/adr/001/adr-001.toml",
"id = 1\nslug = \"a\"\ntitle = \"A\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\ntags = []\n\
[relationships]\nsuperseded_by = []\n\
[[relation]]\nlabel = \"supersedes\"\ntarget = \"ADR-002\"\n\
[[relation]]\nlabel = \"related\"\ntarget = \"ADR-003\"\n",
);
write(&root, ".doctrine/adr/001/adr-001.md", "a\n");
assert_eq!(
emitted_labels(root, "ADR", 1),
table_labels_for("ADR"),
"governance reader emits exactly supersedes + related"
);
write(
&root,
".doctrine/backlog/issue/001/backlog-001.toml",
"id = 1\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[[relation]]\nlabel = \"specs\"\ntarget = \"PRD-010\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"SL-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"originates_from\"\ntarget = \"SL-002\"\n\
[[relation]]\nlabel = \"related\"\ntarget = \"ADR-010\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"drift\"\ntarget = \"free-text\"\n",
);
write(&root, ".doctrine/backlog/issue/001/backlog-001.md", "i\n");
assert_eq!(
emitted_labels(root, "ISS", 1),
table_labels_for("ISS"),
"backlog reader emits exactly its table labels"
);
write(
&root,
".doctrine/spec/tech/001/spec-001.toml",
"id = 1\nslug = \"sp\"\ntitle = \"SP\"\nstatus = \"draft\"\nkind = \"tech\"\n\
descends_from = \"PRD-010\"\nparent = \"SPEC-002\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n",
);
write(&root, ".doctrine/spec/tech/001/spec-001.md", "sp\n");
write(
&root,
".doctrine/spec/tech/001/members.toml",
"[[member]]\nlabel = \"M\"\norder = 0\nrequirement = \"REQ-001\"\n",
);
write(
&root,
".doctrine/spec/tech/001/interactions.toml",
"[[edge]]\ntarget = \"SPEC-003\"\ntype = \"calls\"\nnotes = \"\"\n",
);
assert_eq!(
emitted_labels(root, "SPEC", 1),
table_labels_for("SPEC"),
"tech spec reader emits governed_by + descends_from + parent + members + interactions"
);
write(
&root,
".doctrine/spec/product/001/spec-001.toml",
"id = 1\nslug = \"pr\"\ntitle = \"PR\"\nstatus = \"draft\"\nkind = \"product\"\n\
parent = \"PRD-002\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"consumes\"\ntarget = \"PRD-002\"\n",
);
write(&root, ".doctrine/spec/product/001/spec-001.md", "pr\n");
write(
&root,
".doctrine/spec/product/001/members.toml",
"[[member]]\nlabel = \"M\"\norder = 0\nrequirement = \"REQ-001\"\n",
);
assert_eq!(
emitted_labels(root, "PRD", 1),
table_labels_for("PRD"),
"product spec reader emits governed_by + consumes + members"
);
write(
&root,
".doctrine/review/001/review-001.toml",
"id = 1\nslug = \"r\"\ntitle = \"R\"\n\
[review]\nfacet = \"reconciliation\"\nraiser = \"a\"\nresponder = \"b\"\n\
[target]\nref = \"SL-001\"\n",
);
assert_eq!(
emitted_labels(root, "RV", 1),
table_labels_for("RV"),
"review reader emits exactly reviews"
);
write(
&root,
".doctrine/rec/001/rec-001.toml",
"id = 1\nslug = \"r\"\ntitle = \"R\"\n\
[rec]\nmove = \"accept\"\nowning_slice = \"SL-001\"\ndecision_ref = \"DEC-001-A\"\n",
);
assert_eq!(
emitted_labels(root, "REC", 1),
table_labels_for("REC"),
"rec reader emits exactly owning_slice + decision_ref"
);
write(
&root,
".doctrine/knowledge/assumption/001/record-001.toml",
&format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\n\
id = 1\nslug = \"a\"\ntitle = \"A\"\n\
record_kind = \"assumption\"\nstatus = \"held\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
tags = []\n\n\
[facet]\n\
claim = \"\"\nconfidence = \"\"\nbasis = \"\"\n\
validation_plan = \"\"\nvalidated_by = \"\"\nvalidated_on = \"\"\n\
invalidated_by = \"\"\ninvalidated_on = \"\"\n\n\
[evidence]\n\
supports = []\ncontradicts = []\nnotes = []\n\
[[relation]]\nlabel = \"shapes\"\ntarget = \"SL-001\"\n\
[[relation]]\nlabel = \"spawns\"\ntarget = \"ISS-001\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"SL-001\"\n"
),
);
write(
&root,
".doctrine/knowledge/assumption/001/record-001.md",
"body\n",
);
{
let mut expected = table_labels_for("ASM");
expected.remove(&(RelationLabel::Supersedes, None));
assert_eq!(
emitted_labels(root, "ASM", 1),
expected,
"ASM: shapes + spawns + governed_by + references(concerns) (supersedes is LifecycleOnly — typed parse in PHASE-03)"
);
}
write(
&root,
".doctrine/knowledge/decision/001/record-001.toml",
&format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\n\
id = 1\nslug = \"d\"\ntitle = \"D\"\n\
record_kind = \"decision\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
tags = []\n\n\
[facet]\n\
context = \"\"\nchoice = \"\"\nalternatives = []\n\
rationale = \"\"\nconsequences = []\n\
decided_by = \"\"\ndecided_on = \"\"\n\n\
[evidence]\n\
supports = []\ncontradicts = []\nnotes = []\n\
[[relation]]\nlabel = \"shapes\"\ntarget = \"SL-001\"\n\
[[relation]]\nlabel = \"spawns\"\ntarget = \"ISS-001\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"SL-001\"\n"
),
);
write(
&root,
".doctrine/knowledge/decision/001/record-001.md",
"body\n",
);
{
let mut expected = table_labels_for("DEC");
expected.remove(&(RelationLabel::Supersedes, None));
assert_eq!(
emitted_labels(root, "DEC", 1),
expected,
"DEC: shapes + spawns + governed_by + references(concerns) (supersedes is LifecycleOnly — typed parse in PHASE-03)"
);
}
write(
&root,
".doctrine/knowledge/question/001/record-001.toml",
&format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\n\
id = 1\nslug = \"q\"\ntitle = \"Q\"\n\
record_kind = \"question\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
tags = []\n\n\
[facet]\n\
question = \"\"\nwhy_matters = \"\"\nanswer = \"\"\n\
answered_by = \"\"\nanswered_on = \"\"\n\n\
[evidence]\n\
supports = []\ncontradicts = []\nnotes = []\n\
[[relation]]\nlabel = \"shapes\"\ntarget = \"SL-001\"\n\
[[relation]]\nlabel = \"spawns\"\ntarget = \"ISS-001\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"SL-001\"\n"
),
);
write(
&root,
".doctrine/knowledge/question/001/record-001.md",
"body\n",
);
{
let mut expected = table_labels_for("QUE");
expected.remove(&(RelationLabel::Supersedes, None));
assert_eq!(
emitted_labels(root, "QUE", 1),
expected,
"QUE: shapes + spawns + governed_by + references(concerns) (supersedes is LifecycleOnly — typed parse in PHASE-03)"
);
}
write(
&root,
".doctrine/knowledge/constraint/001/record-001.toml",
&format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\n\
id = 1\nslug = \"c\"\ntitle = \"C\"\n\
record_kind = \"constraint\"\nstatus = \"active\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
tags = []\n\n\
[facet]\n\
statement = \"\"\nsource = \"\"\napplies_to = []\n\
waiver_reason = \"\"\nwaived_by = \"\"\nwaived_on = \"\"\n\n\
[evidence]\n\
supports = []\ncontradicts = []\nnotes = []\n\
[[relation]]\nlabel = \"shapes\"\ntarget = \"SL-001\"\n\
[[relation]]\nlabel = \"spawns\"\ntarget = \"ISS-001\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"SL-001\"\n"
),
);
write(
&root,
".doctrine/knowledge/constraint/001/record-001.md",
"body\n",
);
{
let mut expected = table_labels_for("CON");
expected.remove(&(RelationLabel::Supersedes, None));
assert_eq!(
emitted_labels(root, "CON", 1),
expected,
"CON: shapes + spawns + governed_by + references(concerns) (supersedes is LifecycleOnly — typed parse in PHASE-03)"
);
}
write(
&root,
".doctrine/knowledge/evidence/001/record-001.toml",
&format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\n\
id = 1\nslug = \"e\"\ntitle = \"E\"\n\
record_kind = \"evidence\"\nstatus = \"captured\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
tags = []\n\n\
[facet]\n\
datum = \"\"\nprovenance = \"\"\nconfidence = \"\"\n\n\
[evidence]\n\
supports = []\ncontradicts = []\nnotes = []\n\
[[relation]]\nlabel = \"shapes\"\ntarget = \"SL-001\"\n\
[[relation]]\nlabel = \"spawns\"\ntarget = \"ISS-001\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"supports\"\ntarget = \"ASM-001\"\n\
[[relation]]\nlabel = \"disputes\"\ntarget = \"HYP-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"SL-001\"\n"
),
);
write(
&root,
".doctrine/knowledge/evidence/001/record-001.md",
"body\n",
);
{
let mut expected = table_labels_for("EVD");
expected.remove(&(RelationLabel::Supersedes, None));
assert_eq!(
emitted_labels(root, "EVD", 1),
expected,
"EVD: shapes + spawns + governed_by + supports + disputes + references(concerns) (supersedes is LifecycleOnly)"
);
}
write(
&root,
".doctrine/knowledge/hypothesis/001/record-001.toml",
&format!(
"schema = \"{SCHEMA_KNOWLEDGE}\"\nversion = 1\n\n\
id = 1\nslug = \"h\"\ntitle = \"H\"\n\
record_kind = \"hypothesis\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
tags = []\n\n\
[facet]\n\
proposition = \"\"\npredicts = \"\"\n\n\
[evidence]\n\
supports = []\ncontradicts = []\nnotes = []\n\
[[relation]]\nlabel = \"shapes\"\ntarget = \"SL-001\"\n\
[[relation]]\nlabel = \"spawns\"\ntarget = \"ISS-001\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"SL-001\"\n"
),
);
write(
&root,
".doctrine/knowledge/hypothesis/001/record-001.md",
"body\n",
);
{
let mut expected = table_labels_for("HYP");
expected.remove(&(RelationLabel::Supersedes, None));
assert_eq!(
emitted_labels(root, "HYP", 1),
expected,
"HYP: shapes + spawns + governed_by + references(concerns) (supersedes is LifecycleOnly)"
);
}
}
#[test]
fn validate_relations_reports_danglers_and_illegal_rows() {
let dir = tmp();
let root = dir.path();
seed_slice(
root,
1,
&[("references(implements)", &["REQ-005", "REQ-999"])],
);
write(
root,
".doctrine/requirement/005/requirement-005.toml",
"id = 5\nslug = \"r\"\ntitle = \"R\"\nstatus = \"active\"\n",
);
write(root, ".doctrine/requirement/005/requirement-005.md", "r\n");
write(
root,
".doctrine/backlog/issue/001/backlog-001.toml",
&format!(
"schema = \"{SCHEMA_BACKLOG}\"\nversion = 1\n\
id = 1\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\ntags = []\n\
[[relation]]\nlabel = \"drift\"\ntarget = \"loose talk\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"originates_from\"\ntarget = \"SL-001\"\n"
),
);
write(root, ".doctrine/backlog/issue/001/backlog-001.md", "i\n");
write(
root,
".doctrine/slice/002/slice-002.toml",
"id = 2\nslug = \"s\"\ntitle = \"S\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[[relation]]\nlabel = \"descends_from\"\ntarget = \"PRD-001\"\n",
);
write(root, ".doctrine/slice/002/slice-002.md", "s\n");
let findings = validate_relations(root).unwrap();
let joined = findings.join("\n");
assert!(
joined.contains("SL-001") && joined.contains("REQ-999") && joined.contains("dangling"),
"the deleted REQ-999 target is reported as a dangler: {joined}"
);
assert!(
!joined.contains("REQ-005"),
"the resolvable REQ-005 target is NOT a finding: {joined}"
);
assert!(
!joined.contains("loose talk"),
"the Unvalidated drift target dangles by design — not a finding: {joined}"
);
assert!(
joined.contains("SL-002") && joined.contains("illegal"),
"the hand-edited illegal `descends_from` row is reported: {joined}"
);
let after =
std::fs::read_to_string(root.join(".doctrine/slice/002/slice-002.toml")).unwrap();
assert!(
after.contains("label = \"descends_from\""),
"validate never rewrites the corpus"
);
}
#[test]
fn validate_relations_flags_bad_references_role() {
let dir = tmp();
let root = dir.path();
write(
root,
".doctrine/slice/001/slice-001.toml",
"id = 1\nslug = \"s\"\ntitle = \"S\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"SPEC-018\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-001\"\n\
[[relation]]\nlabel = \"references\"\ntarget = \"PRD-010\"\n",
);
write(root, ".doctrine/slice/001/slice-001.md", "s\n");
write(
root,
".doctrine/backlog/issue/001/backlog-001.toml",
&format!(
"schema = \"{SCHEMA_BACKLOG}\"\nversion = 1\n\
id = 1\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\ntags = []\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"SPEC-018\"\n"
),
);
write(root, ".doctrine/backlog/issue/001/backlog-001.md", "i\n");
write(
root,
".doctrine/spec/tech/018/spec-018.toml",
"id = 18\nslug = \"x\"\ntitle = \"X\"\nstatus = \"draft\"\nkind = \"tech\"\n",
);
write(root, ".doctrine/spec/tech/018/spec-018.md", "x\n");
write(
root,
".doctrine/adr/001/adr-001.toml",
"id = 1\nslug = \"a\"\ntitle = \"A\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\ntags = []\n",
);
write(root, ".doctrine/adr/001/adr-001.md", "a\n");
let findings = validate_relations(root).unwrap();
let joined = findings.join("\n");
assert!(
joined.contains("SL-001: [[relation]] row `references` -> `PRD-010`")
&& joined.contains("missing or illegal role"),
"the missing-role references row is reported as a role-class IllegalRow: {joined}"
);
assert!(
joined.contains("ISS-001: [[relation]] row `references` -> `SPEC-018`")
&& joined.contains("missing or illegal role"),
"the illegal-for-source role is reported: {joined}"
);
assert_eq!(
findings.len(),
2,
"exactly the two role-class findings, nothing else: {joined}"
);
}
#[test]
fn validate_supersession_reports_drift_both_ways() {
let dir = tmp();
let root = dir.path();
seed_adr(root, 1, &[]);
seed_adr(root, 2, &[("supersedes", &["ADR-001"])]);
seed_adr(root, 3, &[("superseded_by", &["ADR-009"])]);
let findings = validate_supersession(root).unwrap();
let joined = findings.join("\n");
assert!(
joined.contains("ADR-001") && joined.contains("ADR-002"),
"ADR-002 supersedes ADR-001 but ADR-001 omits it from superseded_by: {joined}"
);
assert!(
joined.contains("ADR-003") && joined.contains("ADR-009"),
"ADR-003 lists ADR-009 in superseded_by with no backing supersedes: {joined}"
);
}
#[test]
fn validate_supersession_clean_on_consistent_pair() {
let dir = tmp();
let root = dir.path();
seed_adr(root, 1, &[("superseded_by", &["ADR-002"])]);
seed_adr(root, 2, &[("supersedes", &["ADR-001"])]);
assert!(
validate_supersession(root).unwrap().is_empty(),
"a consistent supersedes/superseded_by pair is clean"
);
}
fn scan(root: &Path) -> Vec<ScannedEntity> {
scan_entities(root, &mut vec![], ScanMode::default()).unwrap()
}
fn groups(dir: &Option<Vec<TransitiveGroup>>) -> Vec<(RelationLabel, Vec<String>)> {
dir.as_ref()
.map(|gs| gs.iter().map(|g| (g.label, g.targets.clone())).collect())
.unwrap_or_default()
}
#[test]
fn transitive_inbound_outbound_directional() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("governed_by", &["ADR-005"])]);
seed_adr(root, 5, &[]);
let scanned = scan(root);
let view = transitive_from(
&scanned,
root,
"ADR-005",
TransitiveDir::Inbound,
None,
Some(5),
)
.unwrap();
assert_eq!(
groups(&view.inbound),
vec![(RelationLabel::GovernedBy, vec!["SL-001".to_string()])],
"inbound governed_by from ADR-005 reaches SL-001"
);
assert!(view.outbound.is_none(), "outbound not requested → omitted");
let view = transitive_from(
&scanned,
root,
"ADR-005",
TransitiveDir::Outbound,
None,
Some(5),
)
.unwrap();
assert!(view.inbound.is_none(), "inbound not requested → omitted");
assert_eq!(
groups(&view.outbound),
vec![],
"outbound from ADR-005 on governed_by is empty"
);
let view = transitive_from(
&scanned,
root,
"SL-001",
TransitiveDir::Outbound,
None,
Some(5),
)
.unwrap();
assert_eq!(
groups(&view.outbound),
vec![(RelationLabel::GovernedBy, vec!["ADR-005".to_string()])],
"outbound governed_by from SL-001 reaches ADR-005"
);
}
#[test]
fn transitive_per_label_sections_narrowing_and_role_collapse() {
let dir = tmp();
let root = dir.path();
seed_slice(
root,
1,
&[
("references(implements)", &["REQ-001"]),
("references(concerns)", &["ADR-005"]),
("governed_by", &["ADR-005"]),
],
);
seed_adr(root, 5, &[]);
write(
root,
".doctrine/requirement/001/requirement-001.toml",
"id = 1\nslug = \"r\"\ntitle = \"R\"\nstatus = \"active\"\n",
);
write(root, ".doctrine/requirement/001/requirement-001.md", "b\n");
let scanned = scan(root);
let view = transitive_from(
&scanned,
root,
"SL-001",
TransitiveDir::Outbound,
None,
Some(5),
)
.unwrap();
assert_eq!(
groups(&view.outbound),
vec![
(RelationLabel::GovernedBy, vec!["ADR-005".to_string()]),
(
RelationLabel::References,
vec!["ADR-005".to_string(), "REQ-001".to_string()]
),
],
"per-label sections, references roles collapsed to one section"
);
let view = transitive_from(
&scanned,
root,
"SL-001",
TransitiveDir::Outbound,
Some(&[RelationLabel::References]),
Some(5),
)
.unwrap();
assert_eq!(
groups(&view.outbound),
vec![(
RelationLabel::References,
vec!["ADR-005".to_string(), "REQ-001".to_string()]
)],
"labels narrowing to a subset"
);
}
#[test]
fn transitive_rejects_no_overlay_labels_and_predicate_is_table_derived() {
use crate::relation::{RELATION_RULES, TargetSpec};
use std::collections::BTreeSet;
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("governed_by", &["ADR-005"])]);
seed_adr(root, 5, &[]);
let scanned = scan(root);
for label in [
RelationLabel::Contextualizes,
RelationLabel::Drift,
RelationLabel::DecisionRef,
] {
let err = transitive_from(
&scanned,
root,
"SL-001",
TransitiveDir::Both,
Some(&[label]),
Some(5),
)
.unwrap_err();
assert!(
err.to_string().contains("not transitively walkable")
&& err.to_string().contains(label.name()),
"{label:?} rejected with a clear message: {err}"
);
}
let rg = build_relation_graph_from(&scanned).unwrap();
let default_set: BTreeSet<RelationLabel> = transitive_labels(&rg.overlays, None)
.unwrap()
.into_iter()
.collect();
let resolvable_from_table: BTreeSet<RelationLabel> = RELATION_RULES
.iter()
.filter(|r| !matches!(r.target, TargetSpec::Unvalidated))
.map(|r| r.label)
.collect();
assert_eq!(
default_set, resolvable_from_table,
"default transitive label set is table-derived (== resolvable labels), no hardcoded list"
);
for label in [
RelationLabel::Contextualizes,
RelationLabel::Drift,
RelationLabel::DecisionRef,
] {
assert!(
!default_set.contains(&label),
"{label:?} (no overlay) absent from the default transitive set"
);
}
}
#[test]
fn transitive_depth_cap_truncation_and_existence_gate() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[]);
seed_slice(root, 2, &[("supersedes", &["SL-001"])]);
seed_slice(root, 3, &[("supersedes", &["SL-002"])]);
seed_slice(root, 4, &[("supersedes", &["SL-003"])]);
let scanned = scan(root);
let view = transitive_from(
&scanned,
root,
"SL-004",
TransitiveDir::Outbound,
None,
None,
)
.unwrap();
assert_eq!(
groups(&view.outbound),
vec![(
RelationLabel::Supersedes,
vec![
"SL-001".to_string(),
"SL-002".to_string(),
"SL-003".to_string()
]
)],
"unbounded supersedes walk reaches the deepest leaf"
);
assert!(!view.truncated, "no cap → not truncated");
let view = transitive_from(
&scanned,
root,
"SL-004",
TransitiveDir::Outbound,
None,
Some(2),
)
.unwrap();
let outbound = view.outbound.as_ref().unwrap();
assert_eq!(
outbound[0].targets,
vec!["SL-002".to_string(), "SL-003".to_string()],
"depth 2 excludes the depth-3 node"
);
assert!(outbound[0].truncated, "group truncated at the cap");
assert!(view.truncated, "view-level truncated ORs the group flag");
let err = transitive_from(&scanned, root, "SL-999", TransitiveDir::Both, None, Some(5))
.unwrap_err();
assert_eq!(err.to_string(), "SL-999: no such entity");
}
#[test]
fn transitive_json_envelope_golden() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("governed_by", &["ADR-005"])]);
seed_adr(root, 5, &[]);
let scanned = scan(root);
let view =
transitive_from(&scanned, root, "ADR-005", TransitiveDir::Both, None, None).unwrap();
let json = render_transitive_json(&view).unwrap();
let expected = "\
{
\"id\": \"ADR-005\",
\"inbound\": [
{
\"label\": \"governed_by\",
\"targets\": [
\"SL-001\"
],
\"truncated\": false
}
],
\"kind\": \"inspect-transitive\",
\"max_depth\": null,
\"outbound\": [],
\"truncated\": false
}";
assert_eq!(json, expected, "the C4 JSON envelope golden");
let view = transitive_from(
&scanned,
root,
"ADR-005",
TransitiveDir::Inbound,
None,
Some(5),
)
.unwrap();
let value = transitive_value(&view);
assert!(
value.get("inbound").is_some(),
"requested direction present"
);
assert!(
value.get("outbound").is_none(),
"non-requested direction key absent (not null/empty)"
);
assert_eq!(value.get("max_depth").and_then(|v| v.as_u64()), Some(5));
}
#[test]
fn transitive_human_render_shape() {
let dir = tmp();
let root = dir.path();
seed_slice(root, 1, &[("governed_by", &["ADR-005"])]);
seed_adr(root, 5, &[]);
let scanned = scan(root);
let view = transitive_from(
&scanned,
root,
"ADR-005",
TransitiveDir::Both,
None,
Some(5),
)
.unwrap();
let text = render_transitive_human(&view);
assert_eq!(
text,
"ADR-005 — transitive (depth 5)\n\
\ndepends on this (inbound):\n governed_by: SL-001\n\
\nthis depends on (outbound):\n (none)\n"
);
let view = transitive_from(
&scanned,
root,
"ADR-005",
TransitiveDir::Inbound,
None,
None,
)
.unwrap();
assert!(
render_transitive_human(&view).starts_with("ADR-005 — transitive (depth all)\n"),
"unbounded header reads depth all"
);
}
}