///|
/// Historical quality snapshots are append-only release evidence.
pub(all) struct QualityHistory {
schema_version : Int
snapshots : Array[QualitySnapshot]
} derive(Eq)
///|
/// Directional changes between the first and latest recorded snapshots.
pub(all) struct TrendReport {
sample_count : Int
first_version : String
latest_version : String
source_file_delta : Int
test_file_delta : Int
mutation_score_delta : Int
coverage_delta : Int
warning_delta : Int
direction : String
} derive(Eq)
///|
/// Create an empty, versioned history document from an optional seed list.
pub fn make_history(snapshots : Array[QualitySnapshot]) -> QualityHistory {
let copied : Array[QualitySnapshot] = []
for snapshot in snapshots {
copied.push(snapshot)
}
{ schema_version: 1, snapshots: copied }
}
///|
/// Append a snapshot without mutating the caller's history value.
pub fn append_history(
history : QualityHistory,
snapshot : QualitySnapshot,
) -> QualityHistory {
let snapshots : Array[QualitySnapshot] = []
for item in history.snapshots {
snapshots.push(item)
}
snapshots.push(snapshot)
{ schema_version: history.schema_version, snapshots }
}
///|
/// Summarize changes from the first sample to the latest sample.
pub fn summarize_history(history : QualityHistory) -> TrendReport {
if history.snapshots.length() == 0 {
return {
sample_count: 0,
first_version: "",
latest_version: "",
source_file_delta: 0,
test_file_delta: 0,
mutation_score_delta: 0,
coverage_delta: 0,
warning_delta: 0,
direction: "empty",
}
}
let first = history.snapshots[0]
let latest = history.snapshots[history.snapshots.length() - 1]
let source_delta = latest.source_files - first.source_files
let test_delta = latest.test_files - first.test_files
let mutation_delta = latest.mutation_score - first.mutation_score
let coverage_delta = latest.coverage_total_percentage -
first.coverage_total_percentage
let warning_delta = latest.warning_count - first.warning_count
{
sample_count: history.snapshots.length(),
first_version: first.version,
latest_version: latest.version,
source_file_delta: source_delta,
test_file_delta: test_delta,
mutation_score_delta: mutation_delta,
coverage_delta,
warning_delta,
direction: trend_direction(
source_delta, test_delta, mutation_delta, coverage_delta, warning_delta,
),
}
}
///|
/// Render a concise trend suitable for CI logs and release notes.
pub fn render_history(report : TrendReport) -> String {
let out = StringBuilder()
out.write_string("MoonSeal quality history: " + report.direction + "\n")
out.write_string("samples: " + report.sample_count.to_string() + "\n")
out.write_string(
"versions: " + report.first_version + " -> " + report.latest_version + "\n",
)
out.write_string("source-files: " + signed(report.source_file_delta) + "\n")
out.write_string("test-files: " + signed(report.test_file_delta) + "\n")
out.write_string(
"mutation-score: " + signed(report.mutation_score_delta) + "\n",
)
out.write_string("coverage: " + signed(report.coverage_delta) + "\n")
out.write_string("warnings: " + signed(report.warning_delta) + "\n")
out.to_string()
}
///|
/// Parse an append-only history document.
pub fn parse_history(input : String) -> Result[QualityHistory, String] {
try {
match @json.parse(input) {
Object(map) => {
let schema_version = match required_int(map, "schema_version") {
Ok(value) => value
Err(message) =>
return Err(message.replace(old="baseline", new="history"))
}
if schema_version != 1 {
return Err(
"unsupported history schema_version: " + schema_version.to_string(),
)
}
let snapshots = match map.get("snapshots") {
Some(Array(items)) => {
let result : Array[QualitySnapshot] = []
for item in items {
match item {
Object(snapshot_map) =>
match parse_snapshot_object(snapshot_map) {
Ok(snapshot) => result.push(snapshot)
Err(message) => return Err(message)
}
_ => return Err("history snapshots must contain objects")
}
}
result
}
_ => return Err("history field is missing or not an array: snapshots")
}
Ok({ schema_version, snapshots })
}
_ => Err("history must be a JSON object")
}
} catch {
_ => Err("history is not valid JSON")
}
}
///|
/// Read a history file created by `write_history`.
pub fn read_history(path : String) -> Result[QualityHistory, String] {
if !fs_exists(path) {
return Err("history file not found: " + path)
}
parse_history(fs_read(path))
}
///|
/// Write a stable, human-readable history archive.
pub fn write_history(
path : String,
history : QualityHistory,
) -> Result[Unit, String] {
write_text(path, history.to_json().stringify(indent=2) + "\n")
}
///|
fn trend_direction(
source_delta : Int,
test_delta : Int,
mutation_delta : Int,
coverage_delta : Int,
warning_delta : Int,
) -> String {
if source_delta == 0 &&
test_delta == 0 &&
mutation_delta == 0 &&
coverage_delta == 0 &&
warning_delta == 0 {
return "steady"
}
let quality_positive = test_delta > 0 ||
mutation_delta > 0 ||
coverage_delta > 0
let quality_negative = test_delta < 0 ||
mutation_delta < 0 ||
coverage_delta < 0
if quality_positive && (quality_negative || warning_delta > 0) {
"mixed"
} else if quality_negative || warning_delta > 0 {
"declining"
} else {
"improving"
}
}
///|
fn signed(value : Int) -> String {
if value > 0 {
"+" + value.to_string()
} else {
value.to_string()
}
}
///|
pub impl ToJson for QualityHistory with fn to_json(self : QualityHistory) -> Json {
let snapshots = Array::new()
for snapshot in self.snapshots {
snapshots.push(snapshot.to_json())
}
Json::object({
"schema_version": Json::number(self.schema_version.to_double()),
"snapshots": Json::array(snapshots),
})
}
///|
pub impl ToJson for TrendReport with fn to_json(self : TrendReport) -> Json {
Json::object({
"sample_count": Json::number(self.sample_count.to_double()),
"first_version": Json::string(self.first_version),
"latest_version": Json::string(self.latest_version),
"source_file_delta": Json::number(self.source_file_delta.to_double()),
"test_file_delta": Json::number(self.test_file_delta.to_double()),
"mutation_score_delta": Json::number(self.mutation_score_delta.to_double()),
"coverage_delta": Json::number(self.coverage_delta.to_double()),
"warning_delta": Json::number(self.warning_delta.to_double()),
"direction": Json::string(self.direction),
})
}