///|
/// Backward checks the old schema against the new schema; forward checks the
/// reverse direction; full requires both directions to pass.
pub(all) enum CompatibilityPolicy {
Backward
Forward
Full
} derive(Debug, Eq)
///|
/// Direction in which a schema pair was compared.
pub(all) enum CompatibilityDirection {
BackwardDirection
ForwardDirection
} derive(Debug, Eq)
///|
/// A schema-evolution finding with its source file and comparison direction.
pub(all) struct CompatibilityFinding {
source : String
direction : CompatibilityDirection
change : SchemaChange
} derive(Debug, Eq)
///|
pub(all) enum CompatibilityFormat {
Text
JsonFormat
Markdown
GithubAnnotations
} derive(Debug, Eq)
///|
pub fn CompatibilityPolicy::name(self : CompatibilityPolicy) -> String {
match self {
Backward => "backward"
Forward => "forward"
Full => "full"
}
}
///|
pub fn CompatibilityDirection::name(self : CompatibilityDirection) -> String {
match self {
BackwardDirection => "backward"
ForwardDirection => "forward"
}
}
///|
fn ChangeKind::name(self : ChangeKind) -> String {
match self {
Compatible => "compatible"
Warning => "warning"
Breaking => "breaking"
}
}
///|
/// Applies an existing directional schema comparison in the selected policy.
/// Findings keep their original MTHC codes and are ordered backward first,
/// then forward. Suppression codes are exact matches (for example MTHC105).
pub fn compare_schemas_with_policy(
before : Schema,
after : Schema,
policy : CompatibilityPolicy,
source? : String = "",
suppressed? : Array[String] = [],
) -> Array[CompatibilityFinding] {
let findings : Array[CompatibilityFinding] = []
if policy is Backward || policy is Full {
for item in compare_schemas(before, after) {
if !suppressed.contains(item.code) {
findings.push({ source, direction: BackwardDirection, change: item, })
}
}
}
if policy is Forward || policy is Full {
for item in compare_schemas(after, before) {
if !suppressed.contains(item.code) {
findings.push({ source, direction: ForwardDirection, change: item, })
}
}
}
findings
}
///|
pub fn has_blocking_compatibility_findings(
findings : ArrayView[CompatibilityFinding],
) -> Bool {
findings.any(item => item.change.kind is Breaking)
}
///|
fn json_quote(value : String) -> String {
Json::string(value).stringify()
}
///|
fn markdown_cell(value : String) -> String {
value
.replace_all(old="|", new="\\|")
.replace_all(old="\r", new=" ")
.replace_all(old="\n", new=" ")
}
///|
fn annotation_escape(value : String) -> String {
value
.replace_all(old="%", new="%25")
.replace_all(old="\r", new="%0D")
.replace_all(old="\n", new="%0A")
}
///|
fn annotation_property(value : String) -> String {
annotation_escape(value).replace_all(old=",", new="%2C")
}
///|
/// Renders deterministic machine or human output. Sources are logical schema
/// paths; GitHub annotations intentionally omit line numbers because findings
/// can describe a removed symbol that has no line in the new file.
pub fn render_compatibility_report(
findings : ArrayView[CompatibilityFinding],
policy : CompatibilityPolicy,
format : CompatibilityFormat,
) -> String {
let blocked = has_blocking_compatibility_findings(findings)
match format {
Text => {
let lines : Array[String] = []
for finding in findings {
let prefix = if finding.source == "" {
""
} else {
"\{finding.source}: "
}
lines.push(
"\{prefix}\{finding.direction.name()}: \{finding.change.to_text()}",
)
}
lines.push(
"\{policy.name()}: \{if blocked { "blocked" } else { "passed" }} (\{findings.length()} finding(s))",
)
lines.join("\n")
}
JsonFormat => {
let entries : Array[String] = []
for finding in findings {
let item = finding.change
entries.push(
"{\"direction\":\{json_quote(finding.direction.name())},\"source\":\{json_quote(finding.source)},\"code\":\{json_quote(item.code)},\"kind\":\{json_quote(item.kind.name())},\"path\":\{json_quote(item.path)},\"message\":\{json_quote(item.message)}}",
)
}
"{\"policy\":\{json_quote(policy.name())},\"blocked\":\{if blocked { "true" } else { "false" }},\"findings\":[\{entries.join(",")}]}"
}
Markdown => {
let lines : Array[String] = [
"## MoonThrift compatibility: \{policy.name()} \{if blocked { "blocked" } else { "passed" }}",
"",
"| Source | Direction | Severity | Rule | Symbol | Detail |",
"| --- | --- | --- | --- | --- | --- |",
]
for finding in findings {
let item = finding.change
lines.push(
"| \{markdown_cell(finding.source)} | \{finding.direction.name()} | \{item.kind.name()} | \{item.code} | \{markdown_cell(item.path)} | \{markdown_cell(item.message)} |",
)
}
if findings.is_empty() {
lines.push("| — | — | — | — | — | No changes detected |")
}
lines.join("\n")
}
GithubAnnotations => {
let lines : Array[String] = []
for finding in findings {
let item = finding.change
let level = match item.kind {
Breaking => "error"
Warning => "warning"
Compatible => "notice"
}
let file_property = if finding.source == "" {
""
} else {
"file=\{annotation_property(finding.source)},"
}
lines.push(
"::\{level} \{file_property}title=\{item.code}::\{annotation_escape("\{finding.direction.name()} \{item.path}: \{item.message}")}",
)
}
lines.join("\n")
}
}
}