use crate::error::{BeadsError, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
pub const POLICY_FILE_NAME: &str = "policy.yaml";
pub const ENV_AGENT_NAME: &str = "BR_AGENT_NAME";
pub const ENV_HARNESS: &str = "BR_HARNESS";
pub const ENV_MODEL: &str = "BR_MODEL";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PolicyDocument {
pub close_policy: ClosePolicy,
pub workflow: Workflow,
#[serde(default = "default_true")]
pub allow_bypass: bool,
}
impl Default for PolicyDocument {
fn default() -> Self {
Self {
close_policy: ClosePolicy::default(),
workflow: Workflow::default(),
allow_bypass: default_true(),
}
}
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ClosePolicy {
pub require_close_reason: RequireCloseReason,
pub require_acceptance_criteria_satisfied: ToggleGate,
pub forbid_self_close_after_in_progress: ToggleGate,
pub forbid_close_with_deferred_dependents: ToggleGate,
pub attribution: Attribution,
pub require_typed_references: RequireTypedReferences,
}
impl ClosePolicy {
#[must_use]
pub fn is_active(&self) -> bool {
self.require_close_reason.enabled
|| self.require_acceptance_criteria_satisfied.enabled
|| self.forbid_self_close_after_in_progress.enabled
|| self.forbid_close_with_deferred_dependents.enabled
|| self.require_typed_references.enabled
|| self.attribution.tier != AttributionTier::Off
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Workflow {
pub strict: bool,
#[serde(default)]
pub statuses: Vec<String>,
#[serde(default)]
pub transitions: std::collections::BTreeMap<String, Vec<String>>,
#[serde(default)]
pub gates: std::collections::BTreeMap<String, GateRule>,
#[serde(default)]
pub status_groups: StatusGroups,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct StatusGroups {
#[serde(default)]
pub ready: Vec<String>,
}
pub const DEFAULT_READY_STATUS: &str = "open";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct GateRule {
#[serde(default)]
pub require_all: Vec<GateSpec>,
#[serde(default)]
pub require_if: Vec<ConditionalGate>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateSpec {
Named(String),
MinReviewers(u32),
}
impl GateSpec {
#[must_use]
pub fn id(&self) -> &str {
match self {
Self::Named(name) => name.as_str(),
Self::MinReviewers(_) => GATE_MIN_REVIEWERS,
}
}
}
impl Serialize for GateSpec {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
match self {
Self::Named(name) => serializer.serialize_str(name),
Self::MinReviewers(n) => {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry(GATE_MIN_REVIEWERS, n)?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for GateSpec {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Named(String),
Map(std::collections::BTreeMap<String, serde_yml::Value>),
}
match Raw::deserialize(deserializer)? {
Raw::Named(name) => Ok(Self::Named(name)),
Raw::Map(map) => {
if let Some(value) = map.get(GATE_MIN_REVIEWERS) {
let n = value.as_u64().ok_or_else(|| {
serde::de::Error::custom("min_reviewers must be a non-negative integer")
})?;
let n = u32::try_from(n)
.map_err(|_| serde::de::Error::custom("min_reviewers is too large"))?;
Ok(Self::MinReviewers(n))
} else if let Some((key, _)) = map.iter().next() {
Err(serde::de::Error::custom(format!(
"unknown gate spec key '{key}' (expected a bare gate name or `min_reviewers: N`)"
)))
} else {
Err(serde::de::Error::custom(
"empty gate spec (expected a bare gate name or `min_reviewers: N`)",
))
}
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ConditionalGate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub priority: Vec<i32>,
#[serde(default)]
pub gate: GateSpec,
}
impl Default for GateSpec {
fn default() -> Self {
Self::Named(String::new())
}
}
pub const GATE_MIN_REVIEWERS: &str = "min_reviewers";
pub const REVIEWER_PROVIDER_PREFIX: &str = "reviewer";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GateResult {
pub gate: String,
pub provider: String,
pub passed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl Workflow {
#[must_use]
pub fn gates_enforced(&self) -> bool {
self.strict && !self.gates.is_empty()
}
#[must_use]
pub fn gate_rule_for(&self, from: &str, to: &str) -> Option<&GateRule> {
self.gates.iter().find_map(|(key, rule)| {
parse_transition_key(key).filter(|(rule_from, rule_to)| {
rule_from.eq_ignore_ascii_case(from) && rule_to.eq_ignore_ascii_case(to)
})?;
Some(rule)
})
}
#[must_use]
pub fn required_gates_for(
&self,
from: &str,
to: &str,
labels: &[String],
priority: i32,
) -> Vec<GateSpec> {
let Some(rule) = self.gate_rule_for(from, to) else {
return Vec::new();
};
let mut out: Vec<GateSpec> = Vec::new();
let push_unique = |spec: &GateSpec, out: &mut Vec<GateSpec>| {
if !out
.iter()
.any(|seen| seen.id().eq_ignore_ascii_case(spec.id()))
{
out.push(spec.clone());
}
};
for spec in &rule.require_all {
push_unique(spec, &mut out);
}
for conditional in &rule.require_if {
if conditional_matches(conditional, labels, priority) {
push_unique(&conditional.gate, &mut out);
}
}
out
}
}
fn parse_transition_key(key: &str) -> Option<(&str, &str)> {
let (from, to) = key.split_once("->")?;
let from = from.trim();
let to = to.trim();
if from.is_empty() || to.is_empty() {
return None;
}
Some((from, to))
}
fn conditional_matches(conditional: &ConditionalGate, labels: &[String], priority: i32) -> bool {
if let Some(required_label) = conditional.label.as_deref()
&& !labels
.iter()
.any(|label| label.eq_ignore_ascii_case(required_label))
{
return false;
}
if !conditional.priority.is_empty() && !conditional.priority.contains(&priority) {
return false;
}
true
}
#[must_use]
pub fn evaluate_gates(
workflow: &Workflow,
issue_id: &str,
from: &str,
to: &str,
labels: &[String],
priority: i32,
results: &[GateResult],
) -> Vec<PolicyViolation> {
let required = workflow.required_gates_for(from, to, labels, priority);
let mut violations = Vec::new();
for spec in &required {
match spec {
GateSpec::Named(name) => {
let passed = results
.iter()
.any(|r| r.gate.eq_ignore_ascii_case(name) && r.passed);
if !passed {
let failing: Vec<&str> = results
.iter()
.filter(|r| r.gate.eq_ignore_ascii_case(name) && !r.passed)
.map(|r| r.provider.as_str())
.collect();
let detail_note = if failing.is_empty() {
"no provider has reported a pass".to_string()
} else {
format!("failing provider(s): {}", failing.join(", "))
};
violations.push(PolicyViolation {
gate: format!("gate_{name}"),
message: format!(
"transition '{from}' -> '{to}' requires gate '{name}' to pass for {issue_id}, \
but {detail_note}. Record a result with \
`br gate report {issue_id} --gate {name} --provider <name> --status pass`."
),
detail: Some(serde_json::json!({
"issue_id": issue_id,
"gate": name,
"from": from,
"to": to,
"failing_providers": failing,
})),
});
}
}
GateSpec::MinReviewers(n) => {
let mut reviewers: Vec<&str> = results
.iter()
.filter(|r| r.passed && is_reviewer_provider(&r.provider))
.map(|r| r.provider.as_str())
.collect();
reviewers.sort_unstable();
reviewers.dedup();
let count = u32::try_from(reviewers.len()).unwrap_or(u32::MAX);
if count < *n {
violations.push(PolicyViolation {
gate: format!("gate_{GATE_MIN_REVIEWERS}"),
message: format!(
"transition '{from}' -> '{to}' requires at least {n} reviewer(s) to pass for \
{issue_id}, but only {count} distinct reviewer(s) have. Record a review with \
`br gate report {issue_id} --gate {GATE_MIN_REVIEWERS} --provider reviewer:<who> --status pass`."
),
detail: Some(serde_json::json!({
"issue_id": issue_id,
"gate": GATE_MIN_REVIEWERS,
"from": from,
"to": to,
"required": n,
"actual": count,
"reviewers": reviewers,
})),
});
}
}
}
}
violations
}
#[must_use]
pub fn is_reviewer_provider(provider: &str) -> bool {
let lower = provider.to_ascii_lowercase();
lower == REVIEWER_PROVIDER_PREFIX
|| lower.starts_with(&format!("{REVIEWER_PROVIDER_PREFIX}:"))
|| lower.starts_with(&format!("{REVIEWER_PROVIDER_PREFIX}-"))
}
pub const TRANSITION_ANY_FROM: &str = "any";
pub const TRANSITION_INITIAL: &str = "initial";
impl Workflow {
#[must_use]
pub fn is_enforced(&self) -> bool {
self.strict && !self.statuses.is_empty()
}
#[must_use]
pub fn ready_status_group(&self) -> Vec<String> {
let configured = &self.status_groups.ready;
let source: Vec<String> = if configured.is_empty() {
vec![DEFAULT_READY_STATUS.to_string()]
} else {
configured.clone()
};
let mut out: Vec<String> = Vec::with_capacity(source.len());
for status in source {
let normalized = status.trim().to_lowercase();
if normalized.is_empty() {
continue;
}
if !out.contains(&normalized) {
out.push(normalized);
}
}
if out.is_empty() {
out.push(DEFAULT_READY_STATUS.to_string());
}
out
}
pub fn validate_ready_status_group(&self) -> Result<()> {
if self.status_groups.ready.is_empty() {
return Ok(());
}
if !self.is_enforced() {
return Ok(());
}
let unknown: Vec<String> = self
.ready_status_group()
.into_iter()
.filter(|status| !self.allows(status))
.collect();
if unknown.is_empty() {
return Ok(());
}
Err(BeadsError::validation(
"workflow.status_groups.ready",
format!(
"ready status group contains status(es) not permitted by the project workflow \
policy (.beads/policy.yaml workflow.strict): {}. Allowed statuses: {}.",
unknown.join(", "),
self.allowed_list()
),
))
}
#[must_use]
pub fn allows(&self, status: &str) -> bool {
let target = status.to_lowercase();
self.statuses
.iter()
.any(|allowed| allowed.to_lowercase() == target)
}
#[must_use]
pub fn allowed_list(&self) -> String {
self.statuses.join(", ")
}
pub fn validate_status(&self, status: &str) -> Result<()> {
if !self.is_enforced() || self.allows(status) {
return Ok(());
}
Err(BeadsError::validation(
"status",
format!(
"status '{status}' is not permitted by the project workflow policy \
(.beads/policy.yaml workflow.strict). Allowed statuses: {}.",
self.allowed_list()
),
))
}
#[must_use]
pub fn transitions_enforced(&self) -> bool {
self.strict && !self.transitions.is_empty()
}
fn transitions_from(&self, from: &str) -> Option<&Vec<String>> {
let target = from.to_lowercase();
self.transitions
.iter()
.find(|(key, _)| key.to_lowercase() == target)
.map(|(_, tos)| tos)
}
#[must_use]
pub fn allowed_targets_from(&self, from: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let push_unique = |list: &Vec<String>, out: &mut Vec<String>| {
for value in list {
if !out.iter().any(|seen| seen.eq_ignore_ascii_case(value)) {
out.push(value.clone());
}
}
};
if let Some(explicit) = self.transitions_from(from) {
push_unique(explicit, &mut out);
}
if let Some(wildcard) = self.transitions_from(TRANSITION_ANY_FROM) {
push_unique(wildcard, &mut out);
}
out
}
#[must_use]
pub fn allows_transition(&self, from: &str, to: &str) -> bool {
if from.eq_ignore_ascii_case(to) {
return true;
}
self.allowed_targets_from(from)
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(to))
}
#[must_use]
pub fn allows_initial(&self, to: &str) -> bool {
match self.transitions_from(TRANSITION_INITIAL) {
None => true,
Some(allowed) => allowed.iter().any(|s| s.eq_ignore_ascii_case(to)),
}
}
pub fn validate_transition(&self, from: Option<&str>, to: &str) -> Result<()> {
if !self.transitions_enforced() {
return Ok(());
}
match from {
None => {
if self.allows_initial(to) {
return Ok(());
}
let allowed = self
.transitions_from(TRANSITION_INITIAL)
.map(|tos| tos.join(", "))
.unwrap_or_default();
Err(BeadsError::validation(
"status",
format!(
"initial status '{to}' is not permitted by the project workflow policy \
(.beads/policy.yaml workflow.transitions, key 'initial'). \
Allowed initial statuses: {allowed}."
),
))
}
Some(from) => {
if self.allows_transition(from, to) {
return Ok(());
}
let allowed = self.allowed_targets_from(from);
let allowed_list = if allowed.is_empty() {
"(none)".to_string()
} else {
allowed.join(", ")
};
Err(BeadsError::validation(
"status",
format!(
"transition '{from}' -> '{to}' is not permitted by the project workflow \
policy (.beads/policy.yaml workflow.transitions). \
Valid next statuses from '{from}': {allowed_list}."
),
))
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct RequireTypedReferences {
pub enabled: bool,
#[serde(default)]
pub required_kinds: Vec<String>,
}
const BUILTIN_TYPED_REFERENCE_KINDS: &[&str] = &[
"commit",
"pr",
"reviewer",
"investigation",
"agent-mail",
"dashboard",
];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ToggleGate {
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct RequireCloseReason {
pub enabled: bool,
pub min_length: usize,
pub regex: Option<String>,
}
impl Default for RequireCloseReason {
fn default() -> Self {
Self {
enabled: false,
min_length: 20,
regex: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Attribution {
pub tier: AttributionTier,
#[serde(default)]
pub fields: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AttributionTier {
#[default]
Off,
Capture,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AttributionValues {
pub agent_name: Option<String>,
pub harness: Option<String>,
pub model: Option<String>,
}
impl AttributionValues {
#[must_use]
pub fn resolve(
cli_agent_name: Option<&str>,
cli_harness: Option<&str>,
cli_model: Option<&str>,
env_lookup: &dyn Fn(&str) -> Option<String>,
) -> Self {
Self {
agent_name: cli_agent_name
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.or_else(|| env_lookup(ENV_AGENT_NAME).filter(|s| !s.trim().is_empty())),
harness: cli_harness
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.or_else(|| env_lookup(ENV_HARNESS).filter(|s| !s.trim().is_empty())),
model: cli_model
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.or_else(|| env_lookup(ENV_MODEL).filter(|s| !s.trim().is_empty())),
}
}
#[must_use]
pub fn resolve_from_env(
cli_agent_name: Option<&str>,
cli_harness: Option<&str>,
cli_model: Option<&str>,
) -> Self {
Self::resolve(cli_agent_name, cli_harness, cli_model, &|key| {
std::env::var(key).ok()
})
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.agent_name.is_none() && self.harness.is_none() && self.model.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyViolation {
pub gate: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default)]
pub struct CloseEvidence<'a> {
pub issue_id: &'a str,
pub close_reason: Option<&'a str>,
pub description: Option<&'a str>,
pub design: Option<&'a str>,
pub acceptance_criteria: Option<&'a str>,
pub notes: Option<&'a str>,
pub close_actor: &'a str,
pub in_progress_actor: Option<&'a str>,
}
#[must_use]
pub fn evaluate(policy: &ClosePolicy, evidence: &CloseEvidence<'_>) -> Vec<PolicyViolation> {
let mut violations = Vec::new();
if policy.require_close_reason.enabled {
evaluate_close_reason(&policy.require_close_reason, evidence, &mut violations);
}
if policy.require_acceptance_criteria_satisfied.enabled {
evaluate_acceptance_criteria(evidence, &mut violations);
}
if policy.forbid_self_close_after_in_progress.enabled {
evaluate_self_close(evidence, &mut violations);
}
if policy.require_typed_references.enabled {
evaluate_typed_references(&policy.require_typed_references, evidence, &mut violations);
}
violations
}
fn extract_typed_references(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
let preceding_ok =
i == 0 || matches!(bytes[i - 1], b' ' | b'\n' | b'\t' | b'(' | b'[' | b',');
if !preceding_ok {
i += 1;
continue;
}
if !bytes[i].is_ascii_lowercase() {
i += 1;
continue;
}
let kind_start = i;
while i < bytes.len()
&& (bytes[i].is_ascii_lowercase() || bytes[i] == b'-' || bytes[i].is_ascii_digit())
{
i += 1;
}
if i == kind_start || i >= bytes.len() || bytes[i] != b':' {
i += 1;
continue;
}
let kind = &text[kind_start..i];
i += 1; let value_start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b',' {
i += 1;
}
let value = &text[value_start..i];
if !value.is_empty() && kind.len() >= 2 {
out.push((kind.to_string(), value.to_string()));
}
}
out
}
fn required_typed_reference_description(rule: &RequireTypedReferences) -> String {
if rule.required_kinds.is_empty() {
BUILTIN_TYPED_REFERENCE_KINDS.join(", ")
} else {
rule.required_kinds.join(", ")
}
}
fn typed_reference_kind_satisfies_rule(kind: &str, rule: &RequireTypedReferences) -> bool {
if rule.required_kinds.is_empty() {
return BUILTIN_TYPED_REFERENCE_KINDS.contains(&kind);
}
rule.required_kinds.iter().any(|required| required == kind)
}
fn evaluate_typed_references(
rule: &RequireTypedReferences,
evidence: &CloseEvidence<'_>,
out: &mut Vec<PolicyViolation>,
) {
let reason_text = evidence.close_reason.unwrap_or("");
let refs = extract_typed_references(reason_text);
let required_description = required_typed_reference_description(rule);
if refs.is_empty() {
out.push(PolicyViolation {
gate: "typed_references_required".to_string(),
message: format!(
"close_reason has no typed references; policy requires at least one of: {}",
required_description
),
detail: Some(serde_json::json!({
"required_kinds": &rule.required_kinds,
"issue_id": evidence.issue_id,
})),
});
return;
}
let found_kinds: Vec<&str> = refs
.iter()
.map(|(kind, _)| kind.as_str())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let satisfied = found_kinds
.iter()
.any(|kind| typed_reference_kind_satisfies_rule(kind, rule));
if !satisfied {
out.push(PolicyViolation {
gate: "typed_references_required_kind_missing".to_string(),
message: format!(
"close_reason has typed refs ({}) but none satisfy the required kinds: {}",
found_kinds.join(", "),
required_description
),
detail: Some(serde_json::json!({
"required_kinds": &rule.required_kinds,
"found_kinds": found_kinds,
"issue_id": evidence.issue_id,
})),
});
}
}
fn evaluate_close_reason(
rule: &RequireCloseReason,
evidence: &CloseEvidence<'_>,
out: &mut Vec<PolicyViolation>,
) {
let reason_text = evidence.close_reason.map(str::trim).unwrap_or("");
let actual_len = reason_text.chars().count();
if rule.min_length > 0 && actual_len < rule.min_length {
out.push(PolicyViolation {
gate: "close_reason_min_length".to_string(),
message: format!(
"close_reason fails policy: minimum length is {} chars (got {})",
rule.min_length, actual_len
),
detail: Some(serde_json::json!({
"min_length": rule.min_length,
"actual_length": actual_len,
"issue_id": evidence.issue_id,
})),
});
}
if let Some(pattern) = rule.regex.as_deref() {
match Regex::new(pattern) {
Ok(re) => {
if !re.is_match(reason_text) {
out.push(PolicyViolation {
gate: "close_reason_regex".to_string(),
message: format!(
"close_reason fails policy: must match pattern '{pattern}'"
),
detail: Some(serde_json::json!({
"pattern": pattern,
"issue_id": evidence.issue_id,
})),
});
}
}
Err(err) => {
out.push(PolicyViolation {
gate: "close_reason_regex_invalid".to_string(),
message: format!(
"policy.yaml close_reason regex is invalid ('{pattern}'): {err}"
),
detail: Some(serde_json::json!({
"pattern": pattern,
"parse_error": err.to_string(),
})),
});
}
}
}
}
fn evaluate_acceptance_criteria(evidence: &CloseEvidence<'_>, out: &mut Vec<PolicyViolation>) {
let candidates = [
evidence.acceptance_criteria,
evidence.description,
evidence.design,
evidence.notes,
];
let mut unchecked: Vec<String> = Vec::new();
for body in candidates.into_iter().flatten() {
unchecked.extend(find_unchecked_acceptance_criteria(body));
}
unchecked.sort();
unchecked.dedup();
if !unchecked.is_empty() {
let preview: Vec<String> = unchecked.iter().take(3).cloned().collect();
let suffix = if unchecked.len() > preview.len() {
format!(" (+{} more)", unchecked.len() - preview.len())
} else {
String::new()
};
out.push(PolicyViolation {
gate: "acceptance_criteria_unchecked".to_string(),
message: format!(
"acceptance criteria policy: {} unchecked criteria remain: {}{}",
unchecked.len(),
preview.join("; "),
suffix
),
detail: Some(serde_json::json!({
"unchecked_count": unchecked.len(),
"unchecked_items": unchecked,
"issue_id": evidence.issue_id,
})),
});
}
}
fn evaluate_self_close(evidence: &CloseEvidence<'_>, out: &mut Vec<PolicyViolation>) {
let Some(in_progress_actor) = evidence.in_progress_actor else {
return;
};
if in_progress_actor.is_empty() || evidence.close_actor.is_empty() {
return;
}
if in_progress_actor == evidence.close_actor {
out.push(PolicyViolation {
gate: "forbid_self_close_after_in_progress".to_string(),
message: format!(
"actor policy: close.actor '{}' matches the actor who set in_progress; cross-validation required",
evidence.close_actor
),
detail: Some(serde_json::json!({
"close_actor": evidence.close_actor,
"in_progress_actor": in_progress_actor,
"issue_id": evidence.issue_id,
})),
});
}
}
pub const GATE_FORBID_CLOSE_WITH_DEFERRED_DEPENDENTS: &str =
"forbid_close_with_deferred_dependents";
#[must_use]
pub fn deferred_dependents_violation(
issue_id: &str,
deferred_dependent_ids: &[String],
) -> Option<PolicyViolation> {
if deferred_dependent_ids.is_empty() {
return None;
}
let mut ids: Vec<String> = deferred_dependent_ids.to_vec();
ids.sort();
ids.dedup();
let id_list = ids.join(", ");
let message = format!(
"deferred-dependents policy: cannot close {issue_id}: it has {count} deferred dependent(s): {id_list}. \
Reopen each (`br update <dep> --status=open`) or close-as-superseded with a duplicate_of edge \
before closing {issue_id}.",
count = ids.len(),
);
Some(PolicyViolation {
gate: GATE_FORBID_CLOSE_WITH_DEFERRED_DEPENDENTS.to_string(),
message,
detail: Some(serde_json::json!({
"issue_id": issue_id,
"deferred_dependents": ids,
"deferred_dependent_count": ids.len(),
})),
})
}
#[must_use]
pub fn find_unchecked_acceptance_criteria(body: &str) -> Vec<String> {
let body = body.trim();
if body.is_empty() {
return Vec::new();
}
let mut in_section = false;
let mut found_header = false;
let mut out: Vec<String> = Vec::new();
let has_any_header = has_markdown_heading_outside_fences(body);
if !has_any_header {
in_section = true;
}
let mut fence_marker = None;
for line in body.lines() {
if update_code_fence(line, &mut fence_marker) || fence_marker.is_some() {
continue;
}
let trimmed = line.trim_start();
if let Some(header_text) = markdown_heading_text(trimmed) {
let lower = header_text.to_ascii_lowercase();
if lower.contains("acceptance criteria") {
in_section = true;
found_header = true;
continue;
}
if found_header {
in_section = false;
}
continue;
}
if !in_section {
continue;
}
if let Some(item) = parse_unchecked_box(trimmed) {
out.push(item);
}
}
out
}
fn has_markdown_heading_outside_fences(body: &str) -> bool {
let mut fence_marker = None;
for line in body.lines() {
if update_code_fence(line, &mut fence_marker) || fence_marker.is_some() {
continue;
}
if markdown_heading_text(line).is_some() {
return true;
}
}
false
}
fn update_code_fence(line: &str, fence_marker: &mut Option<char>) -> bool {
let trimmed = line.trim_start();
let Some(marker @ ('`' | '~')) = trimmed.chars().next() else {
return false;
};
let marker_len = trimmed.chars().take_while(|ch| *ch == marker).count();
if marker_len < 3 {
return false;
}
if fence_marker.is_some_and(|open_marker| open_marker == marker) {
*fence_marker = None;
} else if fence_marker.is_none() {
*fence_marker = Some(marker);
}
true
}
fn markdown_heading_text(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let level = trimmed
.as_bytes()
.iter()
.take_while(|byte| **byte == b'#')
.count();
if !(1..=6).contains(&level) {
return None;
}
let rest = trimmed.get(level..)?;
if rest.chars().next().is_some_and(|ch| !ch.is_whitespace()) {
return None;
}
Some(rest.trim())
}
fn parse_unchecked_box(line: &str) -> Option<String> {
let mut chars = line.chars().peekable();
let bullet = chars.next()?;
if !matches!(bullet, '-' | '*' | '+') {
return None;
}
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else {
break;
}
}
if chars.next()? != '[' {
return None;
}
let inner = chars.next()?;
let inner_is_unchecked = inner.is_whitespace() || inner == ' ';
if !inner_is_unchecked {
return None;
}
if chars.next()? != ']' {
return None;
}
let rest: String = chars.collect();
let rest = rest.trim().to_string();
Some(rest)
}
pub fn load_for_beads_dir(beads_dir: &Path) -> Result<PolicyDocument> {
let path = beads_dir.join(POLICY_FILE_NAME);
if !path.exists() {
return Ok(PolicyDocument::default());
}
let raw = fs::read_to_string(&path).map_err(BeadsError::from)?;
let document: PolicyDocument = serde_yml::from_str(&raw).map_err(|err| {
BeadsError::Config(format!("failed to parse {}: {}", path.display(), err))
})?;
if let Ok(raw_value) = serde_yml::from_str::<serde_yml::Value>(&raw) {
let unknown = detect_unknown_policy_fields(&raw_value);
if !unknown.is_empty() {
tracing::warn!(
policy_path = %path.display(),
unknown_fields = ?unknown,
"policy.yaml contains {} unknown field(s) under close_policy structs; \
these were ignored (beads_rust#302). Check for typos: {}",
unknown.len(),
unknown.join(", "),
);
}
}
Ok(document)
}
#[must_use]
pub fn detect_unknown_policy_fields(root: &serde_yml::Value) -> Vec<String> {
let mut unknown = Vec::new();
walk_policy_node(root, PolicyNode::Document, "", &mut unknown);
unknown.sort();
unknown.dedup();
unknown
}
#[derive(Clone, Copy, Debug)]
enum PolicyNode {
Document,
ClosePolicy,
RequireCloseReason,
ToggleGate,
Attribution,
RequireTypedReferences,
Workflow,
StatusGroups,
Scalar,
}
impl PolicyNode {
const fn child_table(self) -> &'static [(&'static str, Self)] {
match self {
Self::Document => &[
("close_policy", Self::ClosePolicy),
("workflow", Self::Workflow),
("allow_bypass", Self::Scalar),
],
Self::ClosePolicy => &[
("require_close_reason", Self::RequireCloseReason),
("require_acceptance_criteria_satisfied", Self::ToggleGate),
("forbid_self_close_after_in_progress", Self::ToggleGate),
("forbid_close_with_deferred_dependents", Self::ToggleGate),
("attribution", Self::Attribution),
("require_typed_references", Self::RequireTypedReferences),
],
Self::RequireCloseReason => &[
("enabled", Self::Scalar),
("min_length", Self::Scalar),
("regex", Self::Scalar),
],
Self::ToggleGate => &[("enabled", Self::Scalar)],
Self::Attribution => &[("tier", Self::Scalar), ("fields", Self::Scalar)],
Self::RequireTypedReferences => {
&[("enabled", Self::Scalar), ("required_kinds", Self::Scalar)]
}
Self::Workflow => &[
("strict", Self::Scalar),
("statuses", Self::Scalar),
("transitions", Self::Scalar),
("gates", Self::Scalar),
("status_groups", Self::StatusGroups),
],
Self::StatusGroups => &[("ready", Self::Scalar)],
Self::Scalar => &[],
}
}
}
fn walk_policy_node(
value: &serde_yml::Value,
node: PolicyNode,
scope: &str,
out: &mut Vec<String>,
) {
if matches!(node, PolicyNode::Scalar) {
return;
}
let Some(map) = value.as_mapping() else {
return;
};
let table = node.child_table();
for (key, sub) in map {
let Some(key_str) = key.as_str() else {
continue;
};
let path = if scope.is_empty() {
key_str.to_string()
} else {
format!("{scope}.{key_str}")
};
match table.iter().find(|(k, _)| *k == key_str) {
Some((_, child)) => walk_policy_node(sub, *child, &path, out),
None => out.push(path),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn evidence_with_reason<'a>(reason: &'a str, issue_id: &'a str) -> CloseEvidence<'a> {
CloseEvidence {
issue_id,
close_reason: Some(reason),
close_actor: "alice",
..Default::default()
}
}
#[test]
fn ready_status_group_defaults_to_open() {
let workflow = Workflow::default();
assert_eq!(workflow.ready_status_group(), vec!["open".to_string()]);
}
#[test]
fn ready_status_group_uses_configured_values_normalized() {
let mut workflow = Workflow::default();
workflow.status_groups.ready = vec![
"Open".to_string(),
" rework ".to_string(),
"open".to_string(),
];
assert_eq!(
workflow.ready_status_group(),
vec!["open".to_string(), "rework".to_string()]
);
}
#[test]
fn validate_ready_group_accepts_subset_when_strict() {
let mut workflow = Workflow {
strict: true,
statuses: vec![
"open".to_string(),
"rework".to_string(),
"closed".to_string(),
],
..Workflow::default()
};
workflow.status_groups.ready = vec!["open".to_string(), "rework".to_string()];
assert!(workflow.validate_ready_status_group().is_ok());
}
#[test]
fn validate_ready_group_rejects_out_of_vocab_when_strict() {
let mut workflow = Workflow {
strict: true,
statuses: vec!["open".to_string(), "closed".to_string()],
..Workflow::default()
};
workflow.status_groups.ready = vec!["open".to_string(), "rework".to_string()];
let err = workflow
.validate_ready_status_group()
.expect_err("out-of-vocab group must be rejected under strict");
let msg = err.to_string();
assert!(
msg.contains("rework"),
"error should name the bad status: {msg}"
);
}
#[test]
fn validate_ready_group_accepts_anything_when_not_strict() {
let mut workflow = Workflow {
strict: false,
statuses: vec!["open".to_string()],
..Workflow::default()
};
workflow.status_groups.ready = vec!["open".to_string(), "rework".to_string()];
assert!(workflow.validate_ready_status_group().is_ok());
workflow.strict = true;
workflow.statuses = vec![];
assert!(workflow.validate_ready_status_group().is_ok());
}
#[test]
fn status_groups_parse_from_yaml_and_not_flagged_unknown() {
let raw = "workflow:\n status_groups:\n ready: [open, rework]\n";
let doc: PolicyDocument = serde_yml::from_str(raw).unwrap();
assert_eq!(
doc.workflow.status_groups.ready,
vec!["open".to_string(), "rework".to_string()]
);
let value: serde_yml::Value = serde_yml::from_str(raw).unwrap();
let unknown = detect_unknown_policy_fields(&value);
assert!(
unknown.is_empty(),
"status_groups.ready must not be flagged unknown: {unknown:?}"
);
}
#[test]
fn default_policy_is_inactive() {
let policy = ClosePolicy::default();
assert!(!policy.is_active());
let evidence = evidence_with_reason("anything", "bd-1");
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn close_reason_min_length_rejects_short_reason() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 20;
let violations = evaluate(&policy, &evidence_with_reason("too short", "bd-1"));
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "close_reason_min_length");
assert!(violations[0].message.contains("minimum length is 20"));
assert!(violations[0].message.contains("got 9"));
}
#[test]
fn close_reason_min_length_counts_unicode_chars_not_bytes() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 4;
let violations = evaluate(&policy, &evidence_with_reason("😀😀😀😀", "bd-1"));
assert!(violations.is_empty(), "{:?}", violations);
}
#[test]
fn close_reason_min_length_zero_disables_length_check() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 0;
let violations = evaluate(&policy, &evidence_with_reason("", "bd-1"));
assert!(violations.is_empty());
}
#[test]
fn close_reason_min_length_treats_missing_reason_as_empty() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 5;
let evidence = CloseEvidence {
issue_id: "bd-1",
close_reason: None,
close_actor: "alice",
..Default::default()
};
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "close_reason_min_length");
}
#[test]
fn close_reason_regex_rejects_non_match() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 0;
policy.require_close_reason.regex = Some(r"^[A-Z][a-z]+: ".to_string());
let violations = evaluate(&policy, &evidence_with_reason("done", "bd-1"));
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "close_reason_regex");
}
#[test]
fn close_reason_regex_accepts_match() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 0;
policy.require_close_reason.regex = Some(r"^Fix: ".to_string());
let violations = evaluate(&policy, &evidence_with_reason("Fix: race in foo", "bd-1"));
assert!(violations.is_empty());
}
#[test]
fn close_reason_invalid_regex_surfaces_a_violation() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 0;
policy.require_close_reason.regex = Some("(unclosed".to_string());
let violations = evaluate(&policy, &evidence_with_reason("anything goes here", "bd-1"));
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "close_reason_regex_invalid");
}
#[test]
fn acceptance_criteria_unchecked_blocks_close() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Acceptance Criteria\n- [x] First\n- [ ] Second\n- [ ] Third\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "acceptance_criteria_unchecked");
assert!(
violations[0].message.contains("2 unchecked"),
"{}",
violations[0].message
);
}
#[test]
fn acceptance_criteria_passes_when_all_checked() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Acceptance Criteria\n- [x] First\n- [X] Second\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn acceptance_criteria_only_scans_section_under_header() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Notes\n- [ ] random todo not under AC\n## Acceptance Criteria\n- [x] all good\n## Out of section\n- [ ] this is ignored\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
assert!(
evaluate(&policy, &evidence).is_empty(),
"TODO outside AC section should NOT block close"
);
}
#[test]
fn acceptance_criteria_does_not_treat_hash_references_as_section_headers() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body =
"## Acceptance Criteria\n#123 tracks the rollout\n- [ ] Finish after the reference\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "acceptance_criteria_unchecked");
assert!(violations[0].message.contains("Finish after the reference"));
}
#[test]
fn acceptance_criteria_ignores_section_headers_inside_fenced_code() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Notes\n```markdown\n## Acceptance Criteria\n- [ ] Example only\n```\n## Acceptance Criteria\n- [x] Real criterion\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
assert!(
evaluate(&policy, &evidence).is_empty(),
"unchecked boxes inside fenced examples should not block close"
);
}
#[test]
fn acceptance_criteria_ignores_unchecked_boxes_inside_fenced_code() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Acceptance Criteria\n- [x] Real criterion\n```sh\n- [ ] Example only\n```\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
assert!(
evaluate(&policy, &evidence).is_empty(),
"unchecked boxes inside fenced examples should not block close"
);
}
#[test]
fn acceptance_criteria_without_markdown_headers_scans_hash_prefixed_body() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.acceptance_criteria = Some("#123 follow-up\n- [ ] Finish referenced work\n");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "acceptance_criteria_unchecked");
assert!(violations[0].message.contains("Finish referenced work"));
}
#[test]
fn acceptance_criteria_dedupes_across_fields() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some("## Acceptance Criteria\n- [ ] First\n");
evidence.acceptance_criteria = Some("- [ ] First\n");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
let detail = violations[0].detail.as_ref().unwrap();
assert_eq!(detail["unchecked_count"], 1);
}
#[test]
fn acceptance_criteria_handles_acceptance_criteria_column_without_header() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.acceptance_criteria = Some("- [x] First\n- [ ] Second\n");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Second"));
}
#[test]
fn forbid_self_close_blocks_when_actors_match() {
let mut policy = ClosePolicy::default();
policy.forbid_self_close_after_in_progress.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.close_actor = "alice";
evidence.in_progress_actor = Some("alice");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "forbid_self_close_after_in_progress");
}
#[test]
fn forbid_self_close_passes_when_actors_differ() {
let mut policy = ClosePolicy::default();
policy.forbid_self_close_after_in_progress.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.close_actor = "alice";
evidence.in_progress_actor = Some("bob");
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn forbid_self_close_passes_when_no_in_progress_recorded() {
let mut policy = ClosePolicy::default();
policy.forbid_self_close_after_in_progress.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.close_actor = "alice";
evidence.in_progress_actor = None;
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn deferred_dependents_gate_default_off() {
let policy = ClosePolicy::default();
assert!(!policy.forbid_close_with_deferred_dependents.enabled);
assert!(!policy.is_active());
}
#[test]
fn deferred_dependents_gate_makes_policy_active_when_enabled() {
let policy = ClosePolicy {
forbid_close_with_deferred_dependents: ToggleGate { enabled: true },
..Default::default()
};
assert!(policy.is_active());
}
#[test]
fn deferred_dependents_violation_none_when_empty() {
assert!(deferred_dependents_violation("bd-1", &[]).is_none());
}
#[test]
fn deferred_dependents_violation_names_offending_ids() {
let violation =
deferred_dependents_violation("bd-1", &["bd-3".to_string(), "bd-2".to_string()])
.expect("violation expected");
assert_eq!(violation.gate, GATE_FORBID_CLOSE_WITH_DEFERRED_DEPENDENTS);
assert!(violation.message.contains("bd-2"), "{}", violation.message);
assert!(violation.message.contains("bd-3"), "{}", violation.message);
assert!(
violation.message.contains("2 deferred dependent"),
"{}",
violation.message
);
assert!(
violation.message.contains("br update <dep> --status=open"),
"{}",
violation.message
);
assert!(
violation.message.contains("duplicate_of"),
"{}",
violation.message
);
let detail = violation.detail.as_ref().unwrap();
assert_eq!(detail["issue_id"], "bd-1");
assert_eq!(detail["deferred_dependent_count"], 2);
assert_eq!(
detail["deferred_dependents"],
serde_json::json!(["bd-2", "bd-3"])
);
}
#[test]
fn deferred_dependents_violation_dedupes_ids() {
let violation = deferred_dependents_violation(
"bd-1",
&["bd-2".to_string(), "bd-2".to_string(), "bd-3".to_string()],
)
.expect("violation expected");
let detail = violation.detail.as_ref().unwrap();
assert_eq!(detail["deferred_dependent_count"], 2);
assert!(violation.message.contains("2 deferred dependent"));
}
#[test]
fn attribution_resolve_prefers_cli_over_env() {
let env = |key: &str| match key {
ENV_AGENT_NAME => Some("env-agent".to_string()),
ENV_HARNESS => Some("env-harness".to_string()),
ENV_MODEL => Some("env-model".to_string()),
_ => None,
};
let values = AttributionValues::resolve(Some("cli-agent"), Some("cli-harness"), None, &env);
assert_eq!(values.agent_name.as_deref(), Some("cli-agent"));
assert_eq!(values.harness.as_deref(), Some("cli-harness"));
assert_eq!(values.model.as_deref(), Some("env-model"));
}
#[test]
fn attribution_resolve_treats_blank_strings_as_absent() {
let env = |key: &str| {
if key == ENV_HARNESS {
Some(" ".to_string())
} else {
None
}
};
let values = AttributionValues::resolve(Some(""), None, None, &env);
assert!(values.agent_name.is_none());
assert!(
values.harness.is_none(),
"blank env var should not populate"
);
assert!(values.model.is_none());
assert!(values.is_empty());
}
#[test]
fn multiple_gates_aggregate_violations() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 50;
policy.forbid_self_close_after_in_progress.enabled = true;
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Acceptance Criteria\n- [ ] Outstanding\n";
let mut evidence = evidence_with_reason("short", "bd-1");
evidence.description = Some(body);
evidence.close_actor = "alice";
evidence.in_progress_actor = Some("alice");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 3);
let gates: Vec<&str> = violations.iter().map(|v| v.gate.as_str()).collect();
assert!(gates.contains(&"close_reason_min_length"));
assert!(gates.contains(&"acceptance_criteria_unchecked"));
assert!(gates.contains(&"forbid_self_close_after_in_progress"));
}
#[test]
fn loader_returns_default_when_file_absent() {
let dir = tempfile::tempdir().expect("tempdir");
let policy = load_for_beads_dir(dir.path()).expect("load");
assert_eq!(policy, PolicyDocument::default());
assert!(!policy.close_policy.is_active());
assert!(policy.allow_bypass);
}
#[test]
fn loader_parses_full_document() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = r#"
close_policy:
require_close_reason:
enabled: true
min_length: 30
regex: "^Fix: "
require_acceptance_criteria_satisfied:
enabled: true
forbid_self_close_after_in_progress:
enabled: true
require_typed_references:
enabled: true
required_kinds: ["commit", "reviewer"]
attribution:
tier: capture
fields: ["agent_name", "harness", "model"]
allow_bypass: false
"#;
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load");
assert!(policy.close_policy.require_close_reason.enabled);
assert_eq!(policy.close_policy.require_close_reason.min_length, 30);
assert_eq!(
policy.close_policy.require_close_reason.regex.as_deref(),
Some("^Fix: ")
);
assert!(
policy
.close_policy
.require_acceptance_criteria_satisfied
.enabled
);
assert!(
policy
.close_policy
.forbid_self_close_after_in_progress
.enabled
);
assert!(policy.close_policy.require_typed_references.enabled);
assert_eq!(
policy.close_policy.require_typed_references.required_kinds,
vec!["commit".to_string(), "reviewer".to_string()]
);
assert_eq!(
policy.close_policy.attribution.tier,
AttributionTier::Capture
);
assert!(!policy.allow_bypass);
assert!(policy.close_policy.is_active());
}
#[test]
fn loader_tolerates_unknown_top_level_keys() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = "unknown_key: 1\nclose_policy:\n require_close_reason:\n enabled: true\n";
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load must succeed");
assert!(
policy.close_policy.require_close_reason.enabled,
"known fields must still parse"
);
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
let unknown = detect_unknown_policy_fields(&raw);
assert_eq!(unknown, vec!["unknown_key".to_string()]);
}
#[test]
fn loader_tolerates_unknown_field_under_close_policy() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = r"
close_policy:
require_close_reason:
enabled: true
min_length: 20
require_new_experimental_field:
enabled: true
";
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load must succeed");
assert!(policy.close_policy.require_close_reason.enabled);
assert_eq!(policy.close_policy.require_close_reason.min_length, 20);
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
let unknown = detect_unknown_policy_fields(&raw);
assert_eq!(
unknown,
vec!["close_policy.require_new_experimental_field".to_string()]
);
}
#[test]
fn detect_unknown_policy_fields_walks_nested_structs() {
let yaml = r#"
close_policy:
require_close_reason:
enabled: true
min_lenght: 20 # typo: should be min_length
attribution:
tier: capture
fileds: ["agent_name"] # typo: should be fields
"#;
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
let unknown = detect_unknown_policy_fields(&raw);
assert_eq!(
unknown,
vec![
"close_policy.attribution.fileds".to_string(),
"close_policy.require_close_reason.min_lenght".to_string(),
]
);
}
#[test]
fn detect_unknown_policy_fields_is_empty_for_canonical_doc() {
let yaml = r#"
allow_bypass: false
close_policy:
require_close_reason:
enabled: true
min_length: 30
regex: "^Fix: "
require_acceptance_criteria_satisfied:
enabled: true
forbid_self_close_after_in_progress:
enabled: true
require_typed_references:
enabled: true
required_kinds: ["commit", "reviewer"]
attribution:
tier: capture
fields: ["agent_name", "harness", "model"]
"#;
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
assert!(detect_unknown_policy_fields(&raw).is_empty());
}
#[test]
fn loader_accepts_empty_document() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(POLICY_FILE_NAME), "{}\n").unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load");
assert_eq!(policy, PolicyDocument::default());
}
#[test]
fn parse_unchecked_box_recognises_variants() {
assert_eq!(
parse_unchecked_box("- [ ] todo item").as_deref(),
Some("todo item")
);
assert_eq!(
parse_unchecked_box("* [ ] starred").as_deref(),
Some("starred")
);
assert_eq!(parse_unchecked_box("+ [ ] plus").as_deref(), Some("plus"));
assert!(parse_unchecked_box("- [x] checked").is_none());
assert!(parse_unchecked_box("- [X] checked").is_none());
assert!(parse_unchecked_box("plain text").is_none());
assert!(parse_unchecked_box("- not a box").is_none());
}
#[test]
fn extract_typed_references_finds_kind_value_pairs() {
let refs = extract_typed_references("Fixed in commit:abc123 per reviewer:bob");
assert_eq!(
refs,
vec![
("commit".to_string(), "abc123".to_string()),
("reviewer".to_string(), "bob".to_string()),
]
);
}
#[test]
fn extract_typed_references_handles_hyphenated_kinds() {
let refs = extract_typed_references("see agent-mail:thread-xyz for context");
assert_eq!(
refs,
vec![("agent-mail".to_string(), "thread-xyz".to_string())]
);
}
#[test]
fn extract_typed_references_skips_prose_with_colons() {
let refs = extract_typed_references("note: this is a regular sentence");
assert!(refs.is_empty(), "got {refs:?}");
}
#[test]
fn extract_typed_references_requires_letter_start() {
let refs = extract_typed_references("bad refs: 12:abc and -kind:value");
assert!(refs.is_empty(), "got {refs:?}");
}
#[test]
fn typed_references_gate_rejects_when_none_present() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec![],
},
..Default::default()
};
let evidence = evidence_with_reason("just plain prose with no refs at all", "bd-1");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "typed_references_required");
}
#[test]
fn typed_references_gate_empty_required_kinds_accepts_builtin_kind() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec![],
},
..Default::default()
};
let evidence = evidence_with_reason("Fixed in reviewer:bob", "bd-1");
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn typed_references_gate_empty_required_kinds_rejects_unknown_kind() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec![],
},
..Default::default()
};
let evidence = evidence_with_reason("Captured in tracker:ABC-123", "bd-1");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "typed_references_required_kind_missing");
}
#[test]
fn typed_references_gate_empty_required_kinds_rejects_bare_url() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec![],
},
..Default::default()
};
let evidence = evidence_with_reason("Evidence: https://example.invalid/report", "bd-1");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "typed_references_required_kind_missing");
}
#[test]
fn typed_references_gate_accepts_when_kind_matches() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec!["commit".to_string()],
},
..Default::default()
};
let evidence = evidence_with_reason("Fixed in commit:abc12345", "bd-1");
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn typed_references_gate_rejects_wrong_kind() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec!["commit".to_string()],
},
..Default::default()
};
let evidence = evidence_with_reason("see investigation:linear-XYZ-42 for details", "bd-1");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "typed_references_required_kind_missing");
}
#[test]
fn policy_node_child_table_covers_every_typed_struct_field() {
fn field_names_of<T: serde::Serialize + Default>() -> Vec<String> {
let value =
serde_yml::to_value(T::default()).expect("default struct must serialise to value");
let mapping = value
.as_mapping()
.expect("default struct must serialise as a mapping");
mapping
.iter()
.filter_map(|(k, _)| k.as_str().map(String::from))
.collect()
}
fn assert_table_covers(node: PolicyNode, struct_fields: &[String], struct_name: &str) {
let table_keys: std::collections::HashSet<&'static str> =
node.child_table().iter().map(|(k, _)| *k).collect();
for field in struct_fields {
assert!(
table_keys.contains(field.as_str()),
"PolicyNode::{node:?}::child_table() is missing key `{field}` declared on \
struct `{struct_name}`. `detect_unknown_policy_fields` would emit a \
FALSE-POSITIVE 'unknown field' warning on every canonical policy.yaml that \
uses this field. Add the entry to `child_table()` (see beads_rust#302).",
);
}
}
assert_table_covers(
PolicyNode::Document,
&field_names_of::<PolicyDocument>(),
"PolicyDocument",
);
assert_table_covers(
PolicyNode::ClosePolicy,
&field_names_of::<ClosePolicy>(),
"ClosePolicy",
);
assert_table_covers(
PolicyNode::RequireCloseReason,
&field_names_of::<RequireCloseReason>(),
"RequireCloseReason",
);
assert_table_covers(
PolicyNode::ToggleGate,
&field_names_of::<ToggleGate>(),
"ToggleGate",
);
assert_table_covers(
PolicyNode::Attribution,
&field_names_of::<Attribution>(),
"Attribution",
);
assert_table_covers(
PolicyNode::RequireTypedReferences,
&field_names_of::<RequireTypedReferences>(),
"RequireTypedReferences",
);
assert_table_covers(
PolicyNode::Workflow,
&field_names_of::<Workflow>(),
"Workflow",
);
}
#[test]
fn policy_node_child_table_has_no_stale_entries() {
fn field_names_of<T: serde::Serialize + Default>() -> std::collections::HashSet<String> {
let value =
serde_yml::to_value(T::default()).expect("default struct must serialise to value");
let mapping = value
.as_mapping()
.expect("default struct must serialise as a mapping");
mapping
.iter()
.filter_map(|(k, _)| k.as_str().map(String::from))
.collect()
}
fn assert_no_stale(
node: PolicyNode,
struct_fields: &std::collections::HashSet<String>,
struct_name: &str,
) {
for (key, _) in node.child_table() {
assert!(
struct_fields.contains(*key),
"PolicyNode::{node:?}::child_table() lists key `{key}` that does not exist \
on struct `{struct_name}`. A typo of this key in policy.yaml would NOT be \
reported as unknown even though it is silently ignored by the typed parse \
(see beads_rust#302).",
);
}
}
assert_no_stale(
PolicyNode::Document,
&field_names_of::<PolicyDocument>(),
"PolicyDocument",
);
assert_no_stale(
PolicyNode::ClosePolicy,
&field_names_of::<ClosePolicy>(),
"ClosePolicy",
);
assert_no_stale(
PolicyNode::RequireCloseReason,
&field_names_of::<RequireCloseReason>(),
"RequireCloseReason",
);
assert_no_stale(
PolicyNode::ToggleGate,
&field_names_of::<ToggleGate>(),
"ToggleGate",
);
assert_no_stale(
PolicyNode::Attribution,
&field_names_of::<Attribution>(),
"Attribution",
);
assert_no_stale(
PolicyNode::RequireTypedReferences,
&field_names_of::<RequireTypedReferences>(),
"RequireTypedReferences",
);
assert_no_stale(
PolicyNode::Workflow,
&field_names_of::<Workflow>(),
"Workflow",
);
}
fn strict_workflow() -> Workflow {
Workflow {
strict: true,
statuses: vec![
"open".to_string(),
"in_progress".to_string(),
"closed".to_string(),
],
..Default::default()
}
}
#[test]
fn workflow_default_is_not_enforced() {
let workflow = Workflow::default();
assert!(!workflow.is_enforced());
assert!(workflow.validate_status("anything-at-all").is_ok());
}
#[test]
fn workflow_strict_but_empty_statuses_is_not_enforced() {
let workflow = Workflow {
strict: true,
statuses: vec![],
..Default::default()
};
assert!(!workflow.is_enforced());
assert!(workflow.validate_status("bogus").is_ok());
}
#[test]
fn workflow_rejects_status_outside_the_set() {
let workflow = strict_workflow();
let err = workflow
.validate_status("completed")
.expect_err("out-of-set status must be rejected");
let message = err.to_string();
assert!(message.contains("completed"), "{message}");
assert!(message.contains("open"), "{message}");
assert!(message.contains("in_progress"), "{message}");
assert!(message.contains("closed"), "{message}");
}
#[test]
fn workflow_allows_status_in_the_set() {
let workflow = strict_workflow();
assert!(workflow.validate_status("open").is_ok());
assert!(workflow.validate_status("in_progress").is_ok());
assert!(workflow.validate_status("closed").is_ok());
}
#[test]
fn workflow_status_match_is_case_insensitive() {
let workflow = Workflow {
strict: true,
statuses: vec!["In_Progress".to_string()],
..Default::default()
};
assert!(workflow.allows("in_progress"));
assert!(workflow.validate_status("in_progress").is_ok());
}
#[test]
fn workflow_supports_custom_statuses() {
let workflow = Workflow {
strict: true,
statuses: vec!["open".to_string(), "in_review".to_string()],
..Default::default()
};
assert!(workflow.validate_status("in_review").is_ok());
assert!(workflow.validate_status("blocked").is_err());
}
#[test]
fn loader_parses_workflow_section() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = r#"
workflow:
strict: true
statuses: ["open", "in_progress", "closed"]
"#;
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load");
assert!(policy.workflow.is_enforced());
assert_eq!(
policy.workflow.statuses,
vec![
"open".to_string(),
"in_progress".to_string(),
"closed".to_string()
]
);
assert!(policy.workflow.validate_status("open").is_ok());
assert!(policy.workflow.validate_status("completed").is_err());
}
#[test]
fn loader_absent_workflow_section_is_permissive() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = "close_policy:\n forbid_self_close_after_in_progress:\n enabled: true\n";
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load");
assert!(!policy.workflow.is_enforced());
assert!(policy.workflow.validate_status("whatever").is_ok());
}
#[test]
fn detect_unknown_policy_fields_walks_workflow_typos() {
let yaml = r#"
workflow:
strict: true
statusses: ["open"] # typo: should be statuses
"#;
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
let unknown = detect_unknown_policy_fields(&raw);
assert_eq!(unknown, vec!["workflow.statusses".to_string()]);
}
#[test]
fn detect_unknown_policy_fields_accepts_canonical_workflow() {
let yaml = r#"
workflow:
strict: true
statuses: ["open", "closed"]
"#;
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
assert!(detect_unknown_policy_fields(&raw).is_empty());
}
fn transition_workflow() -> Workflow {
let mut transitions = std::collections::BTreeMap::new();
transitions.insert(
"open".to_string(),
vec!["in_progress".to_string(), "closed".to_string()],
);
transitions.insert(
"in_progress".to_string(),
vec!["in_review".to_string(), "blocked".to_string()],
);
transitions.insert("in_review".to_string(), vec!["closed".to_string()]);
Workflow {
strict: true,
statuses: vec![],
transitions,
..Default::default()
}
}
#[test]
fn transitions_default_is_not_enforced() {
let workflow = Workflow::default();
assert!(!workflow.transitions_enforced());
assert!(workflow.validate_transition(Some("open"), "bogus").is_ok());
assert!(workflow.validate_transition(None, "bogus").is_ok());
}
#[test]
fn transitions_present_but_not_strict_is_not_enforced() {
let mut workflow = transition_workflow();
workflow.strict = false;
assert!(!workflow.transitions_enforced());
assert!(
workflow
.validate_transition(Some("open"), "deferred")
.is_ok()
);
}
#[test]
fn transitions_strict_but_empty_map_is_not_enforced() {
let workflow = Workflow {
strict: true,
statuses: vec![],
transitions: std::collections::BTreeMap::new(),
..Default::default()
};
assert!(!workflow.transitions_enforced());
assert!(
workflow
.validate_transition(Some("open"), "blocked")
.is_ok()
);
}
#[test]
fn transition_valid_move_is_allowed() {
let workflow = transition_workflow();
assert!(workflow.allows_transition("open", "in_progress"));
assert!(
workflow
.validate_transition(Some("open"), "in_progress")
.is_ok()
);
assert!(
workflow
.validate_transition(Some("in_progress"), "in_review")
.is_ok()
);
assert!(
workflow
.validate_transition(Some("in_review"), "closed")
.is_ok()
);
}
#[test]
fn transition_invalid_move_is_rejected_with_actionable_error() {
let workflow = transition_workflow();
let err = workflow
.validate_transition(Some("open"), "in_review")
.expect_err("open -> in_review is not configured");
let message = err.to_string();
assert!(message.contains("'open'"), "{message}");
assert!(message.contains("'in_review'"), "{message}");
assert!(message.contains("in_progress"), "{message}");
assert!(message.contains("closed"), "{message}");
assert!(message.contains("workflow.transitions"), "{message}");
}
#[test]
fn transition_from_status_with_no_rule_rejects_with_none_targets() {
let workflow = transition_workflow();
let err = workflow
.validate_transition(Some("blocked"), "open")
.expect_err("blocked has no allowed targets");
let message = err.to_string();
assert!(message.contains("(none)"), "{message}");
}
#[test]
fn transition_no_op_is_always_allowed() {
let workflow = transition_workflow();
assert!(workflow.validate_transition(Some("open"), "open").is_ok());
assert!(
workflow
.validate_transition(Some("blocked"), "blocked")
.is_ok()
);
}
#[test]
fn transition_is_case_insensitive() {
let workflow = transition_workflow();
assert!(
workflow
.validate_transition(Some("OPEN"), "In_Progress")
.is_ok()
);
}
#[test]
fn transition_any_wildcard_allows_target_from_every_status() {
let mut workflow = transition_workflow();
workflow
.transitions
.insert("any".to_string(), vec!["deferred".to_string()]);
assert!(
workflow
.validate_transition(Some("open"), "deferred")
.is_ok()
);
assert!(
workflow
.validate_transition(Some("blocked"), "deferred")
.is_ok()
);
let err = workflow
.validate_transition(Some("open"), "bogus")
.expect_err("bogus is not reachable");
assert!(err.to_string().contains("deferred"), "{err}");
}
#[test]
fn transition_initial_key_restricts_creates() {
let mut workflow = transition_workflow();
workflow.transitions.insert(
"initial".to_string(),
vec!["open".to_string(), "draft".to_string()],
);
assert!(workflow.validate_transition(None, "open").is_ok());
assert!(workflow.validate_transition(None, "draft").is_ok());
let err = workflow
.validate_transition(None, "in_progress")
.expect_err("in_progress is not an allowed initial status");
let message = err.to_string();
assert!(message.contains("initial"), "{message}");
assert!(message.contains("'in_progress'"), "{message}");
assert!(message.contains("open"), "{message}");
assert!(message.contains("draft"), "{message}");
}
#[test]
fn transition_absent_initial_key_accepts_any_initial_status() {
let workflow = transition_workflow();
assert!(workflow.validate_transition(None, "open").is_ok());
assert!(workflow.validate_transition(None, "anything").is_ok());
}
#[test]
fn allowed_targets_from_dedupes_and_merges_wildcard() {
let mut workflow = transition_workflow();
workflow.transitions.insert(
"any".to_string(),
vec!["closed".to_string(), "deferred".to_string()],
);
let targets = workflow.allowed_targets_from("open");
assert_eq!(
targets,
vec![
"in_progress".to_string(),
"closed".to_string(),
"deferred".to_string()
]
);
}
#[test]
fn loader_parses_workflow_transitions_section() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = r"
workflow:
strict: true
transitions:
open: [in_progress, deferred, closed]
in_progress: [in_review, blocked, open]
any: [closed]
initial: [open, draft]
";
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load");
let workflow = &policy.workflow;
assert!(workflow.transitions_enforced());
assert!(
workflow
.validate_transition(Some("open"), "in_progress")
.is_ok()
);
assert!(
workflow
.validate_transition(Some("open"), "in_review")
.is_err()
);
assert!(
workflow
.validate_transition(Some("blocked"), "closed")
.is_ok()
);
assert!(workflow.validate_transition(None, "open").is_ok());
assert!(workflow.validate_transition(None, "closed").is_err());
}
#[test]
fn loader_absent_transitions_is_not_enforced() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = "workflow:\n strict: true\n statuses: [open, in_progress, closed]\n";
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load");
assert!(policy.workflow.is_enforced());
assert!(!policy.workflow.transitions_enforced());
assert!(
policy
.workflow
.validate_transition(Some("closed"), "open")
.is_ok()
);
}
#[test]
fn detect_unknown_policy_fields_accepts_canonical_transitions() {
let yaml = r"
workflow:
strict: true
transitions:
open: [in_progress]
";
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
assert!(detect_unknown_policy_fields(&raw).is_empty());
}
#[test]
fn gates_config_parses_full_issue_schema() {
let yaml = r#"
workflow:
strict: true
gates:
"in_review -> closed":
require_all:
- ci_green
- min_reviewers: 2
require_if:
- label: security-sensitive
gate: security_sign_off
- priority: [0, 1]
gate: security_sign_off
"#;
let doc: PolicyDocument = serde_yml::from_str(yaml).expect("parse gates");
assert!(doc.workflow.gates_enforced());
let rule = doc
.workflow
.gate_rule_for("in_review", "closed")
.expect("rule present");
assert_eq!(rule.require_all.len(), 2);
assert_eq!(rule.require_all[0], GateSpec::Named("ci_green".to_string()));
assert_eq!(rule.require_all[1], GateSpec::MinReviewers(2));
assert_eq!(rule.require_if.len(), 2);
assert_eq!(
rule.require_if[0].label.as_deref(),
Some("security-sensitive")
);
assert_eq!(rule.require_if[1].priority, vec![0, 1]);
assert_eq!(
rule.require_if[1].gate,
GateSpec::Named("security_sign_off".to_string())
);
}
#[test]
fn gates_disabled_when_strict_false_or_empty() {
let yaml =
"workflow:\n strict: false\n gates:\n \"a -> b\":\n require_all: [ci]\n";
let doc: PolicyDocument = serde_yml::from_str(yaml).unwrap();
assert!(!doc.workflow.gates_enforced());
let doc2: PolicyDocument = serde_yml::from_str("workflow:\n strict: true\n").unwrap();
assert!(!doc2.workflow.gates_enforced());
}
#[test]
fn gates_key_parsing_is_whitespace_and_case_insensitive() {
let yaml = "workflow:\n strict: true\n gates:\n \"In_Review->Closed\":\n require_all: [ci_green]\n";
let doc: PolicyDocument = serde_yml::from_str(yaml).unwrap();
assert!(doc.workflow.gate_rule_for("in_review", "closed").is_some());
assert!(doc.workflow.gate_rule_for("IN_REVIEW", "CLOSED").is_some());
assert!(doc.workflow.gate_rule_for("open", "closed").is_none());
}
fn gate_workflow() -> Workflow {
let yaml = r#"
strict: true
gates:
"in_review -> closed":
require_all:
- ci_green
- min_reviewers: 1
require_if:
- label: security-sensitive
gate: security_sign_off
- priority: [0]
gate: p0_sign_off
"#;
serde_yml::from_str(yaml).expect("parse gate workflow")
}
#[test]
fn required_gates_includes_unconditional_only_when_no_condition_matches() {
let workflow = gate_workflow();
let required = workflow.required_gates_for("in_review", "closed", &[], 3);
let ids: Vec<&str> = required.iter().map(GateSpec::id).collect();
assert_eq!(ids, vec!["ci_green", "min_reviewers"]);
}
#[test]
fn required_gates_adds_conditional_on_label_match() {
let workflow = gate_workflow();
let labels = vec!["security-sensitive".to_string()];
let required = workflow.required_gates_for("in_review", "closed", &labels, 3);
assert!(
required
.iter()
.map(GateSpec::id)
.any(|id| id == "security_sign_off")
);
}
#[test]
fn required_gates_adds_conditional_on_priority_match() {
let workflow = gate_workflow();
let required = workflow.required_gates_for("in_review", "closed", &[], 0);
assert!(
required
.iter()
.map(GateSpec::id)
.any(|id| id == "p0_sign_off")
);
let required_p3 = workflow.required_gates_for("in_review", "closed", &[], 3);
assert!(
!required_p3
.iter()
.map(GateSpec::id)
.any(|id| id == "p0_sign_off")
);
}
#[test]
fn evaluate_gates_blocks_when_required_named_gate_missing() {
let workflow = gate_workflow();
let violations = evaluate_gates(&workflow, "bd-1", "in_review", "closed", &[], 3, &[]);
let gate_ids: Vec<&str> = violations.iter().map(|v| v.gate.as_str()).collect();
assert!(gate_ids.contains(&"gate_ci_green"));
assert!(gate_ids.contains(&"gate_min_reviewers"));
}
#[test]
fn evaluate_gates_passes_when_named_gate_has_a_pass() {
let workflow = gate_workflow();
let results = vec![
GateResult {
gate: "ci_green".to_string(),
provider: "ci".to_string(),
passed: true,
note: None,
},
GateResult {
gate: "min_reviewers".to_string(),
provider: "reviewer:alice".to_string(),
passed: true,
note: None,
},
];
let violations = evaluate_gates(&workflow, "bd-1", "in_review", "closed", &[], 3, &results);
assert!(violations.is_empty(), "{violations:?}");
}
#[test]
fn evaluate_gates_named_gate_fail_does_not_satisfy() {
let workflow = gate_workflow();
let results = vec![GateResult {
gate: "ci_green".to_string(),
provider: "ci".to_string(),
passed: false,
note: None,
}];
let violations = evaluate_gates(&workflow, "bd-1", "in_review", "closed", &[], 3, &results);
assert!(violations.iter().any(|v| v.gate == "gate_ci_green"));
let ci_violation = violations
.iter()
.find(|v| v.gate == "gate_ci_green")
.unwrap();
assert!(
ci_violation.message.contains("ci"),
"{}",
ci_violation.message
);
}
#[test]
fn min_reviewers_counts_distinct_reviewer_providers() {
let yaml =
"strict: true\ngates:\n \"a -> b\":\n require_all:\n - min_reviewers: 2\n";
let workflow: Workflow = serde_yml::from_str(yaml).unwrap();
let one = vec![GateResult {
gate: "min_reviewers".to_string(),
provider: "reviewer:alice".to_string(),
passed: true,
note: None,
}];
assert_eq!(
evaluate_gates(&workflow, "bd-1", "a", "b", &[], 0, &one).len(),
1
);
let two = vec![
GateResult {
gate: "min_reviewers".to_string(),
provider: "reviewer:alice".to_string(),
passed: true,
note: None,
},
GateResult {
gate: "min_reviewers".to_string(),
provider: "reviewer:bob".to_string(),
passed: true,
note: None,
},
];
assert!(evaluate_gates(&workflow, "bd-1", "a", "b", &[], 0, &two).is_empty());
}
#[test]
fn min_reviewers_dedupes_same_reviewer() {
let yaml =
"strict: true\ngates:\n \"a -> b\":\n require_all:\n - min_reviewers: 2\n";
let workflow: Workflow = serde_yml::from_str(yaml).unwrap();
let dupes = vec![
GateResult {
gate: "min_reviewers".to_string(),
provider: "reviewer:alice".to_string(),
passed: true,
note: Some("first".to_string()),
},
GateResult {
gate: "min_reviewers".to_string(),
provider: "reviewer:alice".to_string(),
passed: true,
note: Some("again".to_string()),
},
];
assert_eq!(
evaluate_gates(&workflow, "bd-1", "a", "b", &[], 0, &dupes).len(),
1
);
}
#[test]
fn is_reviewer_provider_recognises_namespaced_forms() {
assert!(is_reviewer_provider("reviewer"));
assert!(is_reviewer_provider("reviewer:alice"));
assert!(is_reviewer_provider("reviewer-bob"));
assert!(is_reviewer_provider("Reviewer:Carol"));
assert!(!is_reviewer_provider("ci"));
assert!(!is_reviewer_provider("reviewers")); }
#[test]
fn evaluate_gates_no_rule_is_backward_compatible() {
let workflow = gate_workflow();
let violations = evaluate_gates(&workflow, "bd-1", "open", "in_progress", &[], 0, &[]);
assert!(violations.is_empty());
let empty = Workflow::default();
assert!(evaluate_gates(&empty, "bd-1", "in_review", "closed", &[], 0, &[]).is_empty());
}
#[test]
fn unknown_gate_spec_key_is_rejected_at_parse() {
let yaml = "strict: true\ngates:\n \"a -> b\":\n require_all:\n - bogus_key: 3\n";
let parsed: std::result::Result<Workflow, _> = serde_yml::from_str(yaml);
assert!(parsed.is_err(), "unknown gate spec key must fail parse");
}
#[test]
fn gates_key_recognised_by_unknown_field_detector() {
let yaml = r#"
workflow:
strict: true
gates:
"in_review -> closed":
require_all: [ci_green]
"#;
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
assert!(detect_unknown_policy_fields(&raw).is_empty());
}
}