///|
/// A coverage state is derived from all evidence linked to a requirement.
/// Verified evidence wins, because a requirement may retain historical failed
/// evidence alongside a newer passing test or release artifact.
pub(all) enum CoverageState {
VerifiedCoverage
PendingCoverage
RejectedCoverage
MissingCoverage
} derive(Eq, Debug)
///|
pub(all) struct TraceRow {
requirement_id : String
level : RequirementLevel
text : String
capability_ids : Array[String]
evidence_ids : Array[String]
verified_evidence_ids : Array[String]
state : CoverageState
} derive(Eq, Debug)
///|
pub(all) struct EvidenceSummary {
kind : EvidenceKind
total : Int
verified : Int
pending : Int
rejected : Int
} derive(Eq, Debug)
///|
pub(all) struct TraceMatrix {
project : String
rows : Array[TraceRow]
evidence_summaries : Array[EvidenceSummary]
} derive(Eq, Debug)
///|
pub fn CoverageState::label(self : CoverageState) -> String {
match self {
VerifiedCoverage => "verified"
PendingCoverage => "pending"
RejectedCoverage => "rejected"
MissingCoverage => "missing"
}
}
///|
pub fn CoverageState::is_satisfied(self : CoverageState) -> Bool {
self == VerifiedCoverage
}
///|
/// Build a requirement-to-evidence matrix that can be rendered independently of
/// the full validator report. It is useful when a maintainer wants to see which
/// promises lack evidence without scanning all findings.
pub fn build_trace_matrix(spec : BoundarySpec) -> TraceMatrix {
let rows : Array[TraceRow] = []
for requirement in spec.requirements {
let linked = spec.evidence_for_requirement(requirement.id)
let evidence_ids : Array[String] = []
let verified_ids : Array[String] = []
for item in linked {
evidence_ids.push(item.id)
if item.status == Verified {
verified_ids.push(item.id)
}
}
let evidence_ids = sort_strings_ascending(evidence_ids)
let verified_ids = sort_strings_ascending(verified_ids)
rows.push({
requirement_id: requirement.id,
level: requirement.level,
text: requirement.text,
capability_ids: requirement.capabilities.copy(),
evidence_ids,
verified_evidence_ids: verified_ids,
state: coverage_state(linked),
})
}
{
project: spec.project,
rows: sort_trace_rows(rows),
evidence_summaries: build_evidence_summaries(spec),
}
}
///|
pub fn TraceMatrix::row(
self : TraceMatrix,
requirement_id : StringView,
) -> TraceRow? {
let expected = requirement_id.trim().to_owned()
self.rows.iter().find_first(row => row.requirement_id == expected)
}
///|
pub fn TraceMatrix::rows_for_capability(
self : TraceMatrix,
capability_id : StringView,
) -> Array[TraceRow] {
let expected = capability_id.trim().to_owned()
self.rows.filter(row => row.capability_ids.any(id => id == expected))
}
///|
pub fn TraceMatrix::rows_in_state(
self : TraceMatrix,
state : CoverageState,
) -> Array[TraceRow] {
self.rows.filter(row => row.state == state)
}
///|
pub fn TraceMatrix::covered_requirements(self : TraceMatrix) -> Int {
self.rows.count_if(row => row.state.is_satisfied())
}
///|
pub fn TraceMatrix::coverage_percent(self : TraceMatrix) -> Int {
if self.rows.is_empty() {
0
} else {
self.covered_requirements() * 100 / self.rows.length()
}
}
///|
pub fn TraceMatrix::must_requirements_ready(self : TraceMatrix) -> Bool {
!self.rows.any(row => row.level == Must && !row.state.is_satisfied())
}
///|
pub fn TraceMatrix::outstanding_rows(self : TraceMatrix) -> Array[TraceRow] {
self.rows.filter(row => !row.state.is_satisfied())
}
///|
pub fn TraceMatrix::summary(self : TraceMatrix) -> String {
"coverage=" +
self.covered_requirements().to_string() +
"/" +
self.rows.length().to_string() +
" (" +
self.coverage_percent().to_string() +
"%), must-ready=" +
bool_label(self.must_requirements_ready())
}
///|
pub fn TraceMatrix::to_markdown(self : TraceMatrix) -> String {
let lines : Array[String] = []
lines.push("# CapsuleTrace Evidence Matrix")
lines.push("")
lines.push("- Project: " + printable(self.project))
lines.push("- " + self.summary())
lines.push("")
lines.push("## Requirements")
lines.push("")
lines.push(
"| Requirement | Level | Capabilities | Evidence | Verified | State |",
)
lines.push("| --- | --- | --- | --- | --- | --- |")
if self.rows.is_empty() {
lines.push("| - | - | - | - | - | missing |")
} else {
for row in self.rows {
lines.push(
"| " +
markdown_cell(row.requirement_id) +
" | " +
row.level.label() +
" | " +
markdown_cell(row.capability_ids.join(", ")) +
" | " +
markdown_cell(row.evidence_ids.join(", ")) +
" | " +
markdown_cell(row.verified_evidence_ids.join(", ")) +
" | " +
row.state.label() +
" |",
)
}
}
lines.push("")
lines.push("## Evidence Inventory")
lines.push("")
lines.push("| Kind | Total | Verified | Pending | Rejected |")
lines.push("| --- | --- | --- | --- | --- |")
for summary in self.evidence_summaries {
lines.push(
"| " +
summary.kind.label() +
" | " +
summary.total.to_string() +
" | " +
summary.verified.to_string() +
" | " +
summary.pending.to_string() +
" | " +
summary.rejected.to_string() +
" |",
)
}
lines.join("\n")
}
///|
pub fn TraceMatrix::to_json_string(self : TraceMatrix) -> String {
Json::object(
Map([
("project", Json::string(self.project)),
(
"covered_requirements",
Json::number(
self.covered_requirements().to_double(),
repr=self.covered_requirements().to_string(),
),
),
(
"total_requirements",
Json::number(
self.rows.length().to_double(),
repr=self.rows.length().to_string(),
),
),
(
"coverage_percent",
Json::number(
self.coverage_percent().to_double(),
repr=self.coverage_percent().to_string(),
),
),
("must_requirements_ready", Json::boolean(self.must_requirements_ready())),
("rows", trace_rows_to_json(self.rows)),
(
"evidence_summaries",
evidence_summaries_to_json(self.evidence_summaries),
),
]),
).stringify(indent=2)
}
///|
pub fn EvidenceSummary::is_healthy(self : EvidenceSummary) -> Bool {
self.total > 0 && self.rejected == 0
}
///|
pub fn TraceMatrix::summary_for_kind(
self : TraceMatrix,
kind : EvidenceKind,
) -> EvidenceSummary? {
self.evidence_summaries.iter().find_first(summary => summary.kind == kind)
}
///|
fn coverage_state(items : Array[Evidence]) -> CoverageState {
let mut has_pending = false
let mut has_rejected = false
for item in items {
match item.status {
Verified => return VerifiedCoverage
Pending => has_pending = true
Rejected => has_rejected = true
}
}
if has_rejected {
RejectedCoverage
} else if has_pending {
PendingCoverage
} else {
MissingCoverage
}
}
///|
fn sort_trace_rows(values : Array[TraceRow]) -> Array[TraceRow] {
let mut sorted : Array[TraceRow] = []
for value in values {
let next : Array[TraceRow] = []
let mut inserted = false
for existing in sorted {
if !inserted &&
value.requirement_id.lexical_compare(existing.requirement_id) < 0 {
next.push(value)
inserted = true
}
next.push(existing)
}
if !inserted {
next.push(value)
}
sorted = next
}
sorted
}
///|
fn build_evidence_summaries(spec : BoundarySpec) -> Array[EvidenceSummary] {
[
evidence_summary_for(spec.evidence, Test),
evidence_summary_for(spec.evidence, Example),
evidence_summary_for(spec.evidence, CI),
evidence_summary_for(spec.evidence, Doc),
evidence_summary_for(spec.evidence, Release),
evidence_summary_for(spec.evidence, Design),
]
}
///|
fn evidence_summary_for(
evidence : Array[Evidence],
kind : EvidenceKind,
) -> EvidenceSummary {
let mut total = 0
let mut verified = 0
let mut pending = 0
let mut rejected = 0
for item in evidence {
if item.kind == kind {
total = total + 1
match item.status {
Verified => verified = verified + 1
Pending => pending = pending + 1
Rejected => rejected = rejected + 1
}
}
}
{ kind, total, verified, pending, rejected }
}
///|
fn trace_rows_to_json(items : Array[TraceRow]) -> Json {
let values : Array[Json] = []
for item in items {
values.push(
Json::object(
Map([
("requirement_id", Json::string(item.requirement_id)),
("level", Json::string(item.level.label())),
("text", Json::string(item.text)),
("capability_ids", strings_to_json(item.capability_ids)),
("evidence_ids", strings_to_json(item.evidence_ids)),
("verified_evidence_ids", strings_to_json(item.verified_evidence_ids)),
("state", Json::string(item.state.label())),
]),
),
)
}
Json::array(values)
}
///|
fn evidence_summaries_to_json(items : Array[EvidenceSummary]) -> Json {
let values : Array[Json] = []
for item in items {
values.push(
Json::object(
Map([
("kind", Json::string(item.kind.label())),
(
"total",
Json::number(item.total.to_double(), repr=item.total.to_string()),
),
(
"verified",
Json::number(
item.verified.to_double(),
repr=item.verified.to_string(),
),
),
(
"pending",
Json::number(
item.pending.to_double(),
repr=item.pending.to_string(),
),
),
(
"rejected",
Json::number(
item.rejected.to_double(),
repr=item.rejected.to_string(),
),
),
]),
),
)
}
Json::array(values)
}