///|
/// Change classification for a single trace entity between two snapshots.
pub(all) enum ChangeKind {
Added
Removed
Modified
} derive(Eq, Debug)
///|
/// Impact is deliberately conservative. Removing a promised capability or a
/// required behavior is treated as breaking even when source compilation still
/// succeeds.
pub(all) enum ChangeImpact {
Breaking
Review
Informational
} derive(Eq, Debug)
///|
pub(all) struct EntityChange {
category : String
id : String
kind : ChangeKind
impact : ChangeImpact
before : String
after : String
} derive(Eq, Debug)
///|
pub(all) struct SummaryChange {
field : String
before : String
after : String
impact : ChangeImpact
} derive(Eq, Debug)
///|
pub(all) struct SpecDelta {
before_project : String
after_project : String
summary_changes : Array[SummaryChange]
capability_changes : Array[EntityChange]
requirement_changes : Array[EntityChange]
evidence_changes : Array[EntityChange]
} derive(Eq, Debug)
///|
pub fn ChangeKind::label(self : ChangeKind) -> String {
match self {
Added => "added"
Removed => "removed"
Modified => "modified"
}
}
///|
pub fn ChangeImpact::label(self : ChangeImpact) -> String {
match self {
Breaking => "breaking"
Review => "review"
Informational => "informational"
}
}
///|
/// Compare two parsed specifications by stable entity identifier. The result
/// contains only changes, so an empty delta is convenient for CI gates.
pub fn compare_specs(before : BoundarySpec, after : BoundarySpec) -> SpecDelta {
{
before_project: before.project,
after_project: after.project,
summary_changes: compare_summary(before, after),
capability_changes: compare_capabilities(
before.capabilities,
after.capabilities,
),
requirement_changes: compare_requirements(
before.requirements,
after.requirements,
),
evidence_changes: compare_evidence(before.evidence, after.evidence),
}
}
///|
pub fn compare_manifests(before : StringView, after : StringView) -> SpecDelta {
compare_specs(parse_manifest(before), parse_manifest(after))
}
///|
pub fn SpecDelta::is_empty(self : SpecDelta) -> Bool {
self.summary_changes.is_empty() &&
self.capability_changes.is_empty() &&
self.requirement_changes.is_empty() &&
self.evidence_changes.is_empty()
}
///|
pub fn SpecDelta::all_entity_changes(self : SpecDelta) -> Array[EntityChange] {
let changes : Array[EntityChange] = []
for change in self.capability_changes {
changes.push(change)
}
for change in self.requirement_changes {
changes.push(change)
}
for change in self.evidence_changes {
changes.push(change)
}
changes
}
///|
pub fn SpecDelta::breaking_changes(self : SpecDelta) -> Array[EntityChange] {
self.all_entity_changes().filter(change => change.impact == Breaking)
}
///|
pub fn SpecDelta::requires_review(self : SpecDelta) -> Bool {
!self.is_empty()
}
///|
pub fn SpecDelta::has_breaking_change(self : SpecDelta) -> Bool {
!self.breaking_changes().is_empty() ||
self.summary_changes.any(change => change.impact == Breaking)
}
///|
pub fn SpecDelta::change_count(self : SpecDelta) -> Int {
self.summary_changes.length() +
self.capability_changes.length() +
self.requirement_changes.length() +
self.evidence_changes.length()
}
///|
pub fn SpecDelta::to_markdown(self : SpecDelta) -> String {
let lines : Array[String] = []
lines.push("# CapsuleTrace Change Review")
lines.push("")
lines.push("- Before: " + printable(self.before_project))
lines.push("- After: " + printable(self.after_project))
lines.push("- Changes: " + self.change_count().to_string())
lines.push("- Breaking: " + self.breaking_changes().length().to_string())
lines.push("- Review required: " + bool_label(self.requires_review()))
lines.push("")
render_summary_change_table(lines, self.summary_changes)
render_entity_change_table(lines, "Capabilities", self.capability_changes)
render_entity_change_table(lines, "Requirements", self.requirement_changes)
render_entity_change_table(lines, "Evidence", self.evidence_changes)
lines.join("\n")
}
///|
pub fn SpecDelta::to_json_string(self : SpecDelta) -> String {
Json::object(
Map([
("before_project", Json::string(self.before_project)),
("after_project", Json::string(self.after_project)),
(
"change_count",
Json::number(
self.change_count().to_double(),
repr=self.change_count().to_string(),
),
),
("has_breaking_change", Json::boolean(self.has_breaking_change())),
("summary_changes", summary_changes_to_json(self.summary_changes)),
("capability_changes", entity_changes_to_json(self.capability_changes)),
("requirement_changes", entity_changes_to_json(self.requirement_changes)),
("evidence_changes", entity_changes_to_json(self.evidence_changes)),
]),
).stringify(indent=2)
}
///|
fn compare_summary(
before : BoundarySpec,
after : BoundarySpec,
) -> Array[SummaryChange] {
let changes : Array[SummaryChange] = []
add_summary_change(
changes,
"project",
before.project,
after.project,
if before.project != after.project {
Breaking
} else {
Informational
},
)
add_summary_change(changes, "purpose", before.purpose, after.purpose, Review)
add_summary_change(
changes,
"scope",
before.scopes.join(" | "),
after.scopes.join(" | "),
Review,
)
add_summary_change(
changes,
"out-of-scope",
before.out_of_scope.join(" | "),
after.out_of_scope.join(" | "),
Review,
)
changes
}
///|
fn compare_capabilities(
before : Array[Capability],
after : Array[Capability],
) -> Array[EntityChange] {
let changes : Array[EntityChange] = []
for item in before {
match find_capability(after, item.id) {
Some(current) =>
if item != current {
changes.push({
category: "capability",
id: item.id,
kind: Modified,
impact: capability_impact(item, current),
before: capability_to_manifest_line(item),
after: capability_to_manifest_line(current),
})
}
None =>
changes.push({
category: "capability",
id: item.id,
kind: Removed,
impact: Breaking,
before: capability_to_manifest_line(item),
after: "",
})
}
}
for item in after {
if find_capability(before, item.id) is None {
changes.push({
category: "capability",
id: item.id,
kind: Added,
impact: Informational,
before: "",
after: capability_to_manifest_line(item),
})
}
}
sort_entity_changes(changes)
}
///|
fn compare_requirements(
before : Array[Requirement],
after : Array[Requirement],
) -> Array[EntityChange] {
let changes : Array[EntityChange] = []
for item in before {
match find_requirement(after, item.id) {
Some(current) =>
if item != current {
changes.push({
category: "requirement",
id: item.id,
kind: Modified,
impact: requirement_impact(item, current),
before: requirement_to_manifest_line(item),
after: requirement_to_manifest_line(current),
})
}
None =>
changes.push({
category: "requirement",
id: item.id,
kind: Removed,
impact: if item.level == Must {
Breaking
} else {
Review
},
before: requirement_to_manifest_line(item),
after: "",
})
}
}
for item in after {
if find_requirement(before, item.id) is None {
changes.push({
category: "requirement",
id: item.id,
kind: Added,
impact: Informational,
before: "",
after: requirement_to_manifest_line(item),
})
}
}
sort_entity_changes(changes)
}
///|
fn compare_evidence(
before : Array[Evidence],
after : Array[Evidence],
) -> Array[EntityChange] {
let changes : Array[EntityChange] = []
for item in before {
match find_evidence(after, item.id) {
Some(current) =>
if item != current {
changes.push({
category: "evidence",
id: item.id,
kind: Modified,
impact: evidence_impact(item, current),
before: evidence_to_manifest_line(item),
after: evidence_to_manifest_line(current),
})
}
None =>
changes.push({
category: "evidence",
id: item.id,
kind: Removed,
impact: if item.status == Verified {
Review
} else {
Informational
},
before: evidence_to_manifest_line(item),
after: "",
})
}
}
for item in after {
if find_evidence(before, item.id) is None {
changes.push({
category: "evidence",
id: item.id,
kind: Added,
impact: Informational,
before: "",
after: evidence_to_manifest_line(item),
})
}
}
sort_entity_changes(changes)
}
///|
fn capability_impact(before : Capability, after : Capability) -> ChangeImpact {
if before.inputs != after.inputs || before.outputs != after.outputs {
Breaking
} else {
Review
}
}
///|
fn requirement_impact(
before : Requirement,
after : Requirement,
) -> ChangeImpact {
if before.level == Must && after.level != Must {
Breaking
} else if before.capabilities != after.capabilities {
Breaking
} else {
Review
}
}
///|
fn evidence_impact(before : Evidence, after : Evidence) -> ChangeImpact {
if before.status == Verified && after.status != Verified {
Breaking
} else {
Review
}
}
///|
fn find_capability(items : Array[Capability], id : String) -> Capability? {
items.iter().find_first(item => item.id == id)
}
///|
fn sort_entity_changes(values : Array[EntityChange]) -> Array[EntityChange] {
let mut sorted : Array[EntityChange] = []
for value in values {
let next : Array[EntityChange] = []
let mut inserted = false
for existing in sorted {
if !inserted && value.id.lexical_compare(existing.id) < 0 {
next.push(value)
inserted = true
}
next.push(existing)
}
if !inserted {
next.push(value)
}
sorted = next
}
sorted
}
///|
fn find_requirement(items : Array[Requirement], id : String) -> Requirement? {
items.iter().find_first(item => item.id == id)
}
///|
fn find_evidence(items : Array[Evidence], id : String) -> Evidence? {
items.iter().find_first(item => item.id == id)
}
///|
fn add_summary_change(
changes : Array[SummaryChange],
field : String,
before : String,
after : String,
impact : ChangeImpact,
) -> Unit {
if before != after {
changes.push({ field, before, after, impact })
}
}
///|
fn render_summary_change_table(
lines : Array[String],
changes : Array[SummaryChange],
) -> Unit {
lines.push("## Summary")
lines.push("")
lines.push("| Field | Impact | Before | After |")
lines.push("| --- | --- | --- | --- |")
if changes.is_empty() {
lines.push("| info | informational | unchanged | unchanged |")
} else {
for change in changes {
lines.push(
"| " +
markdown_cell(change.field) +
" | " +
change.impact.label() +
" | " +
markdown_cell(change.before) +
" | " +
markdown_cell(change.after) +
" |",
)
}
}
lines.push("")
}
///|
fn render_entity_change_table(
lines : Array[String],
title : String,
changes : Array[EntityChange],
) -> Unit {
lines.push("## " + title)
lines.push("")
lines.push("| ID | Change | Impact | Before | After |")
lines.push("| --- | --- | --- | --- | --- |")
if changes.is_empty() {
lines.push("| - | unchanged | informational | - | - |")
} else {
for change in changes {
lines.push(
"| " +
markdown_cell(change.id) +
" | " +
change.kind.label() +
" | " +
change.impact.label() +
" | " +
markdown_cell(change.before) +
" | " +
markdown_cell(change.after) +
" |",
)
}
}
lines.push("")
}
///|
fn summary_changes_to_json(items : Array[SummaryChange]) -> Json {
let values : Array[Json] = []
for item in items {
values.push(
Json::object(
Map([
("field", Json::string(item.field)),
("before", Json::string(item.before)),
("after", Json::string(item.after)),
("impact", Json::string(item.impact.label())),
]),
),
)
}
Json::array(values)
}
///|
fn entity_changes_to_json(items : Array[EntityChange]) -> Json {
let values : Array[Json] = []
for item in items {
values.push(
Json::object(
Map([
("category", Json::string(item.category)),
("id", Json::string(item.id)),
("kind", Json::string(item.kind.label())),
("impact", Json::string(item.impact.label())),
("before", Json::string(item.before)),
("after", Json::string(item.after)),
]),
),
)
}
Json::array(values)
}