///|
/// A narrowly scoped exception for one known compatibility break.
pub(all) struct ChangeAllowance {
code : String
direction : String
path_prefix : String
reason : String
} derive(Eq, Debug)
///|
/// CI policy applied after compatibility analysis.
pub(all) struct AnalysisPolicy {
name : String
required_mode : CompatibilityMode?
max_unallowed_breaking : Int
max_warnings : Int
forbidden_codes : Array[String]
require_witness : Bool
allowances : Array[ChangeAllowance]
} derive(Eq, Debug)
///|
/// One reason an analysis report does not satisfy policy.
pub(all) struct PolicyViolation {
code : String
path : String
message : String
related_change : String?
} derive(Eq, Debug)
///|
/// Deterministic outcome of applying an analysis policy.
pub(all) struct PolicyResult {
policy_name : String
violations : Array[PolicyViolation]
allowed_breaking : Array[Change]
unallowed_breaking : Array[Change]
} derive(Eq, Debug)
///|
/// Strict release policy: full compatibility and no unapproved breaking change.
pub fn AnalysisPolicy::strict() -> AnalysisPolicy {
{
name: "strict",
required_mode: Some(Full),
max_unallowed_breaking: 0,
max_warnings: 0,
forbidden_codes: [],
require_witness: true,
allowances: [],
}
}
///|
/// Backward-compatible rollout policy for consumer-first deployments.
pub fn AnalysisPolicy::consumer_first() -> AnalysisPolicy {
{
name: "consumer-first",
required_mode: Some(Backward),
max_unallowed_breaking: 0,
max_warnings: 10,
forbidden_codes: ["CONTRACT_RENAMED", "TYPE_REMOVED"],
require_witness: true,
allowances: [],
}
}
///|
/// Development policy that records breaks while enforcing witness quality.
pub fn AnalysisPolicy::development(budget? : Int = 20) -> AnalysisPolicy {
{
name: "development",
required_mode: None,
max_unallowed_breaking: budget,
max_warnings: 100,
forbidden_codes: [],
require_witness: true,
allowances: [],
}
}
///|
/// Return a copy of the policy with one documented compatibility exception.
pub fn AnalysisPolicy::allow(
self : AnalysisPolicy,
code : String,
path_prefix? : String = "$",
direction? : String = "*",
reason~ : String,
) -> AnalysisPolicy {
let allowances = self.allowances.copy()
allowances.push({ code, direction, path_prefix, reason })
{
name: self.name,
required_mode: self.required_mode,
max_unallowed_breaking: self.max_unallowed_breaking,
max_warnings: self.max_warnings,
forbidden_codes: self.forbidden_codes,
require_witness: self.require_witness,
allowances,
}
}
///|
/// Evaluate a report against budgets, forbidden rules and scoped allowances.
pub fn evaluate_policy(
report : AnalysisReport,
policy : AnalysisPolicy,
) -> PolicyResult {
let violations : Array[PolicyViolation] = []
let allowed_breaking : Array[Change] = []
let unallowed_breaking : Array[Change] = []
match policy.required_mode {
Some(required) =>
if report.mode != required {
violations.push({
code: "POLICY_MODE_MISMATCH",
path: "$",
message: "policy requires '" +
required.render() +
"' analysis but report uses '" +
report.mode.render() +
"'",
related_change: None,
})
}
None => ()
}
for change in report.changes {
if policy.forbidden_codes.contains(change.code) {
violations.push({
code: "POLICY_FORBIDDEN_CHANGE",
path: change.path,
message: "change code '" + change.code + "' is forbidden by policy",
related_change: Some(change.code),
})
}
if change.severity == Breaking {
if policy.require_witness && change.witness is None {
violations.push({
code: "POLICY_WITNESS_REQUIRED",
path: change.path,
message: "breaking change has no reproducible witness",
related_change: Some(change.code),
})
}
if policy.allows(change) {
allowed_breaking.push(change)
} else {
unallowed_breaking.push(change)
}
}
}
if unallowed_breaking.length() > policy.max_unallowed_breaking {
violations.push({
code: "POLICY_BREAKING_BUDGET_EXCEEDED",
path: "$",
message: "found " +
unallowed_breaking.length().to_string() +
" unallowed breaking changes; budget is " +
policy.max_unallowed_breaking.to_string(),
related_change: None,
})
}
if report.warning_count() > policy.max_warnings {
violations.push({
code: "POLICY_WARNING_BUDGET_EXCEEDED",
path: "$",
message: "found " +
report.warning_count().to_string() +
" warnings; budget is " +
policy.max_warnings.to_string(),
related_change: None,
})
}
{ policy_name: policy.name, violations, allowed_breaking, unallowed_breaking }
}
///|
pub fn PolicyResult::passed(self : PolicyResult) -> Bool {
self.violations.is_empty()
}
///|
fn AnalysisPolicy::allows(self : AnalysisPolicy, change : Change) -> Bool {
self.allowances.any(allowance => allowance.matches(change))
}
///|
fn ChangeAllowance::matches(self : ChangeAllowance, change : Change) -> Bool {
(self.code == "*" || self.code == change.code) &&
(self.direction == "*" || self.direction == change.direction) &&
change.path.has_prefix(self.path_prefix)
}
///|
/// Render a compact CI-friendly policy result.
pub fn PolicyResult::to_markdown(self : PolicyResult) -> String {
let out = StringBuilder()
out.write_string("# EvoWitness policy: " + self.policy_name + "\n\n")
out.write_string(
"Result: **" + (if self.passed() { "PASS" } else { "FAIL" }) + "**\n\n",
)
out.write_string(
"- Allowed breaking changes: " +
self.allowed_breaking.length().to_string() +
"\n",
)
out.write_string(
"- Unallowed breaking changes: " +
self.unallowed_breaking.length().to_string() +
"\n",
)
out.write_string(
"- Policy violations: " + self.violations.length().to_string() + "\n",
)
if !self.violations.is_empty() {
out.write_string("\n| Code | Path | Message |\n|---|---|---|\n")
for violation in self.violations {
out.write_string(
"| `" + violation.code + "` | `" + violation.path + "` | ",
)
out.write_string(markdown_escape(violation.message) + " |\n")
}
}
out.to_string()
}