///|
/// One auditable compatibility exception.
pub struct ApiPolicyRule {
change_kind : String
item_kind : String
name : String
until : String
max_matches : Int
reason : String
line : Int
} derive(Eq, Debug)
///|
/// Result of parsing a policy file.
pub struct PolicyParseResult {
rules : Array[ApiPolicyRule]
errors : Array[String]
} derive(Eq, Debug)
///|
/// A change accepted by one policy rule.
pub struct AcceptedApiChange {
change : ApiChange
rule : ApiPolicyRule
} derive(Eq, Debug)
///|
/// A diagnostic emitted while evaluating compatibility policy.
pub struct ApiPolicyDiagnostic {
severity : String
code : String
line : Int
message : String
} derive(Eq, Debug)
///|
/// Aggregate policy evaluation counters.
pub struct PolicySummary {
rule_count : Int
matched_rule_count : Int
accepted_change_count : Int
errors : Int
warnings : Int
} derive(Eq, Debug)
///|
/// Original and effective reports together with their audit trail.
pub struct ApiPolicyEvaluation {
original_report : ApiReport
effective_report : ApiReport
accepted_changes : Array[AcceptedApiChange]
diagnostics : Array[ApiPolicyDiagnostic]
summary : PolicySummary
} derive(Eq, Debug)
///|
/// A release plan evaluated against the effective compatibility report.
pub struct PolicyReleasePlan {
evaluation : ApiPolicyEvaluation
plan : ReleasePlan
} derive(Eq, Debug)
///|
/// Parse auditable compatibility rules.
///
/// Syntax: `allow CHANGE_KIND ITEM_KIND NAME [until VERSION]
/// [max_matches N] reason TEXT...`.
pub fn parse_policy_rules(text : String) -> PolicyParseResult {
let rules : Array[ApiPolicyRule] = []
let errors : Array[String] = []
let mut line_number = 1
for raw_line in split_lines(text[:]) {
parse_policy_rule_line(raw_line, line_number, rules, errors)
line_number += 1
}
{ rules, errors }
}
///|
/// Evaluate parsed rules against a compatibility report.
///
/// A rule with an expired deadline or exceeded budget accepts no changes.
/// When several valid rules match a change, the first rule owns its audit
/// record.
pub fn evaluate_policy(
report : ApiReport,
rules : Array[ApiPolicyRule],
target_version : String,
) -> ApiPolicyEvaluation {
let raw_match_counts : Array[Int] = []
let eligible : Array[Bool] = []
let diagnostics : Array[ApiPolicyDiagnostic] = []
let target = if target_version.length() == 0 {
None
} else {
parse_version(target_version)
}
for rule in rules {
let mut count = 0
for change in report.changes {
if change_matches_policy_rule(change, rule) {
count += 1
}
}
raw_match_counts.push(count)
let mut valid = true
match policy_rule_validation_error(rule) {
Some(message) => {
valid = false
diagnostics.push(
policy_diagnostic("error", "policy-invalid-rule", rule.line, message),
)
}
None => ()
}
if count == 0 {
diagnostics.push(
policy_diagnostic(
"warning",
"policy-unmatched-rule",
rule.line,
"policy rule matched no API changes",
),
)
}
if rule.until.length() > 0 {
if target_version.length() == 0 {
valid = false
diagnostics.push(
policy_diagnostic(
"error",
"policy-version-required",
rule.line,
"target version is required for a rule with 'until'",
),
)
} else {
match target {
None => {
valid = false
diagnostics.push(
policy_diagnostic(
"error",
"policy-invalid-target-version",
rule.line,
"target version must be a strict major.minor.patch value",
),
)
}
Some(target_value) =>
match parse_version(rule.until) {
None => {
valid = false
diagnostics.push(
policy_diagnostic(
"error",
"policy-invalid-until-version",
rule.line,
"policy deadline must be a strict major.minor.patch value",
),
)
}
Some(deadline) =>
if compare_versions(target_value, deadline) > 0 {
valid = false
diagnostics.push(
policy_diagnostic(
"error",
"policy-expired-rule",
rule.line,
"policy rule expired after version {rule.until}",
),
)
}
}
}
}
}
if count > rule.max_matches {
valid = false
diagnostics.push(
policy_diagnostic(
"error",
"policy-match-budget-exceeded",
rule.line,
"policy rule matched {count} changes, exceeding max_matches {rule.max_matches}",
),
)
}
eligible.push(valid)
}
let accepted_changes : Array[AcceptedApiChange] = []
let effective_changes : Array[ApiChange] = []
for change in report.changes {
let mut owner = -1
let mut eligible_matches = 0
for i in 0..= 0 {
accepted_changes.push({ change, rule: rules[owner] })
if eligible_matches > 1 {
diagnostics.push(
policy_diagnostic(
"warning",
"policy-overlapping-rules",
rules[owner].line,
"API change matches {eligible_matches} valid policy rules; the first rule was used",
),
)
}
} else {
effective_changes.push(change)
}
}
sort_changes(effective_changes)
let mut matched_rule_count = 0
for count in raw_match_counts {
if count > 0 {
matched_rule_count += 1
}
}
let mut errors = 0
let mut warnings = 0
for diagnostic in diagnostics {
if diagnostic.severity == "error" {
errors += 1
} else if diagnostic.severity == "warning" {
warnings += 1
}
}
if errors > 0 {
return {
original_report: report,
effective_report: report,
accepted_changes: [],
diagnostics,
summary: {
rule_count: rules.length(),
matched_rule_count,
accepted_change_count: 0,
errors,
warnings,
},
}
}
{
original_report: report,
effective_report: {
changes: effective_changes,
recommendation: recommended_impact(effective_changes),
},
accepted_changes,
diagnostics,
summary: {
rule_count: rules.length(),
matched_rule_count,
accepted_change_count: accepted_changes.length(),
errors,
warnings,
},
}
}
///|
/// Parse and evaluate policy text. Parse failures are preserved as policy
/// diagnostics and never accept changes.
pub fn evaluate_policy_text(
report : ApiReport,
rules_text : String,
target_version : String,
) -> ApiPolicyEvaluation {
let parsed = parse_policy_rules(rules_text)
if parsed.errors.is_empty() {
return evaluate_policy(report, parsed.rules, target_version)
}
let evaluated = evaluate_policy(report, [], target_version)
let diagnostics = evaluated.diagnostics.copy()
for error in parsed.errors {
diagnostics.push(policy_diagnostic("error", "policy-parse-error", 0, error))
}
{
original_report: evaluated.original_report,
effective_report: evaluated.effective_report,
accepted_changes: evaluated.accepted_changes,
diagnostics,
summary: {
rule_count: parsed.rules.length(),
matched_rule_count: 0,
accepted_change_count: 0,
errors: evaluated.summary.errors + parsed.errors.length(),
warnings: evaluated.summary.warnings,
},
}
}
///|
/// Return true when policy diagnostics contain at least one error.
pub fn policy_diagnostics_have_errors(
diagnostics : Array[ApiPolicyDiagnostic],
) -> Bool {
for diagnostic in diagnostics {
if diagnostic.severity == "error" {
return true
}
}
false
}
///|
/// Build a release plan using the effective report. Policy errors block the
/// plan independently of interface snapshot diagnostics.
pub fn make_policy_release_plan(
evaluation : ApiPolicyEvaluation,
diagnostics : Array[ApiDiagnostic],
current : String,
next : String,
) -> PolicyReleasePlan {
let base = make_release_plan(
evaluation.effective_report,
diagnostics,
current,
next,
)
let plan = if policy_diagnostics_have_errors(evaluation.diagnostics) {
{
report: base.report,
diagnostics: base.diagnostics,
version_check: base.version_check,
summary: base.summary,
diagnostic_summary: base.diagnostic_summary,
status: "blocked",
decision: "Fix compatibility policy errors before publishing.",
next_action: "Resolve policy errors, then rerun MoonGuard.",
}
} else {
base
}
{ evaluation, plan }
}
///|
/// Render an audited compatibility evaluation as Markdown.
pub fn render_markdown_policy_evaluation(
evaluation : ApiPolicyEvaluation,
) -> String {
let out = StringBuilder()
out.write("# MoonGuard Audited API Compatibility Report\n\n")
out.write("- Original recommendation: **")
out.write(impact_label(evaluation.original_report.recommendation))
out.write("**\n- Effective recommendation: **")
out.write(impact_label(evaluation.effective_report.recommendation))
out.write("**\n- Original changes: ")
out.write(evaluation.original_report.changes.length().to_string())
out.write("\n- Accepted changes: ")
out.write(evaluation.summary.accepted_change_count.to_string())
out.write("\n- Policy diagnostics: ")
out.write(evaluation.diagnostics.length().to_string())
out.write("\n\n")
write_markdown_accepted_changes(out, evaluation.accepted_changes)
out.write("## Effective Changes\n\n")
write_markdown_changes(out, evaluation.effective_report.changes)
out.write("\n")
write_markdown_policy_diagnostics(out, evaluation.diagnostics)
out.to_string()
}
///|
/// Render an audited compatibility evaluation as JSON.
pub fn render_json_policy_evaluation(
evaluation : ApiPolicyEvaluation,
) -> String {
let out = StringBuilder()
write_json_policy_evaluation_object(out, evaluation, 0)
out.write("\n")
out.to_string()
}
///|
/// Render a policy-aware release plan as Markdown.
pub fn render_markdown_policy_release_plan(plan : PolicyReleasePlan) -> String {
let out = StringBuilder()
out.write(render_markdown_policy_evaluation(plan.evaluation))
out.write("\n## Effective Release Plan\n\n")
out.write("- Status: **")
out.write(escape_markdown_cell(plan.plan.status))
out.write("**\n- Decision: ")
out.write(escape_markdown_cell(plan.plan.decision))
out.write("\n- Next action: ")
out.write(escape_markdown_cell(plan.plan.next_action))
out.write("\n- Required bump: **")
out.write(impact_label(plan.plan.version_check.required))
out.write("**\n- Version: `")
out.write(escape_markdown_cell(plan.plan.version_check.current))
out.write("` -> `")
out.write(escape_markdown_cell(plan.plan.version_check.next))
out.write("`")
out.write("\n- Version check: **")
if plan.plan.version_check.ok {
out.write("pass")
} else {
out.write("fail")
}
out.write("**\n\n## Maintainer Checklist\n\n")
write_release_plan_checklist(out, plan.plan)
if plan.evaluation.summary.accepted_change_count > 0 {
out.write("- [ ] Review accepted compatibility exceptions.\n")
} else {
out.write("- [x] No compatibility exceptions were accepted.\n")
}
if plan.evaluation.summary.errors > 0 {
out.write("- [ ] Resolve compatibility policy errors.\n")
} else if plan.evaluation.summary.warnings > 0 {
out.write("- [ ] Review compatibility policy warnings.\n")
} else {
out.write("- [x] Compatibility policy has no diagnostics.\n")
}
out.write("\n")
write_markdown_diagnostics(out, plan.plan.diagnostics)
out.to_string()
}
///|
/// Render a policy-aware release plan as JSON.
pub fn render_json_policy_release_plan(plan : PolicyReleasePlan) -> String {
let out = StringBuilder()
out.write("{\n")
write_json_indent(out, 2)
out.write("\"evaluation\": ")
write_json_policy_evaluation_object(out, plan.evaluation, 2)
out.write(",\n")
write_json_indent(out, 2)
out.write("\"release_plan\": ")
let rendered_plan = render_json_release_plan(plan.plan).trim()
out.write(rendered_plan)
out.write("\n}\n")
out.to_string()
}
///|
fn parse_policy_rule_line(
raw_line : StringView,
line_number : Int,
rules : Array[ApiPolicyRule],
errors : Array[String],
) -> Unit {
let line = strip_rule_comment(raw_line).trim()
if line.length() == 0 || line.has_prefix("#") {
return
}
let words = split_rule_words(line)
if words.length() < 4 || words[0] != "allow" {
errors.push(
"line {line_number}: expected 'allow CHANGE_KIND ITEM_KIND NAME ... reason TEXT'",
)
return
}
let change_kind = words[1]
let item_kind = words[2]
let name = words[3]
if !valid_policy_change_kind(change_kind) {
errors.push("line {line_number}: invalid change kind '{change_kind}'")
return
}
if !valid_ignore_kind(item_kind) {
errors.push("line {line_number}: invalid policy item kind '{item_kind}'")
return
}
if !valid_ignore_name_pattern(name) {
errors.push("line {line_number}: invalid policy name '{name}'")
return
}
let mut until = ""
let mut max_matches = 1
let mut has_max_matches = false
let mut index = 4
let mut found_reason = false
while index < words.length() {
if words[index] == "reason" {
found_reason = true
index += 1
break
} else if words[index] == "until" {
if until.length() > 0 || index + 1 >= words.length() {
errors.push("line {line_number}: 'until' requires one version")
return
}
until = words[index + 1]
if parse_version(until) is None {
errors.push("line {line_number}: invalid policy deadline '{until}'")
return
}
index += 2
} else if words[index] == "max_matches" {
if has_max_matches || index + 1 >= words.length() {
errors.push("line {line_number}: 'max_matches' requires a number")
return
}
match parse_positive_policy_int(words[index + 1]) {
None => {
errors.push(
"line {line_number}: max_matches must be a positive integer",
)
return
}
Some(value) => max_matches = value
}
has_max_matches = true
index += 2
} else {
errors.push(
"line {line_number}: expected 'until', 'max_matches', or 'reason'",
)
return
}
}
let reason = join_words(words, index)
if !found_reason || reason.trim().length() == 0 {
errors.push("line {line_number}: policy rule requires a non-empty reason")
return
}
rules.push({
change_kind,
item_kind,
name,
until,
max_matches,
reason,
line: line_number,
})
}
///|
fn parse_positive_policy_int(text : String) -> Int? {
match parse_version_number(text[:]) {
Some(value) => if value > 0 { Some(value) } else { None }
None => None
}
}
///|
fn valid_policy_change_kind(kind : String) -> Bool {
kind == "*" || kind == "added" || kind == "removed" || kind == "changed"
}
///|
fn policy_rule_validation_error(rule : ApiPolicyRule) -> String? {
if rule.line <= 0 {
Some("policy rule line must be a positive integer")
} else if !valid_policy_change_kind(rule.change_kind) {
Some("invalid policy change kind '{rule.change_kind}'")
} else if !valid_ignore_kind(rule.item_kind) {
Some("invalid policy item kind '{rule.item_kind}'")
} else if !valid_ignore_name_pattern(rule.name) {
Some("invalid policy name '{rule.name}'")
} else if rule.max_matches <= 0 {
Some("policy max_matches must be a positive integer")
} else if rule.reason.trim().length() == 0 {
Some("policy rule requires a non-empty reason")
} else {
None
}
}
///|
fn change_matches_policy_rule(change : ApiChange, rule : ApiPolicyRule) -> Bool {
(
rule.change_kind == "*" ||
rule.change_kind == change_kind_label(change.kind)
) &&
ignore_kind_matches(rule.item_kind, change.item_kind) &&
wildcard_matches(rule.name, change.name)
}
///|
fn policy_diagnostic(
severity : String,
code : String,
line : Int,
message : String,
) -> ApiPolicyDiagnostic {
{ severity, code, line, message }
}
///|
fn write_markdown_accepted_changes(
out : StringBuilder,
accepted : Array[AcceptedApiChange],
) -> Unit {
out.write("## Accepted Changes\n\n")
if accepted.is_empty() {
out.write("No compatibility exceptions were accepted.\n\n")
return
}
out.write("| Change | Symbol | Rule | Until | Budget | Reason |\n")
out.write("| --- | --- | --- | --- | --- | --- |\n")
for accepted_change in accepted {
let change = accepted_change.change
let rule = accepted_change.rule
out.write("| ")
out.write(change_kind_label(change.kind))
out.write(" | `")
out.write(escape_markdown_cell(change.item_kind))
out.write(" ")
out.write(escape_markdown_cell(change.name))
out.write("` | line ")
out.write(rule.line.to_string())
out.write(" | ")
if rule.until.length() == 0 {
out.write("-")
} else {
out.write("`")
out.write(escape_markdown_cell(rule.until))
out.write("`")
}
out.write(" | ")
out.write(rule.max_matches.to_string())
out.write(" | ")
out.write(escape_markdown_cell(rule.reason))
out.write(" |\n")
}
out.write("\n")
}
///|
fn write_markdown_policy_diagnostics(
out : StringBuilder,
diagnostics : Array[ApiPolicyDiagnostic],
) -> Unit {
out.write("## Policy Diagnostics\n\n")
if diagnostics.is_empty() {
out.write("No policy diagnostics.\n")
return
}
out.write("| Severity | Code | Line | Message |\n")
out.write("| --- | --- | --- | --- |\n")
for diagnostic in diagnostics {
out.write("| ")
out.write(escape_markdown_cell(diagnostic.severity))
out.write(" | ")
out.write(escape_markdown_cell(diagnostic.code))
out.write(" | ")
out.write(diagnostic.line.to_string())
out.write(" | ")
out.write(escape_markdown_cell(diagnostic.message))
out.write(" |\n")
}
}
///|
fn write_json_policy_evaluation_object(
out : StringBuilder,
evaluation : ApiPolicyEvaluation,
indent : Int,
) -> Unit {
out.write("{\n")
write_json_indent(out, indent + 2)
out.write("\"original_report\": ")
write_json_report_object(out, evaluation.original_report, indent + 2)
out.write(",\n")
write_json_indent(out, indent + 2)
out.write("\"effective_report\": ")
write_json_report_object(out, evaluation.effective_report, indent + 2)
out.write(",\n")
write_json_indent(out, indent + 2)
out.write("\"policy_summary\": ")
write_json_policy_summary(out, evaluation.summary, indent + 2)
out.write(",\n")
write_json_indent(out, indent + 2)
out.write("\"accepted_changes\": ")
write_json_accepted_changes(out, evaluation.accepted_changes, indent + 2)
out.write(",\n")
write_json_indent(out, indent + 2)
out.write("\"policy_diagnostics\": ")
write_json_policy_diagnostics(out, evaluation.diagnostics, indent + 2)
out.write("\n")
write_json_indent(out, indent)
out.write("}")
}
///|
fn write_json_policy_summary(
out : StringBuilder,
summary : PolicySummary,
indent : Int,
) -> Unit {
out.write("{\n")
write_json_number_field(out, "rule_count", summary.rule_count, indent + 2)
out.write(",\n")
write_json_number_field(
out,
"matched_rule_count",
summary.matched_rule_count,
indent + 2,
)
out.write(",\n")
write_json_number_field(
out,
"accepted_change_count",
summary.accepted_change_count,
indent + 2,
)
out.write(",\n")
write_json_number_field(out, "errors", summary.errors, indent + 2)
out.write(",\n")
write_json_number_field(out, "warnings", summary.warnings, indent + 2)
out.write("\n")
write_json_indent(out, indent)
out.write("}")
}
///|
fn write_json_accepted_changes(
out : StringBuilder,
accepted : Array[AcceptedApiChange],
indent : Int,
) -> Unit {
out.write("[")
if accepted.is_empty() {
out.write("]")
return
}
out.write("\n")
for i in 0.. 0 {
out.write(",\n")
}
let item = accepted[i]
write_json_indent(out, indent + 2)
out.write("{\n")
write_json_indent(out, indent + 4)
out.write("\"change\": ")
write_json_change(out, item.change, indent + 4)
out.write(",\n")
write_json_indent(out, indent + 4)
out.write("\"rule\": ")
write_json_policy_rule(out, item.rule, indent + 4)
out.write("\n")
write_json_indent(out, indent + 2)
out.write("}")
}
out.write("\n")
write_json_indent(out, indent)
out.write("]")
}
///|
fn write_json_policy_rule(
out : StringBuilder,
rule : ApiPolicyRule,
indent : Int,
) -> Unit {
out.write("{\n")
write_json_number_field(out, "line", rule.line, indent + 2)
out.write(",\n")
write_json_string_field(out, "change_kind", rule.change_kind, indent + 2)
out.write(",\n")
write_json_string_field(out, "item_kind", rule.item_kind, indent + 2)
out.write(",\n")
write_json_string_field(out, "name", rule.name, indent + 2)
out.write(",\n")
write_json_string_field(out, "until", rule.until, indent + 2)
out.write(",\n")
write_json_number_field(out, "max_matches", rule.max_matches, indent + 2)
out.write(",\n")
write_json_string_field(out, "reason", rule.reason, indent + 2)
out.write("\n")
write_json_indent(out, indent)
out.write("}")
}
///|
fn write_json_policy_diagnostics(
out : StringBuilder,
diagnostics : Array[ApiPolicyDiagnostic],
indent : Int,
) -> Unit {
out.write("[")
if diagnostics.is_empty() {
out.write("]")
return
}
out.write("\n")
for i in 0.. 0 {
out.write(",\n")
}
let diagnostic = diagnostics[i]
write_json_indent(out, indent + 2)
out.write("{\n")
write_json_string_field(out, "severity", diagnostic.severity, indent + 4)
out.write(",\n")
write_json_string_field(out, "code", diagnostic.code, indent + 4)
out.write(",\n")
write_json_number_field(out, "line", diagnostic.line, indent + 4)
out.write(",\n")
write_json_string_field(out, "message", diagnostic.message, indent + 4)
out.write("\n")
write_json_indent(out, indent + 2)
out.write("}")
}
out.write("\n")
write_json_indent(out, indent)
out.write("]")
}