///|
/// Render a concise Markdown report for reviews and release notes.
pub fn AnalysisReport::to_markdown(self : AnalysisReport) -> String {
let out = StringBuilder()
out.write_string("# EvoWitness compatibility report\n\n")
out.write_string(
"- Contract: `" + markdown_escape(self.contract_name) + "`\n",
)
out.write_string(
"- Versions: `" +
markdown_escape(self.old_version) +
"` → `" +
markdown_escape(self.new_version) +
"`\n",
)
out.write_string("- Mode: `" + self.mode.render() + "`\n")
out.write_string("- Verdict: **" + self.verdict().to_upper() + "**\n")
out.write_string(
"- Findings: " + self.breaking_count().to_string() + " breaking, ",
)
out.write_string(self.warning_count().to_string() + " warnings, ")
out.write_string(self.info_count().to_string() + " informational\n\n")
if self.changes.is_empty() {
out.write_string("No compatibility-relevant changes were detected.\n")
return out.to_string()
}
render_summary_section(
out,
"Summary by severity",
severity_summary(self.changes),
)
render_summary_section(
out,
"Summary by change family",
family_summary(self.changes),
)
out.write_string("| Severity | Direction | Code | Path | Finding |\n")
out.write_string("|---|---|---|---|---|\n")
for change in self.changes {
out.write_string("| " + change.severity.render() + " | ")
out.write_string(change.direction + " | `" + change.code + "` | `")
out.write_string(markdown_escape(change.path) + "` | ")
out.write_string(markdown_escape(change.message) + " |\n")
}
for change in self.changes {
guard change.witness is Some(witness) else { continue }
out.write_string(
"\n## " + change.code + " at `" + markdown_escape(change.path) + "`\n\n",
)
out.write_string(change.message + "\n\n")
out.write_string("Minimal counterexample:\n\n```json\n")
out.write_string(witness.payload + "\n```\n\n")
out.write_string("Accepted by `" + witness.accepted_by + "`, rejected by `")
out.write_string(witness.rejected_by + "`: " + witness.reason + ".\n\n")
out.write_string("Suggested migration: " + change.hint + "\n")
}
out.to_string()
}
///|
/// Render a stable JSON document for custom CI integrations.
pub fn AnalysisReport::to_json(self : AnalysisReport) -> String {
let findings = self.changes.map(change_to_json)
"{" +
"\"schemaVersion\":\"1.0\"," +
"\"contract\":\"" +
json_escape(self.contract_name) +
"\"," +
"\"oldVersion\":\"" +
json_escape(self.old_version) +
"\"," +
"\"newVersion\":\"" +
json_escape(self.new_version) +
"\"," +
"\"mode\":\"" +
self.mode.render() +
"\"," +
"\"verdict\":\"" +
self.verdict() +
"\"," +
"\"summary\":{" +
"\"breaking\":" +
self.breaking_count().to_string() +
"," +
"\"warning\":" +
self.warning_count().to_string() +
"," +
"\"info\":" +
self.info_count().to_string() +
"}," +
"\"changes\":[" +
findings.join(",") +
"]}"
}
///|
/// Render SARIF 2.1.0 so findings can be uploaded to code-scanning systems.
pub fn AnalysisReport::to_sarif(self : AnalysisReport) -> String {
let results = self.changes.map(change_to_sarif)
"{" +
"\"version\":\"2.1.0\"," +
"\"$schema\":\"https://json.schemastore.org/sarif-2.1.0.json\"," +
"\"runs\":[{" +
"\"tool\":{\"driver\":{" +
"\"name\":\"EvoWitness\"," +
"\"informationUri\":\"https://github.com/CJR-ai-nb/evowitness\"," +
"\"semanticVersion\":\"0.1.0\"}}," +
"\"properties\":{" +
"\"contract\":\"" +
json_escape(self.contract_name) +
"\"," +
"\"oldVersion\":\"" +
json_escape(self.old_version) +
"\"," +
"\"newVersion\":\"" +
json_escape(self.new_version) +
"\"," +
"\"mode\":\"" +
self.mode.render() +
"\"}," +
"\"results\":[" +
results.join(",") +
"]}]}"
}
///|
/// Render one parse error in a compiler-style single-line format.
pub fn ParseError::render(self : ParseError) -> String {
self.code +
" at " +
self.line.to_string() +
":" +
self.column.to_string() +
": " +
self.message
}
///|
fn change_to_json(change : Change) -> String {
let witness = match change.witness {
None => "null"
Some(value) =>
"{" +
"\"payload\":" +
json_string(value.payload) +
"," +
"\"acceptedBy\":" +
json_string(value.accepted_by) +
"," +
"\"rejectedBy\":" +
json_string(value.rejected_by) +
"," +
"\"reason\":" +
json_string(value.reason) +
"}"
}
"{" +
"\"code\":" +
json_string(change.code) +
"," +
"\"severity\":" +
json_string(change.severity.render()) +
"," +
"\"direction\":" +
json_string(change.direction) +
"," +
"\"path\":" +
json_string(change.path) +
"," +
"\"message\":" +
json_string(change.message) +
"," +
"\"hint\":" +
json_string(change.hint) +
"," +
"\"witness\":" +
witness +
"}"
}
///|
fn change_to_sarif(change : Change) -> String {
let level = match change.severity {
Breaking => "error"
Warning => "warning"
Info => "note"
}
let witness = match change.witness {
Some(value) => json_string(value.payload)
None => "null"
}
"{" +
"\"ruleId\":" +
json_string(change.code) +
"," +
"\"level\":" +
json_string(level) +
"," +
"\"message\":{\"text\":" +
json_string(change.message) +
"}," +
"\"logicalLocations\":[{" +
"\"fullyQualifiedName\":" +
json_string(change.path) +
"," +
"\"kind\":\"data contract\"}]," +
"\"properties\":{" +
"\"direction\":" +
json_string(change.direction) +
"," +
"\"migrationHint\":" +
json_string(change.hint) +
"," +
"\"witness\":" +
witness +
"}}"
}
///|
fn json_string(value : String) -> String {
"\"" + json_escape(value) + "\""
}
///|
fn markdown_escape(value : String) -> String {
value.replace_all(old="|", new="\\|").replace_all(old="\n", new=" ")
}
///|
fn render_summary_section(
out : StringBuilder,
title : String,
rows : Array[SummaryRow],
) -> Unit {
out.write_string("## " + title + "\n\n")
out.write_string("| Category | Breaking | Warning | Info | Total |\n")
out.write_string("|---|---|---|---|---|\n")
for row in rows {
out.write_string("| " + markdown_escape(row.label) + " | ")
out.write_string(row.breaking.to_string() + " | ")
out.write_string(row.warning.to_string() + " | ")
out.write_string(row.info.to_string() + " | ")
out.write_string(row.total.to_string() + " |\n")
}
out.write_string("\n")
}
///|
fn severity_summary(changes : Array[Change]) -> Array[SummaryRow] {
let rows : Array[SummaryRow] = []
for change in changes {
bump_summary_row(rows, change.severity.render(), change.severity)
}
sort_summary_rows(rows)
rows
}
///|
fn family_summary(changes : Array[Change]) -> Array[SummaryRow] {
let rows : Array[SummaryRow] = []
for change in changes {
bump_summary_row(rows, family_label(change.code), change.severity)
}
sort_summary_rows(rows)
rows
}
///|
fn bump_summary_row(
rows : Array[SummaryRow],
label : String,
severity : Severity,
) -> Unit {
match rows.search_by(row => row.label == label) {
Some(index) => {
let row = rows[index]
rows[index] = row.bump(severity)
}
None => rows.push(new_summary_row(label).bump(severity))
}
}
///|
fn sort_summary_rows(rows : Array[SummaryRow]) -> Unit {
rows.sort_by((left, right) => {
let total_order = right.total - left.total
if total_order != 0 {
total_order
} else if right.breaking != left.breaking {
right.breaking - left.breaking
} else if right.warning != left.warning {
right.warning - left.warning
} else if right.info != left.info {
right.info - left.info
} else {
left.label.compare(right.label)
}
})
}
///|
fn family_label(code : String) -> String {
if code.has_prefix("CONTRACT_") {
"contract"
} else if code.has_prefix("OBJECT_") {
"object"
} else if code.has_prefix("FIELD_") || code.has_suffix("_FIELD_ADDED") {
"field"
} else if code.has_prefix("ENUM_") {
"enum"
} else if code.has_prefix("TYPE_") {
"type"
} else if code.has_prefix("MIN_") ||
code.has_prefix("MAX_") ||
code == "CONSTRAINTS_CHANGED_COMPATIBLY" {
"constraint"
} else {
"other"
}
}
///|
fn new_summary_row(label : String) -> SummaryRow {
{ label, breaking: 0, warning: 0, info: 0, total: 0 }
}
///|
priv struct SummaryRow {
label : String
breaking : Int
warning : Int
info : Int
total : Int
}
///|
fn SummaryRow::bump(self : SummaryRow, severity : Severity) -> SummaryRow {
match severity {
Breaking =>
{
label: self.label,
breaking: self.breaking + 1,
warning: self.warning,
info: self.info,
total: self.total + 1,
}
Warning =>
{
label: self.label,
breaking: self.breaking,
warning: self.warning + 1,
info: self.info,
total: self.total + 1,
}
Info =>
{
label: self.label,
breaking: self.breaking,
warning: self.warning,
info: self.info + 1,
total: self.total + 1,
}
}
}