#![cfg_attr(
not(test),
expect(
dead_code,
reason = "SL-096 PHASE-01/02 — read_record exercises from_name/RELATION_RULES/lookup/read_block/tier1_edges; PHASE-02 wires outbound_for to knowledge::relation_edges (more callers for tier1_edges); remaining dead symbols (validate_link, check_target_kind, append_edge/remove_edge, writable_labels_for, owning_verb_for) self-clear when their command handlers land"
)
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
pub(crate) enum RelationLabel {
References,
Supersedes,
DescendsFrom,
Parent,
Members,
Interactions,
Contextualizes,
Shapes,
Spawns,
GovernedBy,
Consumes,
Slices,
Related,
Reviews,
OwningSlice,
Drift,
DecisionRef,
Revises,
OriginatesFrom,
Supports,
Disputes,
}
impl RelationLabel {
pub(crate) const fn name(self) -> &'static str {
match self {
RelationLabel::References => "references",
RelationLabel::Supersedes => "supersedes",
RelationLabel::DescendsFrom => "descends_from",
RelationLabel::Parent => "parent",
RelationLabel::Members => "members",
RelationLabel::Interactions => "interactions",
RelationLabel::Contextualizes => "contextualizes",
RelationLabel::Shapes => "shapes",
RelationLabel::Spawns => "spawns",
RelationLabel::GovernedBy => "governed_by",
RelationLabel::Consumes => "consumes",
RelationLabel::Slices => "slices",
RelationLabel::Related => "related",
RelationLabel::Reviews => "reviews",
RelationLabel::OwningSlice => "owning_slice",
RelationLabel::Drift => "drift",
RelationLabel::DecisionRef => "decision_ref",
RelationLabel::Revises => "revises",
RelationLabel::OriginatesFrom => "originates_from",
RelationLabel::Supports => "supports",
RelationLabel::Disputes => "disputes",
}
}
pub(crate) fn from_name(name: &str) -> Option<RelationLabel> {
let label = match name {
"references" => RelationLabel::References,
"supersedes" => RelationLabel::Supersedes,
"descends_from" => RelationLabel::DescendsFrom,
"parent" => RelationLabel::Parent,
"members" => RelationLabel::Members,
"interactions" => RelationLabel::Interactions,
"contextualizes" => RelationLabel::Contextualizes,
"shapes" => RelationLabel::Shapes,
"spawns" => RelationLabel::Spawns,
"governed_by" => RelationLabel::GovernedBy,
"consumes" => RelationLabel::Consumes,
"slices" => RelationLabel::Slices,
"related" => RelationLabel::Related,
"reviews" => RelationLabel::Reviews,
"owning_slice" => RelationLabel::OwningSlice,
"drift" => RelationLabel::Drift,
"decision_ref" => RelationLabel::DecisionRef,
"revises" => RelationLabel::Revises,
"originates_from" => RelationLabel::OriginatesFrom,
"supports" => RelationLabel::Supports,
"disputes" => RelationLabel::Disputes,
_ => return None,
};
debug_assert_eq!(label.name(), name);
Some(label)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
pub(crate) enum Role {
Implements,
ScopedFrom,
Concerns,
}
impl Role {
pub(crate) const fn name(self) -> &'static str {
match self {
Role::Implements => "implements",
Role::ScopedFrom => "scoped_from",
Role::Concerns => "concerns",
}
}
pub(crate) fn from_name(name: &str) -> Option<Role> {
let role = match name {
"implements" => Role::Implements,
"scoped_from" => Role::ScopedFrom,
"concerns" => Role::Concerns,
_ => return None,
};
debug_assert_eq!(role.name(), name);
Some(role)
}
}
use anyhow::Context;
use crate::entity::Kind;
use crate::kinds::{
ADR, ASM, BACKLOG, CHR, CM, CON, DEC, EVD, GOV, HYP, IDE, IMP, ISS, POL, PRD, QUE, REC, RECORD,
REQ, REV, RFC, RSK, RV, SL, SPEC, STD,
};
#[derive(Clone, Copy)]
pub(crate) enum TargetSpec {
Kinds(&'static [&'static str]),
SameKind,
AnyNumbered,
Unvalidated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Tier {
One,
Typed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LinkPolicy {
Writable,
LifecycleOnly,
TypedVerbOnly,
}
#[derive(Clone, Copy)]
pub(crate) struct RelationRule {
pub(crate) sources: &'static [&'static str],
pub(crate) label: RelationLabel,
pub(crate) role: Option<Role>,
pub(crate) inbound_name: &'static str,
pub(crate) target: TargetSpec,
pub(crate) tier: Tier,
pub(crate) link: LinkPolicy,
}
pub(crate) const RELATION_RULES: &[RelationRule] = &[
RelationRule {
sources: &[SL],
label: RelationLabel::References,
role: Some(Role::Implements),
inbound_name: "implemented by",
target: TargetSpec::Kinds(&[SPEC, PRD, REQ]),
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: &[SL],
label: RelationLabel::References,
role: Some(Role::ScopedFrom),
inbound_name: "scoped into",
target: TargetSpec::Kinds(BACKLOG),
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: &[
SL, RFC, ISS, IMP, CHR, RSK, IDE, ASM, DEC, QUE, CON, EVD, HYP,
],
label: RelationLabel::References,
role: Some(Role::Concerns),
inbound_name: "concerned by",
target: TargetSpec::AnyNumbered,
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: &[SL],
label: RelationLabel::Supersedes,
role: None,
inbound_name: "superseded by",
target: TargetSpec::Kinds(&[SL]),
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: GOV,
label: RelationLabel::Supersedes,
role: None,
inbound_name: "superseded by",
target: TargetSpec::SameKind,
tier: Tier::One,
link: LinkPolicy::LifecycleOnly,
},
RelationRule {
sources: RECORD,
label: RelationLabel::Supersedes,
role: None,
inbound_name: "superseded by",
target: TargetSpec::Kinds(RECORD),
tier: Tier::One,
link: LinkPolicy::LifecycleOnly,
},
RelationRule {
sources: &[SPEC],
label: RelationLabel::DescendsFrom,
role: None,
inbound_name: "descends_from",
target: TargetSpec::Kinds(&[PRD]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
},
RelationRule {
sources: &[SPEC, PRD],
label: RelationLabel::Parent,
role: None,
inbound_name: "parent",
target: TargetSpec::Kinds(&[SPEC, PRD]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
},
RelationRule {
sources: &[PRD, SPEC],
label: RelationLabel::Members,
role: None,
inbound_name: "members",
target: TargetSpec::Kinds(&[REQ]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
},
RelationRule {
sources: &[SPEC],
label: RelationLabel::Interactions,
role: None,
inbound_name: "interactions",
target: TargetSpec::Kinds(&[SPEC]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
},
RelationRule {
sources: &[CM],
label: RelationLabel::Contextualizes,
role: None,
inbound_name: "contextualized_by",
target: TargetSpec::Unvalidated,
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: RECORD,
label: RelationLabel::Shapes,
role: None,
inbound_name: "shaped_by",
target: TargetSpec::Kinds(&[
PRD, SPEC, REQ, SL, ISS, IMP, CHR, RSK, IDE, ADR, POL, STD, RFC, ASM, DEC, QUE, CON,
EVD, HYP,
]),
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: RECORD,
label: RelationLabel::Spawns,
role: None,
inbound_name: "spawned_by",
target: TargetSpec::Kinds(&[ISS, IMP, CHR, RSK, IDE]),
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: &[
SL, PRD, SPEC, CM, ASM, DEC, QUE, CON, EVD, HYP, ISS, IMP, CHR, RSK, IDE,
],
label: RelationLabel::GovernedBy,
role: None,
inbound_name: "governs",
target: TargetSpec::Kinds(GOV),
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: &[PRD],
label: RelationLabel::Consumes,
role: None,
inbound_name: "consumed_by",
target: TargetSpec::Kinds(&[PRD]),
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: BACKLOG,
label: RelationLabel::Slices,
role: None,
inbound_name: "slices",
target: TargetSpec::Kinds(&[SL]),
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: GOV,
label: RelationLabel::Related,
role: None,
inbound_name: "related",
target: TargetSpec::SameKind,
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: &[SL, RFC, ISS, IMP, CHR, RSK, IDE],
label: RelationLabel::Related,
role: None,
inbound_name: "related",
target: TargetSpec::AnyNumbered,
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: &[RV],
label: RelationLabel::Reviews,
role: None,
inbound_name: "reviews",
target: TargetSpec::AnyNumbered,
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
},
RelationRule {
sources: &[REC],
label: RelationLabel::OwningSlice,
role: None,
inbound_name: "owning_slice",
target: TargetSpec::Kinds(&[SL]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
},
RelationRule {
sources: BACKLOG,
label: RelationLabel::Drift,
role: None,
inbound_name: "drift",
target: TargetSpec::Unvalidated,
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: &[REC],
label: RelationLabel::DecisionRef,
role: None,
inbound_name: "decision_ref",
target: TargetSpec::Unvalidated,
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
},
RelationRule {
sources: &[REV],
label: RelationLabel::Revises,
role: None,
inbound_name: "revises",
target: TargetSpec::Kinds(&[SPEC, PRD, REQ, ADR, POL, STD]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
},
RelationRule {
sources: &[REV],
label: RelationLabel::OriginatesFrom,
role: None,
inbound_name: "precursor of",
target: TargetSpec::Kinds(&[RFC]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
},
RelationRule {
sources: &[EVD],
label: RelationLabel::Supports,
role: None,
inbound_name: "supported_by",
target: TargetSpec::Kinds(RECORD),
tier: Tier::One,
link: LinkPolicy::Writable,
},
RelationRule {
sources: &[EVD],
label: RelationLabel::Disputes,
role: None,
inbound_name: "disputed_by",
target: TargetSpec::Kinds(RECORD),
tier: Tier::One,
link: LinkPolicy::Writable,
},
];
pub(crate) fn lookup(
source: &Kind,
label: RelationLabel,
role: Option<Role>,
) -> Option<&'static RelationRule> {
RELATION_RULES
.iter()
.find(|r| r.label == label && r.role == role && r.sources.contains(&source.prefix))
}
pub(crate) fn legal_roles(source: &Kind, label: RelationLabel) -> impl Iterator<Item = Role> + '_ {
RELATION_RULES
.iter()
.filter(move |r| r.label == label && r.sources.contains(&source.prefix))
.filter_map(|r| r.role)
}
fn source_label_admitted(source: &Kind, label: RelationLabel) -> bool {
RELATION_RULES
.iter()
.any(|r| r.label == label && r.sources.contains(&source.prefix))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RelationEdge {
pub(crate) label: RelationLabel,
pub(crate) role: Option<Role>,
pub(crate) target: String,
}
impl RelationEdge {
pub(crate) fn new(label: RelationLabel, target: String) -> Self {
Self {
label,
role: None,
target,
}
}
pub(crate) fn with_role(label: RelationLabel, role: Option<Role>, target: String) -> Self {
Self {
label,
role,
target,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum IllegalReason {
UnknownLabel,
IllegalForSource,
IllegalRole,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IllegalRow {
pub(crate) label: String,
pub(crate) target: String,
pub(crate) reason: IllegalReason,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct RelationRow {
label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
role: Option<String>,
target: String,
}
#[derive(Debug, Default, serde::Deserialize)]
pub(crate) struct RelationDoc {
#[serde(default)]
relation: Vec<RelationRow>,
}
impl RelationDoc {
pub(crate) fn parse(text: &str) -> anyhow::Result<RelationDoc> {
toml::from_str(text).map_err(|e| anyhow::anyhow!("parse [[relation]] block: {e}"))
}
}
pub(crate) fn read_block(
source_kind: &Kind,
doc: &RelationDoc,
) -> (Vec<RelationEdge>, Vec<IllegalRow>) {
let mut illegal: Vec<IllegalRow> = Vec::new();
let mut legal: Vec<(usize, RelationEdge)> = Vec::new();
for row in &doc.relation {
let illegal_row = |reason: IllegalReason| IllegalRow {
label: row.label.clone(),
target: row.target.clone(),
reason,
};
let Some(label) = RelationLabel::from_name(&row.label) else {
illegal.push(illegal_row(IllegalReason::UnknownLabel));
continue;
};
if !source_label_admitted(source_kind, label) {
illegal.push(illegal_row(IllegalReason::IllegalForSource));
continue;
}
let role = match &row.role {
None => None,
Some(spelling) => {
let Some(parsed) = Role::from_name(spelling) else {
illegal.push(illegal_row(IllegalReason::IllegalRole));
continue;
};
Some(parsed)
}
};
let Some(pos) = canonical_position(source_kind, label, role) else {
illegal.push(illegal_row(IllegalReason::IllegalRole));
continue;
};
legal.push((
pos,
RelationEdge::with_role(label, role, row.target.clone()),
));
}
legal.sort_by_key(|(pos, _)| *pos);
let edges = legal.into_iter().map(|(_, e)| e).collect();
(edges, illegal)
}
fn canonical_position(source: &Kind, label: RelationLabel, role: Option<Role>) -> Option<usize> {
RELATION_RULES
.iter()
.position(|r| r.label == label && r.role == role && r.sources.contains(&source.prefix))
}
pub(crate) fn tier1_edges(source_kind: &Kind, text: &str) -> anyhow::Result<Vec<RelationEdge>> {
let doc = RelationDoc::parse(text)?;
let (edges, _illegal) = read_block(source_kind, &doc);
Ok(edges)
}
pub(crate) fn targets_for(edges: &[RelationEdge], label: RelationLabel) -> Vec<String> {
edges
.iter()
.filter(|e| e.label == label)
.map(|e| e.target.clone())
.collect()
}
pub(crate) fn targets_for_role(
edges: &[RelationEdge],
label: RelationLabel,
role: Role,
) -> Vec<String> {
edges
.iter()
.filter(|e| e.label == label && e.role == Some(role))
.map(|e| e.target.clone())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AppendOutcome {
Wrote,
Noop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RemoveOutcome {
Removed,
Absent,
}
fn trailing_typed_table_after_relation(doc: &toml_edit::DocumentMut) -> Option<String> {
let mut seen_relation = false;
for (key, item) in doc.as_table() {
if key == "relation" && item.is_array_of_tables() {
seen_relation = true;
} else if seen_relation {
return Some(key.to_string());
}
}
None
}
fn append_relation_row(
text: &str,
label: RelationLabel,
role: Option<Role>,
target: &str,
) -> anyhow::Result<(String, AppendOutcome)> {
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| anyhow::anyhow!("parse TOML for relation append: {e}"))?;
if relation_row_present(&doc, label, role, target) {
return Ok((text.to_string(), AppendOutcome::Noop));
}
if let Some(offending) = trailing_typed_table_after_relation(&doc) {
anyhow::bail!(
"refusing to append [[relation]]: typed table `[{offending}]` is authored AFTER \
the [[relation]] array (F1 — appending would corrupt it by tail-inserting into \
the last array element). Re-home `[{offending}]` above the [[relation]] block."
);
}
let array = doc
.as_table_mut()
.entry("relation")
.or_insert_with(|| toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()))
.as_array_of_tables_mut()
.ok_or_else(|| {
anyhow::anyhow!("`relation` is present but is not an array-of-tables (corrupt file)")
})?;
let mut row = toml_edit::Table::new();
row.insert("label", toml_edit::value(label.name()));
if let Some(r) = role {
row.insert("role", toml_edit::value(r.name()));
}
row.insert("target", toml_edit::value(target));
array.push(row);
Ok((doc.to_string(), AppendOutcome::Wrote))
}
fn remove_relation_row(
text: &str,
label: RelationLabel,
role: Option<Role>,
target: &str,
) -> anyhow::Result<(String, RemoveOutcome)> {
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| anyhow::anyhow!("parse TOML for relation remove: {e}"))?;
let Some(array) = doc
.as_table_mut()
.get_mut("relation")
.and_then(toml_edit::Item::as_array_of_tables_mut)
else {
return Ok((text.to_string(), RemoveOutcome::Absent));
};
let before = array.len();
array.retain(|row| !row_matches(row, label, role, target));
if array.len() == before {
return Ok((text.to_string(), RemoveOutcome::Absent));
}
Ok((doc.to_string(), RemoveOutcome::Removed))
}
fn relation_row_present(
doc: &toml_edit::DocumentMut,
label: RelationLabel,
role: Option<Role>,
target: &str,
) -> bool {
doc.as_table()
.get("relation")
.and_then(toml_edit::Item::as_array_of_tables)
.is_some_and(|array| {
array
.iter()
.any(|row| row_matches(row, label, role, target))
})
}
fn row_matches(
row: &toml_edit::Table,
label: RelationLabel,
role: Option<Role>,
target: &str,
) -> bool {
let row_role = row.get("role").and_then(toml_edit::Item::as_str);
row.get("label").and_then(toml_edit::Item::as_str) == Some(label.name())
&& row_role == role.map(Role::name)
&& row.get("target").and_then(toml_edit::Item::as_str) == Some(target)
}
pub(crate) fn append_edge(
toml_path: &std::path::Path,
label: RelationLabel,
role: Option<Role>,
target: &str,
) -> anyhow::Result<AppendOutcome> {
let text = std::fs::read_to_string(toml_path)
.map_err(|e| anyhow::anyhow!("read {} for relation append: {e}", toml_path.display()))?;
let (next, outcome) = append_relation_row(&text, label, role, target)?;
if outcome == AppendOutcome::Wrote {
crate::fsutil::write_atomic(toml_path, next.as_bytes())
.with_context(|| format!("write {} after relation append", toml_path.display()))?;
}
Ok(outcome)
}
pub(crate) fn remove_edge(
toml_path: &std::path::Path,
label: RelationLabel,
role: Option<Role>,
target: &str,
) -> anyhow::Result<RemoveOutcome> {
let text = std::fs::read_to_string(toml_path)
.map_err(|e| anyhow::anyhow!("read {} for relation remove: {e}", toml_path.display()))?;
let (next, outcome) = remove_relation_row(&text, label, role, target)?;
if outcome == RemoveOutcome::Removed {
crate::fsutil::write_atomic(toml_path, next.as_bytes())
.with_context(|| format!("write {} after relation remove", toml_path.display()))?;
}
Ok(outcome)
}
pub(crate) fn inbound_name(label: RelationLabel, role: Option<Role>) -> &'static str {
RELATION_RULES
.iter()
.find(|r| r.label == label && r.role == role)
.map_or(label.name(), |r| r.inbound_name)
}
fn writable_labels_for(source: &Kind) -> Vec<&'static str> {
RELATION_RULES
.iter()
.filter(|r| r.link == LinkPolicy::Writable && r.sources.contains(&source.prefix))
.map(|r| r.label.name())
.collect()
}
fn owning_verb_for(rule: &RelationRule) -> &'static str {
match rule.link {
LinkPolicy::Writable => "link",
LinkPolicy::LifecycleOnly => "the transactional supersede verb (IMP-006)",
LinkPolicy::TypedVerbOnly => "the kind's typed verb (e.g. `spec req add`, `review …`)",
}
}
pub(crate) fn validate_link(
source_kind: &Kind,
label_str: &str,
role: Option<Role>,
) -> anyhow::Result<&'static RelationRule> {
let legal = || writable_labels_for(source_kind).join(", ");
let label = RelationLabel::from_name(label_str).ok_or_else(|| {
anyhow::anyhow!(
"`{label_str}` is not a relation label authorable by {} via `link`. Legal labels: {}",
source_kind.prefix,
legal()
)
})?;
anyhow::ensure!(
source_label_admitted(source_kind, label),
"{} may not author `{label_str}` (illegal for this source). Legal `link` labels: {}",
source_kind.prefix,
legal()
);
let roles_here: Vec<Role> = legal_roles(source_kind, label).collect();
let roleful = !roles_here.is_empty();
match (roleful, role) {
(true, None) => anyhow::bail!(
"`{label_str}` requires a role — author it with `--role <{}>`",
roles_here
.iter()
.map(|r| r.name())
.collect::<Vec<_>>()
.join("|")
),
(false, Some(r)) => anyhow::bail!(
"`{label_str}` does not take a role; remove `--role {}`",
r.name()
),
(true, Some(r)) => anyhow::ensure!(
roles_here.contains(&r),
"`{}` is not a legal role for {} `{label_str}` — legal roles: {}",
r.name(),
source_kind.prefix,
roles_here
.iter()
.map(|lr| lr.name())
.collect::<Vec<_>>()
.join(", ")
),
(false, None) => {}
}
let rule = lookup(source_kind, label, role).ok_or_else(|| {
anyhow::anyhow!(
"{} may not author `{label_str}` (illegal for this source). Legal `link` labels: {}",
source_kind.prefix,
legal()
)
})?;
anyhow::ensure!(
rule.link == LinkPolicy::Writable,
"`{label_str}` is not `link`-writable — author it through {}, not generic `link`",
owning_verb_for(rule)
);
Ok(rule)
}
pub(crate) fn check_target_kind(
rule: &RelationRule,
source_kind: &Kind,
target_prefix: &str,
) -> anyhow::Result<()> {
match rule.target {
TargetSpec::Kinds(set) => anyhow::ensure!(
set.contains(&target_prefix),
"`{}` target must be one of [{}], got a {target_prefix}",
rule.label.name(),
set.to_vec().join(", ")
),
TargetSpec::SameKind => anyhow::ensure!(
target_prefix == source_kind.prefix,
"`{}` target must be the same kind as the source ({}), got a {target_prefix}",
rule.label.name(),
source_kind.prefix
),
TargetSpec::AnyNumbered | TargetSpec::Unvalidated => {}
}
Ok(())
}
#[cfg(test)]
pub(crate) fn rels_block(source: &Kind, axes: &[(&str, &[&str])]) -> String {
let migrated = |label: RelationLabel| -> bool {
lookup(source, label, None)
.map(|r| r.tier == Tier::One)
.unwrap_or(false)
};
let mut typed = String::new();
let mut rows = String::new();
for (label, targets) in axes {
if let Some(role) = label
.strip_prefix("references(")
.and_then(|s| s.strip_suffix(')'))
{
for t in *targets {
rows.push_str(&format!(
"[[relation]]\nlabel = \"references\"\nrole = \"{role}\"\ntarget = \"{t}\"\n"
));
}
continue;
}
let is_migrated = RelationLabel::from_name(label)
.map(migrated)
.unwrap_or(false);
if is_migrated {
for t in *targets {
rows.push_str(&format!(
"[[relation]]\nlabel = \"{label}\"\ntarget = \"{t}\"\n"
));
}
} else {
let list = targets
.iter()
.map(|t| format!("\"{t}\""))
.collect::<Vec<_>>()
.join(", ");
typed.push_str(&format!("{label} = [{list}]\n"));
}
}
let typed_table = if typed.is_empty() {
String::new()
} else {
format!("[relationships]\n{typed}")
};
format!("{typed_table}{rows}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn label_name_is_stable() {
assert_eq!(RelationLabel::Supersedes.name(), "supersedes");
assert_eq!(RelationLabel::OwningSlice.name(), "owning_slice");
assert_eq!(RelationLabel::Drift.name(), "drift");
assert_eq!(RelationLabel::DecisionRef.name(), "decision_ref");
assert_eq!(RelationLabel::Supports.name(), "supports");
assert_eq!(RelationLabel::Disputes.name(), "disputes");
}
#[test]
fn edge_carries_label_and_target() {
let e = RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"PRD-010".to_string(),
);
assert_eq!(e.label, RelationLabel::References);
assert_eq!(e.target, "PRD-010");
}
#[test]
fn targets_for_role_filters_by_label_and_role() {
let edges = vec![
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"SPEC-018".into(),
),
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Concerns),
"RFC-003".into(),
),
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"PRD-010".into(),
),
RelationEdge::new(RelationLabel::Supersedes, "SL-009".into()),
];
assert_eq!(
targets_for_role(&edges, RelationLabel::References, Role::Implements),
vec!["SPEC-018".to_string(), "PRD-010".to_string()],
);
assert_eq!(
targets_for_role(&edges, RelationLabel::References, Role::Concerns),
vec!["RFC-003".to_string()],
);
assert!(
targets_for_role(&edges, RelationLabel::References, Role::ScopedFrom).is_empty(),
"an empty role bucket yields an empty Vec",
);
}
use crate::adr::ADR_KIND;
use crate::backlog::{CHORE_KIND, IDEA_KIND, IMPROVEMENT_KIND, ISSUE_KIND, RISK_KIND};
use crate::entity::Kind;
use crate::knowledge::ASSUMPTION_KIND;
use crate::policy::POLICY_KIND;
use crate::rec::REC_KIND;
use crate::requirement::REQUIREMENT_KIND;
use crate::review::REVIEW_KIND;
use crate::slice::SLICE_KIND;
use crate::spec::{PRODUCT_SPEC_KIND, TECH_SPEC_KIND};
use crate::standard::STANDARD_KIND;
fn distinct_labels_in_decl_order() -> Vec<RelationLabel> {
let mut seen: Vec<RelationLabel> = Vec::new();
for r in RELATION_RULES {
if !seen.contains(&r.label) {
seen.push(r.label);
}
}
seen
}
#[test]
fn enum_ord_matches_relation_rules_label_order() {
let from_table = distinct_labels_in_decl_order();
let mut sorted = from_table.clone();
sorted.sort();
assert_eq!(
from_table, sorted,
"RELATION_RULES distinct-label declaration order diverged from RelationLabel enum Ord"
);
}
#[test]
fn sources_match_shipped_accessors() {
let expected: &[(RelationLabel, &[&str])] = &[
(
RelationLabel::References,
&[
"SL", "RFC", "ISS", "IMP", "CHR", "RSK", "IDE", "ASM", "DEC", "QUE", "CON",
"EVD", "HYP",
],
),
(
RelationLabel::Supersedes,
&[
"SL", "ADR", "POL", "STD", "ASM", "DEC", "QUE", "CON", "EVD", "HYP",
],
),
(RelationLabel::DescendsFrom, &["SPEC"]),
(RelationLabel::Parent, &["PRD", "SPEC"]),
(RelationLabel::Members, &["PRD", "SPEC"]),
(RelationLabel::Interactions, &["SPEC"]),
(RelationLabel::Slices, &["ISS", "IMP", "CHR", "RSK", "IDE"]),
(
RelationLabel::Related,
&[
"ADR", "POL", "RFC", "SL", "STD", "ISS", "IMP", "CHR", "RSK", "IDE",
],
),
(RelationLabel::Reviews, &["RV"]),
(RelationLabel::OwningSlice, &["REC"]),
(RelationLabel::Drift, &["ISS", "IMP", "CHR", "RSK", "IDE"]),
(RelationLabel::DecisionRef, &["REC"]),
(RelationLabel::Shapes, RECORD),
(RelationLabel::Spawns, RECORD),
(RelationLabel::OriginatesFrom, &["REV"]),
(RelationLabel::Supports, &["EVD"]),
(RelationLabel::Disputes, &["EVD"]),
];
for (label, want_prefixes) in expected {
let mut got: Vec<&str> = RELATION_RULES
.iter()
.filter(|r| r.label == *label)
.flat_map(|r| r.sources.iter().copied())
.collect();
got.sort_unstable();
got.dedup();
let mut want: Vec<&str> = want_prefixes.to_vec();
want.sort_unstable();
want.dedup();
assert_eq!(
got, want,
"RELATION_RULES source set for {label:?} diverged from the shipped accessor"
);
}
}
#[test]
fn inbound_name_equals_name_except_the_three_inverted() {
for r in RELATION_RULES {
let differs = r.inbound_name != r.label.name();
let allowed_to_differ = matches!(
r.label,
RelationLabel::Supersedes
| RelationLabel::GovernedBy
| RelationLabel::Consumes
| RelationLabel::Contextualizes
| RelationLabel::Shapes
| RelationLabel::Spawns
| RelationLabel::OriginatesFrom
| RelationLabel::References
| RelationLabel::Supports
| RelationLabel::Disputes
);
if differs {
assert!(
allowed_to_differ,
"{:?} inbound_name {:?} differs from name() {:?} but is not an allowed inverted label",
r.label,
r.inbound_name,
r.label.name()
);
}
}
assert_eq!(
lookup(&SLICE_KIND, RelationLabel::Supersedes, None)
.unwrap()
.inbound_name,
"superseded by"
);
assert_eq!(
lookup(&SLICE_KIND, RelationLabel::GovernedBy, None)
.unwrap()
.inbound_name,
"governs"
);
assert_eq!(
lookup(&PRODUCT_SPEC_KIND, RelationLabel::Consumes, None)
.unwrap()
.inbound_name,
"consumed_by"
);
}
#[test]
fn no_rule_label_is_an_inverse_spelling() {
const INVERSE_SPELLINGS: &[&str] = &[
"superseded_by",
"governs",
"consumed_by",
"contextualized_by",
"supported_by",
"disputed_by",
];
for r in RELATION_RULES {
assert!(
!INVERSE_SPELLINGS.contains(&r.label.name()),
"{:?} round-trips to an inverse outbound spelling {:?} — inverses are derived, not authorable",
r.label,
r.label.name()
);
}
assert!(
RELATION_RULES
.iter()
.any(|r| r.inbound_name == "superseded by"),
"expected the supersedes rule to carry the inverted inbound text"
);
}
#[test]
fn every_variant_appears_in_the_table() {
const ALL: &[RelationLabel] = &[
RelationLabel::References,
RelationLabel::Supersedes,
RelationLabel::DescendsFrom,
RelationLabel::Parent,
RelationLabel::Members,
RelationLabel::Interactions,
RelationLabel::Contextualizes,
RelationLabel::Shapes,
RelationLabel::Spawns,
RelationLabel::GovernedBy,
RelationLabel::Consumes,
RelationLabel::Slices,
RelationLabel::Related,
RelationLabel::Reviews,
RelationLabel::OwningSlice,
RelationLabel::Drift,
RelationLabel::DecisionRef,
RelationLabel::Revises,
RelationLabel::OriginatesFrom,
RelationLabel::Supports,
RelationLabel::Disputes,
];
let mut sorted = ALL.to_vec();
sorted.sort();
assert_eq!(ALL, sorted.as_slice(), "ALL is not in enum Ord order");
assert_eq!(
distinct_labels_in_decl_order(),
ALL.to_vec(),
"RELATION_RULES does not cover exactly the RelationLabel variants in order"
);
}
#[test]
fn tier_partition_matches_design() {
let tier_one = [
RelationLabel::References,
RelationLabel::Supersedes,
RelationLabel::Contextualizes,
RelationLabel::Shapes,
RelationLabel::Spawns,
RelationLabel::GovernedBy,
RelationLabel::Consumes,
RelationLabel::Slices,
RelationLabel::Related,
RelationLabel::Drift,
RelationLabel::Supports,
RelationLabel::Disputes,
];
for r in RELATION_RULES {
let want = if tier_one.contains(&r.label) {
Tier::One
} else {
Tier::Typed
};
assert_eq!(
r.tier, want,
"{:?} tier diverged from the design storage-shape column",
r.label
);
}
}
#[test]
fn target_spec_matches_design() {
for r in RELATION_RULES {
match (r.label, r.sources) {
(RelationLabel::Related, s) => {
if s.iter().any(|k| *k == "ADR") {
assert!(
matches!(r.target, TargetSpec::SameKind),
"gov related → SameKind"
);
} else {
assert!(
matches!(r.target, TargetSpec::AnyNumbered),
"slice related → AnyNumbered"
);
}
}
(RelationLabel::Supersedes, s) if !s.iter().any(|k| *k == "SL") => {
if s.iter().any(|k| *k == "ADR") {
assert!(
matches!(r.target, TargetSpec::SameKind),
"gov supersedes → SameKind"
);
} else {
match r.target {
TargetSpec::Kinds(ks) => {
let got: Vec<&str> = ks.iter().copied().collect();
let want: Vec<&str> = RECORD.iter().copied().collect();
assert_eq!(got, want, "record supersedes → Kinds(RECORD)");
}
other => panic!(
"record supersedes → Kinds(RECORD), got {:?}",
std::mem::discriminant(&other)
),
}
}
}
(
RelationLabel::Drift
| RelationLabel::DecisionRef
| RelationLabel::Contextualizes,
_,
) => {
assert!(
matches!(r.target, TargetSpec::Unvalidated),
"{:?} → Unvalidated",
r.label
);
}
(RelationLabel::Reviews, _) => {
assert!(
matches!(r.target, TargetSpec::AnyNumbered),
"reviews → AnyNumbered"
);
}
(RelationLabel::References, _) => match r.role {
Some(Role::Implements) => match r.target {
TargetSpec::Kinds(ks) => {
let mut got: Vec<&str> = ks.iter().copied().collect();
got.sort_unstable();
assert_eq!(got, ["PRD", "REQ", "SPEC"], "implements → SPEC·PRD·REQ");
}
_ => panic!("references(implements) → Kinds(SPEC,PRD,REQ)"),
},
Some(Role::ScopedFrom) => match r.target {
TargetSpec::Kinds(ks) => {
let got: Vec<&str> = ks.iter().copied().collect();
let want: Vec<&str> = BACKLOG.iter().copied().collect();
assert_eq!(got, want, "scoped_from → Kinds(BACKLOG)");
}
_ => panic!("references(scoped_from) → Kinds(BACKLOG)"),
},
Some(Role::Concerns) => assert!(
matches!(r.target, TargetSpec::AnyNumbered),
"concerns → AnyNumbered"
),
None => panic!("a references row must carry a role"),
},
(_, _) => match r.target {
TargetSpec::Kinds(ks) => {
assert!(!ks.is_empty(), "{:?} → non-empty Kinds set", r.label)
}
other => panic!(
"{:?} expected an explicit Kinds target, got {}",
r.label,
match other {
TargetSpec::SameKind => "SameKind",
TargetSpec::AnyNumbered => "AnyNumbered",
TargetSpec::Unvalidated => "Unvalidated",
TargetSpec::Kinds(_) => unreachable!(),
}
),
},
}
}
if let TargetSpec::Kinds(ks) = lookup(&SLICE_KIND, RelationLabel::GovernedBy, None)
.unwrap()
.target
{
let mut got: Vec<&str> = ks.iter().copied().collect();
got.sort_unstable();
assert_eq!(got, ["ADR", "POL", "STD"]);
} else {
panic!("governed_by → Kinds([ADR,POL,STD])");
}
if let TargetSpec::Kinds(ks) = lookup(&ASSUMPTION_KIND, RelationLabel::Shapes, None)
.unwrap()
.target
{
let mut got: Vec<&str> = ks.iter().copied().collect();
got.sort_unstable();
assert_eq!(
got,
[
"ADR", "ASM", "CHR", "CON", "DEC", "EVD", "HYP", "IDE", "IMP", "ISS", "POL",
"PRD", "QUE", "REQ", "RFC", "RSK", "SL", "SPEC", "STD"
]
);
} else {
panic!(
"shapes → Kinds([PRD, SPEC, REQ, SLICE, ISS, IMP, CHR, RSK, IDE, ADR, POL, STD, ASM, DEC, QUE, CON])"
);
}
if let TargetSpec::Kinds(ks) = lookup(&ASSUMPTION_KIND, RelationLabel::Spawns, None)
.unwrap()
.target
{
let mut got: Vec<&str> = ks.iter().copied().collect();
got.sort_unstable();
assert_eq!(got, ["CHR", "IDE", "IMP", "ISS", "RSK"]);
} else {
panic!("spawns → Kinds([ISS, IMP, CHR, RSK, IDE])");
}
let r = lookup(&ASSUMPTION_KIND, RelationLabel::Supersedes, None)
.unwrap_or_else(|| panic!("RECORD Supersedes row not found for ASM"));
assert_eq!(
r.link,
LinkPolicy::LifecycleOnly,
"record supersedes → LifecycleOnly"
);
if let TargetSpec::Kinds(ks) = r.target {
let mut got: Vec<&str> = ks.iter().copied().collect();
got.sort_unstable();
assert_eq!(got, ["ASM", "CON", "DEC", "EVD", "HYP", "QUE"]);
} else {
panic!("record supersedes → Kinds(RECORD)");
}
}
fn edge_pairs(edges: &[RelationEdge]) -> Vec<(RelationLabel, &str)> {
edges.iter().map(|e| (e.label, e.target.as_str())).collect()
}
#[test]
fn read_block_rejects_illegal_source_label_pairs() {
let slice_doc = RelationDoc::parse(
"[[relation]]\nlabel = \"related\"\ntarget = \"SL-002\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"PRD-010\"\n",
)
.unwrap();
let (edges, illegal) = read_block(&SLICE_KIND, &slice_doc);
assert_eq!(
edge_pairs(&edges),
vec![
(RelationLabel::References, "PRD-010"),
(RelationLabel::Related, "SL-002"),
],
"the legal references and related rows emit edges"
);
assert!(illegal.is_empty(), "related is legal for a slice source");
let backlog_doc = RelationDoc::parse(
"[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-010\"\n\
[[relation]]\nlabel = \"slices\"\ntarget = \"SL-020\"\n\
[[relation]]\nlabel = \"related\"\ntarget = \"IMP-005\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"REQ-001\"\n",
)
.unwrap();
let (edges, illegal) = read_block(&ISSUE_KIND, &backlog_doc);
assert_eq!(
edge_pairs(&edges),
vec![
(RelationLabel::GovernedBy, "ADR-010"),
(RelationLabel::Slices, "SL-020"),
(RelationLabel::Related, "IMP-005"),
],
"governed_by, slices, and related all emit edges for a backlog source (SL-145)"
);
assert_eq!(
illegal,
vec![IllegalRow {
label: "references".to_string(),
target: "REQ-001".to_string(),
reason: IllegalReason::IllegalRole,
}],
"references(implements) is illegal for a backlog source (implements is SL-only)"
);
let bad_doc = RelationDoc::parse(
"[[relation]]\nlabel = \"superseded_by\"\ntarget = \"SL-001\"\n\
[[relation]]\nlabel = \"nonsense\"\ntarget = \"X\"\n",
)
.unwrap();
let (edges, illegal) = read_block(&SLICE_KIND, &bad_doc);
assert!(edges.is_empty(), "no legal edges from unknown labels");
assert_eq!(
illegal,
vec![
IllegalRow {
label: "superseded_by".to_string(),
target: "SL-001".to_string(),
reason: IllegalReason::UnknownLabel,
},
IllegalRow {
label: "nonsense".to_string(),
target: "X".to_string(),
reason: IllegalReason::UnknownLabel,
},
],
"an inverse spelling and a typo are both UnknownLabel findings, verbatim"
);
}
#[test]
fn read_block_emits_in_canonical_order_stable_within_label() {
let doc = RelationDoc::parse(
"[[relation]]\nlabel = \"supersedes\"\ntarget = \"SL-000\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"REQ-002\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"REQ-001\"\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"PRD-010\"\n",
)
.unwrap();
let (edges, illegal) = read_block(&SLICE_KIND, &doc);
assert!(illegal.is_empty(), "all rows are legal for a slice");
assert_eq!(
edge_pairs(&edges),
vec![
(RelationLabel::References, "REQ-002"),
(RelationLabel::References, "REQ-001"),
(RelationLabel::References, "PRD-010"),
(RelationLabel::Supersedes, "SL-000"),
],
"edges land in canonical table order; same-(label,role) rows keep authored order"
);
}
#[test]
fn read_block_empty_block_is_no_edges_no_findings() {
let doc = RelationDoc::parse("id = 1\ntitle = \"x\"\n").unwrap();
let (edges, illegal) = read_block(&SLICE_KIND, &doc);
assert!(edges.is_empty());
assert!(illegal.is_empty());
}
#[test]
fn append_relation_row_appends_and_preserves() {
let text = "# a comment\nid = 1\ntitle = \"x\"\n";
let (next, outcome) =
append_relation_row(text, RelationLabel::GovernedBy, None, "ADR-010").unwrap();
assert_eq!(outcome, AppendOutcome::Wrote);
assert!(next.contains("# a comment"), "comment preserved");
assert!(next.contains("[[relation]]"));
assert!(next.contains("label = \"governed_by\""));
assert!(next.contains("target = \"ADR-010\""));
assert!(
!next.contains("role ="),
"label-only row carries no role key"
);
let edges = tier1_edges(&SLICE_KIND, &next).unwrap();
assert_eq!(
edge_pairs(&edges),
vec![(RelationLabel::GovernedBy, "ADR-010")]
);
}
#[test]
fn append_relation_row_is_idempotent() {
let text = "id = 1\n";
let (once, o1) =
append_relation_row(text, RelationLabel::GovernedBy, None, "ADR-010").unwrap();
assert_eq!(o1, AppendOutcome::Wrote);
let (twice, o2) =
append_relation_row(&once, RelationLabel::GovernedBy, None, "ADR-010").unwrap();
assert_eq!(o2, AppendOutcome::Noop);
assert_eq!(once, twice, "a no-op append leaves the text byte-identical");
}
#[test]
fn append_relation_row_refuses_trailing_typed_table() {
let trap = "id = 1\n\
[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"PRD-010\"\n\
[relationships]\ntags = [\"x\"]\n";
let err =
append_relation_row(trap, RelationLabel::GovernedBy, None, "ADR-010").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("relationships") && msg.contains("AFTER"),
"refusal must name the offending trailing table: {msg}"
);
let (out, outcome) = append_relation_row(
trap,
RelationLabel::References,
Some(Role::Implements),
"PRD-010",
)
.unwrap();
assert_eq!(outcome, AppendOutcome::Noop);
assert_eq!(out, trap);
}
#[test]
fn append_relation_row_escapes_target() {
let text = "id = 1\n";
let (next, _) = append_relation_row(text, RelationLabel::Drift, None, "a\"b").unwrap();
let doc = RelationDoc::parse(&next).unwrap();
let (edges, _illegal) = read_block(&ISSUE_KIND, &doc);
assert_eq!(edge_pairs(&edges), vec![(RelationLabel::Drift, "a\"b")]);
}
#[test]
fn remove_relation_row_round_trips_and_is_idempotent() {
let (with, _) =
append_relation_row("id = 1\n", RelationLabel::GovernedBy, None, "ADR-010").unwrap();
let (without, o1) =
remove_relation_row(&with, RelationLabel::GovernedBy, None, "ADR-010").unwrap();
assert_eq!(o1, RemoveOutcome::Removed);
assert!(
tier1_edges(&SLICE_KIND, &without).unwrap().is_empty(),
"the edge is gone after remove"
);
let (again, o2) =
remove_relation_row(&without, RelationLabel::GovernedBy, None, "ADR-010").unwrap();
assert_eq!(o2, RemoveOutcome::Absent);
assert_eq!(without, again, "a second remove is a byte-identical no-op");
}
#[test]
fn references_role_round_trips_through_storage() {
let (with, outcome) = append_relation_row(
"id = 1\n",
RelationLabel::References,
Some(Role::Implements),
"SPEC-018",
)
.unwrap();
assert_eq!(outcome, AppendOutcome::Wrote);
assert!(with.contains("label = \"references\""));
assert!(with.contains("role = \"implements\""));
assert!(with.contains("target = \"SPEC-018\""));
let label_at = with.find("label =").unwrap();
let role_at = with.find("role =").unwrap();
let target_at = with.find("target =").unwrap();
assert!(
label_at < role_at && role_at < target_at,
"the row reads label / role / target on disk: {with}"
);
let edges = tier1_edges(&SLICE_KIND, &with).unwrap();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].label, RelationLabel::References);
assert_eq!(edges[0].role, Some(Role::Implements));
assert_eq!(edges[0].target, "SPEC-018");
let (miss, o_miss) = remove_relation_row(
&with,
RelationLabel::References,
Some(Role::Concerns),
"SPEC-018",
)
.unwrap();
assert_eq!(o_miss, RemoveOutcome::Absent, "a wrong role does not match");
assert_eq!(miss, with, "a no-op remove is byte-identical");
let (without, o_hit) = remove_relation_row(
&with,
RelationLabel::References,
Some(Role::Implements),
"SPEC-018",
)
.unwrap();
assert_eq!(o_hit, RemoveOutcome::Removed);
assert!(tier1_edges(&SLICE_KIND, &without).unwrap().is_empty());
let (gb, _) =
append_relation_row("id = 1\n", RelationLabel::GovernedBy, None, "ADR-010").unwrap();
assert!(
!gb.contains("role ="),
"a label-only row carries no role key: {gb}"
);
let gb_edges = tier1_edges(&SLICE_KIND, &gb).unwrap();
assert_eq!(
gb_edges[0].role, None,
"a label-only edge reads back role None"
);
}
#[test]
fn references_role_idempotency_keys_on_the_triple() {
let (once, o1) = append_relation_row(
"id = 1\n",
RelationLabel::References,
Some(Role::Concerns),
"SL-002",
)
.unwrap();
assert_eq!(o1, AppendOutcome::Wrote);
let (again, o2) = append_relation_row(
&once,
RelationLabel::References,
Some(Role::Concerns),
"SL-002",
)
.unwrap();
assert_eq!(o2, AppendOutcome::Noop);
assert_eq!(once, again, "re-link of the same triple is byte-identical");
let (two, o3) = append_relation_row(
&once,
RelationLabel::References,
Some(Role::Implements),
"SL-002",
)
.unwrap();
assert_eq!(
o3,
AppendOutcome::Wrote,
"a different role is a distinct edge"
);
let edges = tier1_edges(&SLICE_KIND, &two).unwrap();
let roles: Vec<Option<Role>> = edges
.iter()
.filter(|e| e.label == RelationLabel::References && e.target == "SL-002")
.map(|e| e.role)
.collect();
assert!(roles.contains(&Some(Role::Concerns)));
assert!(roles.contains(&Some(Role::Implements)));
}
#[test]
fn read_block_flags_bad_references_role() {
let bad = |kind: &Kind, toml: &str| -> Vec<IllegalReason> {
let doc = RelationDoc::parse(toml).unwrap();
let (_edges, illegal) = read_block(kind, &doc);
illegal.into_iter().map(|r| r.reason).collect()
};
assert_eq!(
bad(
&SLICE_KIND,
"[[relation]]\nlabel = \"references\"\ntarget = \"SPEC-018\"\n"
),
vec![IllegalReason::IllegalRole],
"a references row with no role is an IllegalRow"
);
assert_eq!(
bad(
&ISSUE_KIND,
"[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"SPEC-018\"\n"
),
vec![IllegalReason::IllegalRole],
"implements is SL-only — illegal for a backlog source"
);
assert_eq!(
bad(
&SLICE_KIND,
"[[relation]]\nlabel = \"references\"\nrole = \"nonsense\"\ntarget = \"SPEC-018\"\n"
),
vec![IllegalReason::IllegalRole],
"an unparseable role spelling is an IllegalRow"
);
assert_eq!(
bad(
&SLICE_KIND,
"[[relation]]\nlabel = \"governed_by\"\nrole = \"concerns\"\ntarget = \"ADR-010\"\n"
),
vec![IllegalReason::IllegalRole],
"a role on a label-only row is an IllegalRow"
);
let (edges, illegal) = read_block(
&SLICE_KIND,
&RelationDoc::parse(
"[[relation]]\nlabel = \"references\"\nrole = \"implements\"\ntarget = \"SPEC-018\"\n\
[[relation]]\nlabel = \"governed_by\"\ntarget = \"ADR-010\"\n",
)
.unwrap(),
);
assert!(illegal.is_empty(), "well-formed rows produce no findings");
assert_eq!(edges.len(), 2);
assert!(
edges
.iter()
.any(|e| e.label == RelationLabel::References && e.role == Some(Role::Implements))
);
assert!(
edges
.iter()
.any(|e| e.label == RelationLabel::GovernedBy && e.role.is_none())
);
}
#[test]
fn inbound_name_is_table_driven() {
assert_eq!(inbound_name(RelationLabel::GovernedBy, None), "governs");
assert_eq!(inbound_name(RelationLabel::Consumes, None), "consumed_by");
assert_eq!(
inbound_name(RelationLabel::Supersedes, None),
"superseded by"
);
assert_eq!(
inbound_name(RelationLabel::OriginatesFrom, None),
"precursor of"
);
assert_eq!(
inbound_name(RelationLabel::References, Some(Role::Implements)),
"implemented by"
);
assert_eq!(
inbound_name(RelationLabel::References, Some(Role::ScopedFrom)),
"scoped into"
);
assert_eq!(
inbound_name(RelationLabel::References, Some(Role::Concerns)),
"concerned by"
);
for label in distinct_labels_in_decl_order() {
let inverted = matches!(
label,
RelationLabel::GovernedBy
| RelationLabel::Consumes
| RelationLabel::Supersedes
| RelationLabel::Contextualizes
| RelationLabel::Shapes
| RelationLabel::Spawns
| RelationLabel::OriginatesFrom
| RelationLabel::References
| RelationLabel::Supports
| RelationLabel::Disputes
);
if !inverted {
assert_eq!(
inbound_name(label, None),
label.name(),
"{label:?} inbound render must equal its name()"
);
}
}
}
#[test]
fn validate_link_gates_source_label_and_policy() {
match validate_link(&SLICE_KIND, "governed_by", None) {
Ok(rule) => assert_eq!(rule.label, RelationLabel::GovernedBy),
Err(e) => panic!("governed_by should be writable for a slice: {e}"),
}
let refusal = |src: &Kind, label: &str| -> String {
match validate_link(src, label, None) {
Ok(_) => panic!("expected `{label}` to be refused for {}", src.prefix),
Err(e) => e.to_string(),
}
};
let e = refusal(&SLICE_KIND, "nonsense");
assert!(e.contains("governed_by"), "lists legal labels: {e}");
match validate_link(&SLICE_KIND, "related", None) {
Ok(rule) => assert_eq!(rule.label, RelationLabel::Related),
Err(e) => panic!("related should be writable for a slice (SL-095): {e}"),
}
match validate_link(&ISSUE_KIND, "governed_by", None) {
Ok(rule) => assert_eq!(rule.label, RelationLabel::GovernedBy),
Err(e) => panic!("governed_by should be writable for a backlog item (SL-145): {e}"),
}
match validate_link(&ISSUE_KIND, "related", None) {
Ok(rule) => assert_eq!(rule.label, RelationLabel::Related),
Err(e) => panic!("related should be writable for a backlog item (SL-145): {e}"),
}
let e = refusal(&ADR_KIND.kind, "supersedes");
assert!(e.contains("supersede verb"), "names the owning verb: {e}");
let e = refusal(&PRODUCT_SPEC_KIND, "members");
assert!(e.contains("typed verb"), "names the typed verb: {e}");
}
#[test]
fn check_target_kind_enforces_target_kind() {
let unwrap_rule = |r: anyhow::Result<&'static RelationRule>| -> &'static RelationRule {
match r {
Ok(rule) => rule,
Err(e) => panic!("expected a writable rule: {e}"),
}
};
let gov_by = unwrap_rule(validate_link(&SLICE_KIND, "governed_by", None));
assert!(check_target_kind(gov_by, &SLICE_KIND, "SL").is_err());
for p in ["ADR", "POL", "STD"] {
assert!(check_target_kind(gov_by, &SLICE_KIND, p).is_ok());
}
let related = unwrap_rule(validate_link(&ADR_KIND.kind, "related", None));
assert!(check_target_kind(related, &ADR_KIND.kind, "ADR").is_ok());
assert!(
check_target_kind(related, &ADR_KIND.kind, "POL").is_err(),
"SameKind refuses a cross-gov target"
);
let sl_related = unwrap_rule(validate_link(&SLICE_KIND, "related", None));
assert!(check_target_kind(sl_related, &SLICE_KIND, "ADR").is_ok());
assert!(check_target_kind(sl_related, &SLICE_KIND, "SPEC").is_ok());
assert!(check_target_kind(sl_related, &SLICE_KIND, "RV").is_ok());
let bk_gov = unwrap_rule(validate_link(&ISSUE_KIND, "governed_by", None));
assert!(
check_target_kind(bk_gov, &ISSUE_KIND, "SL").is_err(),
"backlog governed_by still refuses a non-GOV target"
);
for p in ["ADR", "POL", "STD"] {
assert!(check_target_kind(bk_gov, &ISSUE_KIND, p).is_ok());
}
let bk_related = unwrap_rule(validate_link(&ISSUE_KIND, "related", None));
assert!(check_target_kind(bk_related, &ISSUE_KIND, "SL").is_ok());
assert!(check_target_kind(bk_related, &ISSUE_KIND, "ADR").is_ok());
}
#[test]
fn revises_rule_is_typed_verb_only_with_authored_truth_targets() {
use crate::revision::REV_KIND;
let rule = lookup(&REV_KIND, RelationLabel::Revises, None).expect("revises rule for REV");
assert_eq!(rule.link, LinkPolicy::TypedVerbOnly);
assert_eq!(rule.tier, Tier::Typed);
assert_eq!(rule.inbound_name, "revises");
match validate_link(&REV_KIND, "revises", None) {
Ok(_) => panic!("`link … revises …` must be refused (TypedVerbOnly)"),
Err(e) => assert!(e.to_string().contains("typed verb"), "names the verb: {e}"),
}
for p in ["SPEC", "PRD", "REQ", "ADR", "POL", "STD"] {
assert!(
check_target_kind(rule, &REV_KIND, p).is_ok(),
"{p} is a legal revises target"
);
}
for p in ["SL", "ISS", "REC", "REV", "RV"] {
assert!(
check_target_kind(rule, &REV_KIND, p).is_err(),
"{p} is NOT a legal revises target (off-target)"
);
}
}
#[test]
fn lookup_keys_on_source_and_label() {
assert!(lookup(&ISSUE_KIND, RelationLabel::GovernedBy, None).is_some());
let sl_related = lookup(&SLICE_KIND, RelationLabel::Related, None);
assert!(sl_related.is_some());
assert!(matches!(
sl_related.unwrap().target,
TargetSpec::AnyNumbered
));
for k in [&SLICE_KIND, &PRODUCT_SPEC_KIND, &TECH_SPEC_KIND] {
assert!(lookup(k, RelationLabel::GovernedBy, None).is_some());
}
assert!(lookup(&PRODUCT_SPEC_KIND, RelationLabel::Consumes, None).is_some());
assert!(lookup(&TECH_SPEC_KIND, RelationLabel::Consumes, None).is_none());
let sl_sup = lookup(&SLICE_KIND, RelationLabel::Supersedes, None).unwrap();
assert_eq!(sl_sup.link, LinkPolicy::Writable);
let adr_sup = lookup(&ADR_KIND.kind, RelationLabel::Supersedes, None).unwrap();
assert_eq!(adr_sup.link, LinkPolicy::LifecycleOnly);
let _ = (
&REQUIREMENT_KIND,
&REVIEW_KIND,
&REC_KIND,
&STANDARD_KIND,
&POLICY_KIND,
&IMPROVEMENT_KIND,
&CHORE_KIND,
&RISK_KIND,
&IDEA_KIND,
);
assert!(lookup(&ASSUMPTION_KIND, RelationLabel::Shapes, None).is_some());
assert!(lookup(&SLICE_KIND, RelationLabel::Shapes, None).is_none());
assert!(lookup(&ASSUMPTION_KIND, RelationLabel::Spawns, None).is_some());
assert!(lookup(&SLICE_KIND, RelationLabel::Spawns, None).is_none());
let _: &Kind = &SLICE_KIND;
}
#[test]
fn role_name_round_trips_and_ord_is_declaration_order() {
for role in [Role::Implements, Role::ScopedFrom, Role::Concerns] {
assert_eq!(Role::from_name(role.name()), Some(role));
}
assert_eq!(Role::from_name("nonsense"), None);
let mut roles = [Role::Concerns, Role::Implements, Role::ScopedFrom];
roles.sort();
assert_eq!(roles, [Role::Implements, Role::ScopedFrom, Role::Concerns]);
}
#[test]
fn references_replaces_specs_requirements() {
let labels = distinct_labels_in_decl_order();
assert!(
labels.contains(&RelationLabel::References),
"References must be present in the table"
);
assert!(RelationLabel::from_name("specs").is_none());
assert!(RelationLabel::from_name("requirements").is_none());
let pos = |l: RelationLabel| labels.iter().position(|x| *x == l).unwrap();
assert!(pos(RelationLabel::References) < pos(RelationLabel::Supersedes));
}
#[test]
fn at_most_one_rule_per_source_label_role() {
use std::collections::HashSet;
let mut seen: HashSet<(&str, &str, Option<&str>)> = HashSet::new();
for r in RELATION_RULES {
let role_key = r.role.map(Role::name);
for src in r.sources {
assert!(
seen.insert((src, r.label.name(), role_key)),
"duplicate rule for ({src}, {}, {role_key:?})",
r.label.name()
);
}
}
}
#[test]
fn each_source_label_is_wholly_roleful_or_roleless() {
use std::collections::HashMap;
let mut seen: HashMap<(&str, &str), (bool, bool)> = HashMap::new();
for r in RELATION_RULES {
for src in r.sources {
let e = seen.entry((src, r.label.name())).or_insert((false, false));
if r.role.is_some() {
e.0 = true;
} else {
e.1 = true;
}
}
}
for ((src, label), (some, none)) in seen {
assert!(
!(some && none),
"({src}, {label}) mixes roleful and roleless rows"
);
}
assert!(
legal_roles(&SLICE_KIND, RelationLabel::References)
.next()
.is_some()
);
assert!(
legal_roles(&SLICE_KIND, RelationLabel::GovernedBy)
.next()
.is_none()
);
}
#[test]
fn legal_roles_reachability() {
let sl: Vec<Role> = legal_roles(&SLICE_KIND, RelationLabel::References).collect();
assert_eq!(
sl,
[Role::Implements, Role::ScopedFrom, Role::Concerns],
"a slice can author all three references roles, in declaration order"
);
let iss: Vec<Role> = legal_roles(&ISSUE_KIND, RelationLabel::References).collect();
assert_eq!(
iss,
[Role::Concerns],
"a backlog item authors only concerns (implements/scoped_from are SL-only)"
);
assert_eq!(
legal_roles(&SLICE_KIND, RelationLabel::GovernedBy).count(),
0
);
}
#[test]
fn lookup_is_role_keyed() {
let impl_rule = lookup(
&SLICE_KIND,
RelationLabel::References,
Some(Role::Implements),
)
.unwrap();
assert!(matches!(impl_rule.target, TargetSpec::Kinds(_)));
assert!(lookup(&SLICE_KIND, RelationLabel::References, None).is_none());
assert!(
lookup(
&SLICE_KIND,
RelationLabel::References,
Some(Role::ScopedFrom)
)
.is_some()
);
assert!(lookup(&SLICE_KIND, RelationLabel::References, Some(Role::Concerns)).is_some());
assert!(
lookup(
&ISSUE_KIND,
RelationLabel::References,
Some(Role::Implements)
)
.is_none()
);
assert!(lookup(&ISSUE_KIND, RelationLabel::References, Some(Role::Concerns)).is_some());
assert!(lookup(&SLICE_KIND, RelationLabel::GovernedBy, Some(Role::Concerns)).is_none());
assert!(lookup(&SLICE_KIND, RelationLabel::GovernedBy, None).is_some());
}
#[test]
fn validate_link_role_taxonomy() {
let refusal = |src: &Kind, label: &str, role: Option<Role>| -> String {
match validate_link(src, label, role) {
Ok(_) => panic!(
"expected `{label}` (role {role:?}) refused for {}",
src.prefix
),
Err(e) => e.to_string(),
}
};
let e = refusal(&SLICE_KIND, "references", None);
assert!(
e.contains("requires a role") && e.contains("implements"),
"MissingRole names the legal roles: {e}"
);
let e = refusal(&ISSUE_KIND, "references", Some(Role::ScopedFrom));
assert!(
e.contains("not a legal role") && e.contains("concerns"),
"IllegalRole lists the legal roles for the source: {e}"
);
let e = refusal(&SLICE_KIND, "governed_by", Some(Role::Concerns));
assert!(e.contains("does not take a role"), "RoleNotApplicable: {e}");
match validate_link(&SLICE_KIND, "references", Some(Role::Implements)) {
Ok(rule) => {
assert_eq!(rule.label, RelationLabel::References);
assert_eq!(rule.role, Some(Role::Implements));
}
Err(e) => panic!("references(implements) should validate for a slice: {e}"),
}
let unwrap_rule = |r: anyhow::Result<&'static RelationRule>| -> &'static RelationRule {
match r {
Ok(rule) => rule,
Err(e) => panic!("expected a writable rule: {e}"),
}
};
let impl_rule = unwrap_rule(validate_link(
&SLICE_KIND,
"references",
Some(Role::Implements),
));
assert!(check_target_kind(impl_rule, &SLICE_KIND, "IMP").is_err());
for p in ["SPEC", "PRD", "REQ"] {
assert!(check_target_kind(impl_rule, &SLICE_KIND, p).is_ok());
}
let conc_rule = unwrap_rule(validate_link(
&SLICE_KIND,
"references",
Some(Role::Concerns),
));
assert!(check_target_kind(conc_rule, &SLICE_KIND, "IMP").is_ok());
let scoped_rule = unwrap_rule(validate_link(
&SLICE_KIND,
"references",
Some(Role::ScopedFrom),
));
assert!(check_target_kind(scoped_rule, &SLICE_KIND, "IMP").is_ok());
assert!(
check_target_kind(scoped_rule, &SLICE_KIND, "SPEC").is_err(),
"scoped_from refuses a non-backlog target"
);
}
#[test]
fn record_authors_references_concerns() {
let rule = lookup(
&ASSUMPTION_KIND,
RelationLabel::References,
Some(Role::Concerns),
)
.expect("ASM must be able to author references(concerns)");
assert_eq!(rule.label, RelationLabel::References);
assert_eq!(rule.role, Some(Role::Concerns));
assert_eq!(rule.inbound_name, "concerned by");
assert!(
matches!(rule.target, TargetSpec::AnyNumbered),
"concerns target is AnyNumbered"
);
assert_eq!(rule.tier, Tier::One);
assert_eq!(rule.link, LinkPolicy::Writable);
let roles: Vec<Role> = legal_roles(&ASSUMPTION_KIND, RelationLabel::References).collect();
assert!(
roles.contains(&Role::Concerns),
"legal_roles for ASM must contain Concerns"
);
let doc = RelationDoc::parse(
"[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"SL-001\"\n",
)
.unwrap();
let (edges, illegal) = read_block(&ASSUMPTION_KIND, &doc);
assert_eq!(
edge_pairs(&edges),
vec![(RelationLabel::References, "SL-001")],
"record references(concerns) emits a legal edge"
);
assert!(illegal.is_empty(), "no illegal rows expected");
assert!(
lookup(
&ASSUMPTION_KIND,
RelationLabel::References,
Some(Role::Implements)
)
.is_none(),
"records cannot author implements"
);
assert!(
lookup(
&ASSUMPTION_KIND,
RelationLabel::References,
Some(Role::ScopedFrom)
)
.is_none(),
"records cannot author scoped_from"
);
}
}