///|
/// CI-oriented policy for generated scenario runs. It turns coverage, risk and
/// scenario-lint facts into a small pass/fail report that can be printed in a
/// release job.
pub(all) struct ScenarioGate {
require_complete_coverage : Bool
max_missing_interactions : Int
min_cases : Int
max_cases : Int
min_top_risk_score : Int
min_positive_risk_cases : Int
allow_warnings : Bool
}
///|
pub(all) enum GateFailureKind {
IncompleteCoverage(Int, Int)
TooManyMissingInteractions(Int, Int)
TooFewCases(Int, Int)
TooManyCases(Int, Int)
TopRiskTooLow(Int, Int)
TooFewPositiveRiskCases(Int, Int)
ScenarioWarnings(Int)
} derive(Debug, Eq)
///|
pub(all) struct GateFailure {
kind : GateFailureKind
message : String
} derive(Debug, Eq)
///|
pub(all) struct GateReport {
passed : Bool
failures : Array[GateFailure]
warnings : Array[SpecWarning]
coverage : CoverageReport
case_count : Int
top_risk_score : Int
positive_risk_cases : Int
}
///|
pub fn ScenarioGate::default() -> ScenarioGate {
{
require_complete_coverage: true,
max_missing_interactions: 0,
min_cases: 1,
max_cases: 100000,
min_top_risk_score: 0,
min_positive_risk_cases: 0,
allow_warnings: true,
}
}
///|
pub fn ScenarioGate::strict() -> ScenarioGate {
{
require_complete_coverage: true,
max_missing_interactions: 0,
min_cases: 1,
max_cases: 100000,
min_top_risk_score: 1,
min_positive_risk_cases: 1,
allow_warnings: false,
}
}
///|
pub fn ScenarioGate::coverage_only() -> ScenarioGate {
{
require_complete_coverage: true,
max_missing_interactions: 0,
min_cases: 0,
max_cases: 100000,
min_top_risk_score: 0,
min_positive_risk_cases: 0,
allow_warnings: true,
}
}
///|
pub fn ScenarioGate::with_case_budget(
min_cases : Int,
max_cases : Int,
) -> ScenarioGate {
{
require_complete_coverage: true,
max_missing_interactions: 0,
min_cases,
max_cases,
min_top_risk_score: 0,
min_positive_risk_cases: 0,
allow_warnings: true,
}
}
///|
pub fn ScenarioGate::with_risk_floor(
min_top_risk_score : Int,
min_positive_risk_cases : Int,
) -> ScenarioGate {
{
require_complete_coverage: true,
max_missing_interactions: 0,
min_cases: 1,
max_cases: 100000,
min_top_risk_score,
min_positive_risk_cases,
allow_warnings: true,
}
}
///|
pub fn ScenarioRun::evaluate_gate(
self : ScenarioRun,
spec : ScenarioSpec,
gate? : ScenarioGate = ScenarioGate::default(),
) -> GateReport {
let failures : Array[GateFailure] = []
let warnings = spec.lint()
let top_score = self.top_risk_score()
let positive_risk_cases = self.positive_risk_case_count()
if gate.require_complete_coverage && !self.coverage.is_complete() {
failures.push({
kind: IncompleteCoverage(
self.coverage.covered_interactions,
self.coverage.total_interactions,
),
message: "Coverage is incomplete: \{self.coverage.covered_interactions}/\{self.coverage.total_interactions} interactions are covered.",
})
}
if self.coverage.missing.length() > gate.max_missing_interactions {
failures.push({
kind: TooManyMissingInteractions(
self.coverage.missing.length(),
gate.max_missing_interactions,
),
message: "Missing interactions exceed the gate: \{self.coverage.missing.length()} > \{gate.max_missing_interactions}.",
})
}
if self.cases.length() < gate.min_cases {
failures.push({
kind: TooFewCases(self.cases.length(), gate.min_cases),
message: "The run has too few cases: \{self.cases.length()} < \{gate.min_cases}.",
})
}
if self.cases.length() > gate.max_cases {
failures.push({
kind: TooManyCases(self.cases.length(), gate.max_cases),
message: "The run has too many cases for the configured budget: \{self.cases.length()} > \{gate.max_cases}.",
})
}
if top_score < gate.min_top_risk_score {
failures.push({
kind: TopRiskTooLow(top_score, gate.min_top_risk_score),
message: "The highest risk score is below the gate: \{top_score} < \{gate.min_top_risk_score}.",
})
}
if positive_risk_cases < gate.min_positive_risk_cases {
failures.push({
kind: TooFewPositiveRiskCases(
positive_risk_cases,
gate.min_positive_risk_cases,
),
message: "Too few generated cases match risk hints: \{positive_risk_cases} < \{gate.min_positive_risk_cases}.",
})
}
if !gate.allow_warnings && warnings.length() > 0 {
failures.push({
kind: ScenarioWarnings(warnings.length()),
message: "Scenario lint produced \{warnings.length()} warning(s), and this gate treats warnings as failures.",
})
}
{
passed: failures.length() == 0,
failures,
warnings,
coverage: self.coverage,
case_count: self.cases.length(),
top_risk_score: top_score,
positive_risk_cases,
}
}
///|
pub fn ScenarioRun::top_risk_score(self : ScenarioRun) -> Int {
if self.ranked_cases.length() == 0 {
0
} else {
self.ranked_cases[0].score
}
}
///|
pub fn ScenarioRun::positive_risk_case_count(self : ScenarioRun) -> Int {
let mut count = 0
for item in self.ranked_cases {
if item.score > 0 {
count = count + 1
}
}
count
}
///|
pub fn GateReport::is_passed(self : GateReport) -> Bool {
self.passed
}
///|
pub fn GateReport::summary(self : GateReport) -> String {
let status = if self.passed { "pass" } else { "fail" }
"gate=\{status}, failures=\{self.failures.length()}, warnings=\{self.warnings.length()}, cases=\{self.case_count}, coverage=\{self.coverage.coverage_percent}%, top-risk=\{self.top_risk_score}, risk-cases=\{self.positive_risk_cases}"
}
///|
pub fn GateReport::failure_text(self : GateReport) -> String {
if self.failures.length() == 0 {
return "no gate failures\n"
}
let builder = StringBuilder::new()
for failure in self.failures {
builder.write_string("- ")
builder.write_string(failure.message)
builder.write_char('\n')
}
builder.to_string()
}
///|
pub fn GateReport::warning_text(self : GateReport) -> String {
if self.warnings.length() == 0 {
return "no gate warnings\n"
}
let builder = StringBuilder::new()
for warning in self.warnings {
builder.write_string("- ")
builder.write_string(warning.message)
builder.write_char('\n')
}
builder.to_string()
}
///|
pub fn GateReport::to_markdown(self : GateReport) -> String {
let builder = StringBuilder::new()
builder.write_string("## CaseWeave Gate Report\n\n")
builder.write_string("- status: ")
builder.write_string(if self.passed { "pass" } else { "fail" })
builder.write_char('\n')
builder.write_string("- cases: ")
builder.write_string(self.case_count.to_string())
builder.write_char('\n')
builder.write_string("- coverage: ")
builder.write_string(self.coverage.coverage_percent.to_string())
builder.write_string("%\n")
builder.write_string("- top risk score: ")
builder.write_string(self.top_risk_score.to_string())
builder.write_char('\n')
builder.write_string("- positive risk cases: ")
builder.write_string(self.positive_risk_cases.to_string())
builder.write_string("\n\n")
builder.write_string("### Failures\n\n")
if self.failures.length() == 0 {
builder.write_string("No gate failures.\n\n")
} else {
for failure in self.failures {
builder.write_string("- ")
builder.write_string(failure.message)
builder.write_char('\n')
}
builder.write_char('\n')
}
builder.write_string("### Warnings\n\n")
if self.warnings.length() == 0 {
builder.write_string("No scenario warnings.\n")
} else {
for warning in self.warnings {
builder.write_string("- ")
builder.write_string(warning.message)
builder.write_char('\n')
}
}
builder.to_string()
}
///|
pub fn ScenarioGate::describe(self : ScenarioGate) -> String {
let builder = StringBuilder::new()
builder.write_string("coverage=")
builder.write_string(
if self.require_complete_coverage {
"complete"
} else {
"best-effort"
},
)
builder.write_string(", max-missing=")
builder.write_string(self.max_missing_interactions.to_string())
builder.write_string(", case-budget=")
builder.write_string(self.min_cases.to_string())
builder.write_string("..")
builder.write_string(self.max_cases.to_string())
builder.write_string(", min-top-risk=")
builder.write_string(self.min_top_risk_score.to_string())
builder.write_string(", min-risk-cases=")
builder.write_string(self.min_positive_risk_cases.to_string())
builder.write_string(", warnings=")
builder.write_string(if self.allow_warnings { "allowed" } else { "fail" })
builder.to_string()
}
///|
pub fn ScenarioGate::normalize(self : ScenarioGate) -> ScenarioGate {
{
require_complete_coverage: self.require_complete_coverage,
max_missing_interactions: clamp_non_negative(self.max_missing_interactions),
min_cases: clamp_non_negative(self.min_cases),
max_cases: if self.max_cases < 0 {
0
} else {
self.max_cases
},
min_top_risk_score: clamp_non_negative(self.min_top_risk_score),
min_positive_risk_cases: clamp_non_negative(self.min_positive_risk_cases),
allow_warnings: self.allow_warnings,
}
}
///|
fn clamp_non_negative(value : Int) -> Int {
if value < 0 {
0
} else {
value
}
}
///|
pub fn ScenarioRun::evaluate_normalized_gate(
self : ScenarioRun,
spec : ScenarioSpec,
gate : ScenarioGate,
) -> GateReport {
self.evaluate_gate(spec, gate=gate.normalize())
}