#![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,
Related,
Fulfils,
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::Related => "related",
RelationLabel::Fulfils => "fulfils",
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,
"related" => RelationLabel::Related,
"fulfils" => RelationLabel::Fulfils,
"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,
OriginatesFrom,
Concerns,
}
impl Role {
pub(crate) const fn name(self) -> &'static str {
match self {
Role::Implements => "implements",
Role::OriginatesFrom => "originates_from",
Role::Concerns => "concerns",
}
}
pub(crate) fn from_name(name: &str) -> Option<Role> {
let role = match name {
"implements" => Role::Implements,
"originates_from" => Role::OriginatesFrom,
"concerns" => Role::Concerns,
_ => return None,
};
debug_assert_eq!(role.name(), name);
Some(role)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
pub(crate) enum Degree {
Full,
Partial,
}
impl Degree {
pub(crate) const fn name(self) -> &'static str {
match self {
Degree::Full => "full",
Degree::Partial => "partial",
}
}
pub(crate) fn from_name(name: &str) -> Option<Degree> {
let degree = match name {
"full" => Degree::Full,
"partial" => Degree::Partial,
_ => return None,
};
debug_assert_eq!(degree.name(), name);
Some(degree)
}
}
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) degree_bearing: bool,
}
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,
degree_bearing: false,
},
RelationRule {
sources: &[SL, ISS, IMP, CHR, RSK, IDE],
label: RelationLabel::References,
role: Some(Role::OriginatesFrom),
inbound_name: "originated from",
target: TargetSpec::Kinds(&[ISS, IMP, CHR, RSK, IDE, SL]),
tier: Tier::One,
link: LinkPolicy::Writable,
degree_bearing: false,
},
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,
degree_bearing: false,
},
RelationRule {
sources: &[SL],
label: RelationLabel::Supersedes,
role: None,
inbound_name: "superseded by",
target: TargetSpec::Kinds(&[SL]),
tier: Tier::One,
link: LinkPolicy::Writable,
degree_bearing: false,
},
RelationRule {
sources: GOV,
label: RelationLabel::Supersedes,
role: None,
inbound_name: "superseded by",
target: TargetSpec::SameKind,
tier: Tier::Typed,
link: LinkPolicy::LifecycleOnly,
degree_bearing: false,
},
RelationRule {
sources: RECORD,
label: RelationLabel::Supersedes,
role: None,
inbound_name: "superseded by",
target: TargetSpec::Kinds(RECORD),
tier: Tier::One,
link: LinkPolicy::LifecycleOnly,
degree_bearing: false,
},
RelationRule {
sources: &[SPEC],
label: RelationLabel::DescendsFrom,
role: None,
inbound_name: "descends_from",
target: TargetSpec::Kinds(&[PRD]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
degree_bearing: false,
},
RelationRule {
sources: &[SPEC, PRD],
label: RelationLabel::Parent,
role: None,
inbound_name: "parent",
target: TargetSpec::Kinds(&[SPEC, PRD]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
degree_bearing: false,
},
RelationRule {
sources: &[PRD, SPEC],
label: RelationLabel::Members,
role: None,
inbound_name: "members",
target: TargetSpec::Kinds(&[REQ]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
degree_bearing: false,
},
RelationRule {
sources: &[SPEC],
label: RelationLabel::Interactions,
role: None,
inbound_name: "interactions",
target: TargetSpec::Kinds(&[SPEC]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
degree_bearing: false,
},
RelationRule {
sources: &[CM],
label: RelationLabel::Contextualizes,
role: None,
inbound_name: "contextualized_by",
target: TargetSpec::Unvalidated,
tier: Tier::One,
link: LinkPolicy::Writable,
degree_bearing: false,
},
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,
degree_bearing: false,
},
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,
degree_bearing: false,
},
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,
degree_bearing: false,
},
RelationRule {
sources: &[PRD],
label: RelationLabel::Consumes,
role: None,
inbound_name: "consumed_by",
target: TargetSpec::Kinds(&[PRD]),
tier: Tier::One,
link: LinkPolicy::Writable,
degree_bearing: false,
},
RelationRule {
sources: GOV,
label: RelationLabel::Related,
role: None,
inbound_name: "related",
target: TargetSpec::SameKind,
tier: Tier::One,
link: LinkPolicy::Writable,
degree_bearing: false,
},
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,
degree_bearing: false,
},
RelationRule {
sources: &[SL],
label: RelationLabel::Fulfils,
role: None,
inbound_name: "fulfilled by",
target: TargetSpec::Kinds(BACKLOG),
tier: Tier::One,
link: LinkPolicy::Writable,
degree_bearing: true,
},
RelationRule {
sources: &[RV],
label: RelationLabel::Reviews,
role: None,
inbound_name: "reviews",
target: TargetSpec::AnyNumbered,
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
degree_bearing: false,
},
RelationRule {
sources: &[REC],
label: RelationLabel::OwningSlice,
role: None,
inbound_name: "owning_slice",
target: TargetSpec::Kinds(&[SL]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
degree_bearing: false,
},
RelationRule {
sources: BACKLOG,
label: RelationLabel::Drift,
role: None,
inbound_name: "drift",
target: TargetSpec::Unvalidated,
tier: Tier::One,
link: LinkPolicy::Writable,
degree_bearing: false,
},
RelationRule {
sources: &[REC],
label: RelationLabel::DecisionRef,
role: None,
inbound_name: "decision_ref",
target: TargetSpec::Unvalidated,
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
degree_bearing: false,
},
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,
degree_bearing: false,
},
RelationRule {
sources: &[REV],
label: RelationLabel::OriginatesFrom,
role: None,
inbound_name: "precursor of",
target: TargetSpec::Kinds(&[RFC]),
tier: Tier::Typed,
link: LinkPolicy::TypedVerbOnly,
degree_bearing: false,
},
RelationRule {
sources: &[EVD],
label: RelationLabel::Supports,
role: None,
inbound_name: "supported_by",
target: TargetSpec::Kinds(RECORD),
tier: Tier::One,
link: LinkPolicy::Writable,
degree_bearing: false,
},
RelationRule {
sources: &[EVD],
label: RelationLabel::Disputes,
role: None,
inbound_name: "disputed_by",
target: TargetSpec::Kinds(RECORD),
tier: Tier::One,
link: LinkPolicy::Writable,
degree_bearing: false,
},
];
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)]
pub(crate) struct RelationEdge {
pub(crate) label: RelationLabel,
pub(crate) role: Option<Role>,
pub(crate) target: String,
pub(crate) degree: Option<Degree>,
}
impl PartialEq for RelationEdge {
fn eq(&self, other: &Self) -> bool {
self.label == other.label && self.role == other.role && self.target == other.target
}
}
impl Eq for RelationEdge {}
impl RelationEdge {
pub(crate) fn new(label: RelationLabel, target: String) -> Self {
Self {
label,
role: None,
target,
degree: None,
}
}
pub(crate) fn with_role(label: RelationLabel, role: Option<Role>, target: String) -> Self {
Self {
label,
role,
target,
degree: None,
}
}
pub(crate) fn with_degree(
label: RelationLabel,
role: Option<Role>,
degree: Option<Degree>,
target: String,
) -> Self {
Self {
label,
role,
target,
degree,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum IllegalReason {
UnknownLabel,
IllegalForSource,
IllegalRole,
IllegalDegree,
DuplicateEdge,
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
degree: 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;
};
let degree = match &row.degree {
None => None,
Some(spelling) => {
let Some(parsed) = Degree::from_name(spelling) else {
illegal.push(illegal_row(IllegalReason::IllegalDegree));
continue;
};
Some(parsed)
}
};
legal.push((
pos,
RelationEdge::with_degree(label, role, degree, row.target.clone()),
));
}
legal.sort_by_key(|(pos, _)| *pos);
{
let mut seen: Vec<(RelationLabel, Option<Role>, &str)> = Vec::with_capacity(legal.len());
let mut dupe_indices: Vec<usize> = Vec::new();
for (i, (_, e)) in legal.iter().enumerate() {
let key = (e.label, e.role, e.target.as_str());
if seen.contains(&key) {
dupe_indices.push(i);
} else {
seen.push(key);
}
}
for &i in dupe_indices.iter().rev() {
let (_pos, edge) = legal.remove(i);
illegal.push(IllegalRow {
label: edge.label.name().to_string(),
target: edge.target.clone(),
reason: IllegalReason::DuplicateEdge,
});
}
}
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>,
degree: Option<Degree>,
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 let Some(existing) = relation_row_find(&doc, label, role, target) {
let existing_degree = existing
.get("degree")
.and_then(toml_edit::Item::as_str)
.and_then(Degree::from_name);
if existing_degree == degree {
return Ok((text.to_string(), AppendOutcome::Noop));
}
anyhow::bail!(
"already {} {} with degree={}; unlink to change",
label.name(),
target,
existing_degree.map_or("full", |d| d.name()),
);
}
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()));
}
if let Some(d) = degree {
row.insert("degree", toml_edit::value(d.name()));
}
row.insert("target", toml_edit::value(target));
let mut rows: Vec<toml_edit::Table> = array.iter().cloned().collect();
rows.push(row);
rows.sort_by(|a, b| {
let la = a.get("label").and_then(|v| v.as_str()).unwrap_or("");
let lb = b.get("label").and_then(|v| v.as_str()).unwrap_or("");
la.cmp(lb)
});
for i in (0..array.len()).rev() {
array.remove(i);
}
for r in rows {
array.push(r);
}
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_find<'a>(
doc: &'a toml_edit::DocumentMut,
label: RelationLabel,
role: Option<Role>,
target: &str,
) -> Option<&'a toml_edit::Table> {
doc.as_table()
.get("relation")
.and_then(toml_edit::Item::as_array_of_tables)
.and_then(|array| {
array
.iter()
.find(|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>,
degree: Option<Degree>,
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, degree, 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>,
degree: Option<Degree>,
) -> 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)
);
if degree.is_some() && !rule.degree_bearing {
anyhow::bail!("`{label_str}` does not take a degree; remove `--degree`");
}
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::OriginatesFrom).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::Related,
&[
"ADR", "POL", "RFC", "SL", "STD", "ISS", "IMP", "CHR", "RSK", "IDE",
],
),
(RelationLabel::Fulfils, &["SL"]),
(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
| RelationLabel::Fulfils
);
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::Related,
RelationLabel::Fulfils,
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::Related,
RelationLabel::Fulfils,
RelationLabel::Drift,
RelationLabel::Supports,
RelationLabel::Disputes,
];
for r in RELATION_RULES {
let want = if tier_one.contains(&r.label) {
if r.label == RelationLabel::Supersedes && r.sources == GOV {
Tier::Typed
} else {
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::OriginatesFrom) => match r.target {
TargetSpec::Kinds(ks) => {
let mut got: Vec<&str> = ks.iter().copied().collect();
got.sort_unstable();
assert_eq!(
got,
["CHR", "IDE", "IMP", "ISS", "RSK", "SL"],
"originates_from → widened Kinds"
);
}
_ => panic!("references(originates_from) → widened Kinds"),
},
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();
let mut want: Vec<&str> = RECORD.to_vec();
want.sort_unstable();
assert_eq!(got, want, "Supersedes target must equal RECORD");
} 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 = \"references\"\nrole = \"originates_from\"\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::References, "SL-020"),
(RelationLabel::GovernedBy, "ADR-010"),
(RelationLabel::Related, "IMP-005"),
],
"references(originates_from), governed_by, and related all emit edges for a backlog source (SL-176)"
);
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, 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, None, "ADR-010").unwrap();
assert_eq!(o1, AppendOutcome::Wrote);
let (twice, o2) =
append_relation_row(&once, RelationLabel::GovernedBy, None, 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, 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),
None,
"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, 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 append_relation_row_preserves_label_contiguity() {
let text = "id = 1\n";
let (text, _) =
append_relation_row(text, RelationLabel::GovernedBy, None, None, "ADR-001").unwrap();
let (text, _) =
append_relation_row(&text, RelationLabel::Related, None, None, "SL-099").unwrap();
let (text, _) =
append_relation_row(&text, RelationLabel::GovernedBy, None, None, "ADR-002").unwrap();
let edges = tier1_edges(&SLICE_KIND, &text).unwrap();
let pairs = edge_pairs(&edges);
assert_eq!(
pairs,
vec![
(RelationLabel::GovernedBy, "ADR-001"),
(RelationLabel::GovernedBy, "ADR-002"),
(RelationLabel::Related, "SL-099"),
],
"same-label rows must be contiguous (ISS-058)"
);
}
#[test]
fn remove_relation_row_round_trips_and_is_idempotent() {
let (with, _) =
append_relation_row("id = 1\n", RelationLabel::GovernedBy, None, 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),
None,
"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, 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),
None,
"SL-002",
)
.unwrap();
assert_eq!(o1, AppendOutcome::Wrote);
let (again, o2) = append_relation_row(
&once,
RelationLabel::References,
Some(Role::Concerns),
None,
"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),
None,
"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::OriginatesFrom)),
"originated from"
);
assert_eq!(
inbound_name(RelationLabel::References, Some(Role::Concerns)),
"concerned by"
);
assert_eq!(inbound_name(RelationLabel::Fulfils, None), "fulfilled 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
| RelationLabel::Fulfils
);
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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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::OriginatesFrom, 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::OriginatesFrom];
roles.sort();
assert_eq!(
roles,
[Role::Implements, Role::OriginatesFrom, 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::OriginatesFrom, 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::OriginatesFrom, Role::Concerns],
"a backlog item authors originates_from + concerns (implements is 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::OriginatesFrom)
)
.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::OriginatesFrom)
)
.is_some()
);
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, None) {
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}"
);
match validate_link(&ISSUE_KIND, "references", Some(Role::OriginatesFrom), None) {
Ok(rule) => {
assert_eq!(rule.label, RelationLabel::References);
assert_eq!(rule.role, Some(Role::OriginatesFrom));
}
Err(e) => panic!("backlog item should now author references(originates_from): {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), None) {
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),
None,
));
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),
None,
));
assert!(check_target_kind(conc_rule, &SLICE_KIND, "IMP").is_ok());
let scoped_rule = unwrap_rule(validate_link(
&SLICE_KIND,
"references",
Some(Role::OriginatesFrom),
None,
));
assert!(check_target_kind(scoped_rule, &SLICE_KIND, "IMP").is_ok());
assert!(
check_target_kind(scoped_rule, &SLICE_KIND, "SL").is_ok(),
"originates_from now accepts an SL target (widened)"
);
assert!(
check_target_kind(scoped_rule, &SLICE_KIND, "SPEC").is_err(),
"originates_from still refuses a SPEC 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::OriginatesFrom)
)
.is_none(),
"records cannot author originates_from"
);
}
#[test]
fn degree_storage_round_trip() {
let (text, outcome) = append_relation_row(
"id = 1\n",
RelationLabel::Fulfils,
None,
Some(Degree::Partial),
"IMP-001",
)
.unwrap();
assert_eq!(outcome, AppendOutcome::Wrote);
assert!(text.contains("label = \"fulfils\""));
assert!(text.contains("degree = \"partial\""));
assert!(text.contains("target = \"IMP-001\""));
let edges = tier1_edges(&SLICE_KIND, &text).unwrap();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].label, RelationLabel::Fulfils);
assert_eq!(edges[0].degree, Some(Degree::Partial));
assert_eq!(edges[0].target, "IMP-001");
let (text_full, _) =
append_relation_row("id = 1\n", RelationLabel::Fulfils, None, None, "IMP-002").unwrap();
assert!(
!text_full.contains("degree ="),
"a degree-absent edge serialises with no degree key: {text_full}"
);
let edges_full = tier1_edges(&SLICE_KIND, &text_full).unwrap();
assert_eq!(edges_full[0].degree, None, "absent degree ≡ None (Full)");
let (text_deg, _) = append_relation_row(
"id = 1\n",
RelationLabel::GovernedBy,
None,
Some(Degree::Full),
"ADR-010",
)
.unwrap();
assert!(
text_deg.contains("degree = \"full\""),
"a governed_by with explicit degree=full serialises the cell"
);
}
#[test]
fn duplicate_edge_flagged_at_read_block() {
let doc = RelationDoc::parse(
"[[relation]]\nlabel = \"fulfils\"\ntarget = \"IMP-001\"\n\
[[relation]]\nlabel = \"fulfils\"\ndegree = \"partial\"\ntarget = \"IMP-001\"\n",
)
.unwrap();
let (edges, illegal) = read_block(&SLICE_KIND, &doc);
assert_eq!(edges.len(), 1, "only one edge survives");
assert_eq!(edges[0].label, RelationLabel::Fulfils);
assert_eq!(edges[0].target, "IMP-001");
assert_eq!(illegal.len(), 1, "one duplicate finding");
assert_eq!(illegal[0].reason, IllegalReason::DuplicateEdge);
assert_eq!(illegal[0].label, "fulfils");
assert_eq!(illegal[0].target, "IMP-001");
}
#[test]
fn append_no_upsert_and_unlink_ignores_degree() {
let (once, o1) = append_relation_row(
"id = 1\n",
RelationLabel::Fulfils,
None,
Some(Degree::Partial),
"IMP-001",
)
.unwrap();
assert_eq!(o1, AppendOutcome::Wrote);
let (twice, o2) = append_relation_row(
&once,
RelationLabel::Fulfils,
None,
Some(Degree::Partial),
"IMP-001",
)
.unwrap();
assert_eq!(o2, AppendOutcome::Noop);
assert_eq!(once, twice, "identical append is byte-identical");
let err = append_relation_row(
&once,
RelationLabel::Fulfils,
None,
None, "IMP-001",
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("already") && msg.contains("IMP-001") && msg.contains("partial"),
"degree conflict error names the existing degree: {msg}"
);
let (removed, o_rm) =
remove_relation_row(&once, RelationLabel::Fulfils, None, "IMP-001").unwrap();
assert_eq!(o_rm, RemoveOutcome::Removed);
assert!(
tier1_edges(&SLICE_KIND, &removed).unwrap().is_empty(),
"the edge is gone after unlink"
);
}
#[test]
fn degree_not_applicable_on_non_degree_bearing_label() {
match validate_link(&SLICE_KIND, "fulfils", None, Some(Degree::Partial)) {
Ok(rule) => {
assert!(rule.degree_bearing);
assert_eq!(rule.label, RelationLabel::Fulfils);
}
Err(e) => panic!("fulfils should accept a degree: {e}"),
}
let err = match validate_link(&SLICE_KIND, "governed_by", None, Some(Degree::Partial)) {
Ok(_) => panic!("governed_by with degree should be refused"),
Err(e) => e.to_string(),
};
assert!(
err.contains("does not take a degree"),
"DegreeNotApplicable: {err}"
);
match validate_link(&SLICE_KIND, "fulfils", None, None) {
Ok(rule) => assert_eq!(rule.label, RelationLabel::Fulfils),
Err(e) => panic!("fulfils without degree should be legal: {e}"),
}
let rule = match validate_link(&SLICE_KIND, "references", Some(Role::OriginatesFrom), None)
{
Ok(rule) => rule,
Err(e) => panic!("originates_from should validate: {e}"),
};
assert!(
check_target_kind(rule, &SLICE_KIND, "SL").is_ok(),
"originates_from now accepts SL target (widened)"
);
assert!(
check_target_kind(rule, &SLICE_KIND, "ISS").is_ok(),
"originates_from accepts backlog target"
);
assert!(
check_target_kind(rule, &SLICE_KIND, "SPEC").is_err(),
"originates_from still refuses SPEC target"
);
match validate_link(&ISSUE_KIND, "references", Some(Role::OriginatesFrom), None) {
Ok(rule) => {
assert_eq!(rule.label, RelationLabel::References);
assert_eq!(rule.role, Some(Role::OriginatesFrom));
}
Err(e) => panic!("backlog should be able to author originates_from: {e}"),
}
}
#[test]
fn unknown_degree_is_illegal_degree_finding() {
let doc = RelationDoc::parse(
"[[relation]]\nlabel = \"fulfils\"\ndegree = \"half\"\ntarget = \"IMP-001\"\n",
)
.unwrap();
let (_edges, illegal) = read_block(&SLICE_KIND, &doc);
assert_eq!(illegal.len(), 1, "expected one illegal finding");
assert_eq!(illegal[0].reason, IllegalReason::IllegalDegree);
}
#[derive(serde::Deserialize)]
struct DispositionEdge {
class: u8,
from_source: String,
from_label: String,
from_target: String,
from_role: Option<String>,
to_source: String,
to_label: String,
to_target: String,
to_role: Option<String>,
degree: Option<String>,
}
#[derive(serde::Deserialize)]
struct DispositionDoc {
#[serde(rename = "edge")]
edges: Vec<DispositionEdge>,
}
fn load_dispositions() -> Vec<DispositionEdge> {
let path = crate::test_support::repo_root()
.join(".doctrine/slice/176/migration-dispositions.toml");
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let doc: DispositionDoc =
toml::from_str(&text).unwrap_or_else(|e| panic!("parse disposition record: {e}"));
assert!(!doc.edges.is_empty(), "disposition record is empty");
doc.edges
}
fn split_ref(r: &str) -> (&str, u32) {
let (prefix, num) = r
.rsplit_once('-')
.unwrap_or_else(|| panic!("`{r}` is not a canonical entity ref"));
(
prefix,
num.parse()
.unwrap_or_else(|_| panic!("`{r}` has no numeric id")),
)
}
#[test]
fn sl176_migration_dispositions_applied_to_corpus() {
let root = crate::test_support::repo_root();
let mut checked = 0usize;
let mut dep_seq_carved = 0usize;
for e in load_dispositions() {
let Some(want_label) = RelationLabel::from_name(&e.to_label) else {
assert!(
matches!(e.to_label.as_str(), "needs" | "after"),
"unexpected non-relation-layer to_label `{}` in disposition row",
e.to_label
);
dep_seq_carved += 1;
continue;
};
let want_role = e.to_role.as_deref().and_then(Role::from_name);
let want_degree = e.degree.as_deref().and_then(Degree::from_name);
let (prefix, id) = split_ref(&e.to_source);
let kind = crate::integrity::kind_by_prefix(prefix)
.unwrap_or_else(|| panic!("unknown kind prefix `{prefix}`"))
.kind;
let edges = crate::catalog::scan::outbound_for(&root, kind, id)
.unwrap_or_else(|err| panic!("read outbound for {}: {err}", e.to_source));
let present = edges.iter().any(|x| {
x.label == want_label
&& x.role == want_role
&& x.target == e.to_target
&& (want_label != RelationLabel::Fulfils
|| x.degree.unwrap_or(Degree::Full) == want_degree.unwrap_or(Degree::Full))
});
assert!(
present,
"VT-1 faithfulness: corpus is missing migrated edge {} --{}{}--> {} (degree {:?})",
e.to_source,
e.to_label,
want_role
.map(|r| format!("({})", r.name()))
.unwrap_or_default(),
e.to_target,
want_degree,
);
checked += 1;
}
assert_eq!(checked, 103, "expected 103 relation-layer migrated edges");
assert_eq!(
dep_seq_carved, 1,
"expected exactly one dep-seq (needs/after) row"
);
}
#[test]
fn sl176_migration_class_aware_multiset() {
for e in load_dispositions() {
match e.class {
1 | 2 => {
assert_eq!(
e.from_source, e.to_source,
"class {}: source preserved",
e.class
);
assert_eq!(
e.from_target, e.to_target,
"class {}: target preserved",
e.class
);
assert_eq!(e.to_label, "references");
assert_eq!(e.to_role.as_deref(), Some("originates_from"));
if e.class == 1 {
assert_eq!(e.from_label, "references", "class 1: in-place wire rename");
assert_eq!(e.from_role.as_deref(), Some("scoped_from"));
} else {
assert!(
matches!(e.from_label.as_str(), "slices" | "references"),
"class 2: provenance from `slices` or retcon `references`, got `{}`",
e.from_label
);
}
}
3 => {
assert_eq!(e.from_source, e.to_target, "class 3: source↔target flipped");
assert_eq!(e.from_target, e.to_source, "class 3: source↔target flipped");
assert_eq!(e.from_label, "slices");
assert_eq!(e.to_label, "fulfils");
assert!(e.degree.is_some(), "class 3: fulfils carries a degree");
}
4 => {
assert_eq!(e.from_label, "drift");
assert_eq!(e.from_source, e.to_source, "class 4: source preserved");
assert!(
e.from_target.contains(&e.to_target),
"class 4: carved target `{}` named in free-text `{}`",
e.to_target,
e.from_target
);
assert_eq!(e.to_label, "references");
assert_eq!(e.to_role.as_deref(), Some("originates_from"));
}
5 => {
assert_eq!(e.from_label, "drift");
assert_eq!(e.from_source, e.to_source, "class 5: source preserved");
assert!(
e.from_target.contains(&e.to_target),
"class 5: carved target `{}` named in free-text `{}`",
e.to_target,
e.from_target
);
assert!(
matches!(e.to_label.as_str(), "needs" | "after"),
"class 5: label moved to dep-seq (needs/after), got `{}`",
e.to_label
);
assert!(
RelationLabel::from_name(&e.to_label).is_none(),
"class 5: dep-seq label is not a relation-layer RelationLabel"
);
}
other => panic!("unexpected disposition class {other}"),
}
}
}
}