use std::collections::{BTreeMap, BTreeSet};
use lex_vcs::{
render_signature, render_type_signature, Acceptance, ApiChangeKind, ApiEntry, Attestation,
AcceptanceProposal, AttestationId, AttestationKind, AttestationResult, IntentLog, Issue,
IssueId, IssueLog, OpLog, ProducerDescriptor, ProposalId, ReviewVerdict,
};
use serde::Serialize;
use crate::render::demangled_head_stages;
use crate::store::{Store, StoreError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueEvaluation {
Passed,
Failed { detail: String },
NotEvaluable { reason: String },
}
impl IssueEvaluation {
pub fn failed(detail: impl Into<String>) -> Self {
IssueEvaluation::Failed { detail: detail.into() }
}
pub fn not_evaluable(reason: impl Into<String>) -> Self {
IssueEvaluation::NotEvaluable { reason: reason.into() }
}
pub fn is_passed(&self) -> bool {
matches!(self, IssueEvaluation::Passed)
}
}
pub fn evaluate_static(
store: &Store,
issue: &Issue,
head_op: &str,
) -> Result<IssueEvaluation, StoreError> {
match &issue.acceptance {
Acceptance::FreeForm {} => Ok(IssueEvaluation::not_evaluable(
"free_form: human-closed, not machine-evaluable",
)),
Acceptance::MetricInvariant { .. } => Ok(IssueEvaluation::not_evaluable(
"metric_invariant: the metric/invariant oracle evaluator lands in #954",
)),
Acceptance::Evidence { .. } => Ok(IssueEvaluation::not_evaluable(
"evidence: the evidence oracle evaluator lands in #954",
)),
Acceptance::FailingExample { .. } => Ok(IssueEvaluation::Passed),
Acceptance::TypedDelta { api, .. } => {
check_api_delta(store, issue.base.as_deref(), head_op, api)
}
}
}
pub fn check_api_delta(
store: &Store,
base: Option<&str>,
head_op: &str,
api: &[ApiEntry],
) -> Result<IssueEvaluation, StoreError> {
let head = surface(&demangled_head_stages(store, head_op)?);
let base_surface = match base {
Some(b) => Some(surface(&demangled_head_stages(store, b)?)),
None => None,
};
let mut problems: Vec<String> = Vec::new();
for e in api {
let want = normalize_signature(&e.signature);
let at_head = head
.get(&e.name)
.and_then(|r| tail_after_name(r, &e.name))
.map(|t| normalize_signature(&t));
let at_base = base_surface
.as_ref()
.and_then(|b| b.get(&e.name))
.and_then(|r| tail_after_name(r, &e.name))
.map(|t| normalize_signature(&t));
match e.kind {
ApiChangeKind::Added => {
match &at_head {
None => problems.push(format!("`{}`: declared added, but absent at head", e.name)),
Some(h) if *h != want => problems.push(format!(
"`{}`: signature at head `{}` differs from declared `{}`",
e.name, h, want
)),
_ => {}
}
if at_base.is_some() {
problems.push(format!("`{}`: declared added, but already present at base", e.name));
}
}
ApiChangeKind::Changed => {
match &at_head {
None => problems.push(format!("`{}`: declared changed, but absent at head", e.name)),
Some(h) if *h != want => problems.push(format!(
"`{}`: signature at head `{}` differs from declared `{}`",
e.name, h, want
)),
_ => {}
}
if base_surface.is_some() {
match &at_base {
None => problems.push(format!("`{}`: declared changed, but absent at base", e.name)),
Some(b) if *b == want => problems.push(format!(
"`{}`: declared changed, but base already had this signature",
e.name
)),
_ => {}
}
}
}
ApiChangeKind::Removed => {
if at_head.is_some() {
problems.push(format!("`{}`: declared removed, but still present at head", e.name));
}
if base_surface.is_some() && at_base.is_none() {
problems.push(format!("`{}`: declared removed, but was absent at base", e.name));
}
}
}
}
if problems.is_empty() {
Ok(IssueEvaluation::Passed)
} else {
Ok(IssueEvaluation::failed(problems.join("; ")))
}
}
pub fn prepare_example_stages(
store: &Store,
head_op: &str,
cases: &[(String, lex_ast::Example)],
) -> Result<Vec<lex_ast::Stage>, StoreError> {
let mut stages = demangled_head_stages(store, head_op)?;
for st in &mut stages {
if let lex_ast::Stage::FnDecl(fd) = st {
fd.examples.clear();
}
}
for (name, example) in cases {
let target = stages.iter_mut().find_map(|st| match st {
lex_ast::Stage::FnDecl(fd) if &fd.name == name => Some(fd),
_ => None,
});
match target {
Some(fd) => fd.examples.push(example.clone()),
None => return Err(StoreError::IssueTarget(name.clone())),
}
}
Ok(stages)
}
pub fn record_issue_verdict(
store: &Store,
issue: &Issue,
head_op: &str,
evaluation: &IssueEvaluation,
) -> Result<AttestationId, StoreError> {
let result = match evaluation {
IssueEvaluation::Passed => AttestationResult::Passed,
IssueEvaluation::Failed { detail } => AttestationResult::Failed { detail: detail.clone() },
IssueEvaluation::NotEvaluable { reason } => {
AttestationResult::Inconclusive { detail: reason.clone() }
}
};
let attestation = Attestation::new(
issue.issue_id.clone(),
Some(head_op.to_string()),
None,
AttestationKind::IssueVerified {
issue_id: issue.issue_id.clone(),
shape: issue.acceptance.shape().to_string(),
},
result,
issue_gate_producer(),
None,
);
store.attestation_log()?.put(&attestation)?;
Ok(attestation.attestation_id.clone())
}
pub fn issue_verdicts(store: &Store, issue_id: &str) -> Result<Vec<Attestation>, StoreError> {
let all = store.attestation_log()?.list_for_stage(&issue_id.to_string())?;
Ok(all
.into_iter()
.filter(|a| matches!(a.kind, AttestationKind::IssueVerified { .. }))
.collect())
}
pub fn is_verified(store: &Store, issue_id: &str) -> Result<bool, StoreError> {
let latest = issue_verdicts(store, issue_id)?
.into_iter()
.max_by_key(|a| a.timestamp);
Ok(matches!(latest.map(|a| a.result), Some(AttestationResult::Passed)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueState {
Open,
InProgress,
Verified,
Blocked,
}
#[derive(Debug, Clone, Serialize)]
pub struct IssueStatus {
pub issue: Issue,
pub state: IssueState,
pub blocked_on: Vec<IssueId>,
pub has_work: bool,
}
pub fn issues_in_progress(store: &Store) -> Result<BTreeSet<IssueId>, StoreError> {
let log = OpLog::open(store.root())?;
let intents = IntentLog::open(store.root())?;
let mut out = BTreeSet::new();
let mut seen_intents: BTreeSet<String> = BTreeSet::new();
for branch in store.list_branches()? {
let Some(head) = store.get_branch(&branch).ok().flatten().and_then(|b| b.head_op) else {
continue;
};
for rec in log.walk_forward(&head, None)? {
let Some(iid) = rec.op.intent_id.clone() else { continue };
if !seen_intents.insert(iid.clone()) {
continue;
}
if let Some(intent) = intents.get(&iid)? {
if let Some(issue_id) = intent.issue_id {
out.insert(issue_id);
}
}
}
}
Ok(out)
}
pub fn issue_status(
store: &Store,
issue: &Issue,
in_progress: &BTreeSet<IssueId>,
) -> Result<IssueStatus, StoreError> {
let verified = is_verified(store, &issue.issue_id)?;
let mut blocked_on = Vec::new();
for dep in &issue.deps {
if !is_verified(store, dep)? {
blocked_on.push(dep.clone());
}
}
let has_work = in_progress.contains(&issue.issue_id);
let state = if verified {
IssueState::Verified
} else if !blocked_on.is_empty() {
IssueState::Blocked
} else if has_work {
IssueState::InProgress
} else {
IssueState::Open
};
Ok(IssueStatus { issue: issue.clone(), state, blocked_on, has_work })
}
pub fn all_issue_status(store: &Store) -> Result<Vec<IssueStatus>, StoreError> {
let log = IssueLog::open(store.root())?;
let in_progress = issues_in_progress(store)?;
let mut out = Vec::new();
for id in log.list_ids()? {
if let Some(issue) = log.get(&id)? {
out.push(issue_status(store, &issue, &in_progress)?);
}
}
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProposalStatus {
Pending,
Approved,
Rejected,
}
pub fn check_refinable(issue: &Issue) -> Result<(), StoreError> {
match issue.acceptance {
Acceptance::FreeForm {} => Ok(()),
ref a => Err(StoreError::IssueRefinement(format!(
"issue {} is already {} — only a free_form issue takes a proposed acceptance; \
file a new issue to change a typed contract",
issue.issue_id,
a.shape()
))),
}
}
pub fn record_proposal_review(
store: &Store,
proposal: &AcceptanceProposal,
reviewer: &str,
approve: bool,
notes: Option<String>,
) -> Result<AttestationId, StoreError> {
let (verdict, result) = if approve {
(ReviewVerdict::Approve, AttestationResult::Passed)
} else {
(
ReviewVerdict::Reject,
AttestationResult::Failed {
detail: notes.clone().unwrap_or_else(|| "proposal rejected".into()),
},
)
};
let attestation = Attestation::new(
proposal.proposal_id.clone(),
None,
None,
AttestationKind::Review { reviewer: reviewer.to_string(), verdict, notes },
result,
issue_gate_producer(),
None,
);
store.attestation_log()?.put(&attestation)?;
Ok(attestation.attestation_id.clone())
}
fn latest_review(store: &Store, proposal_id: &ProposalId) -> Result<Option<(bool, u64)>, StoreError> {
let latest = store
.attestation_log()?
.list_for_stage(proposal_id)?
.into_iter()
.filter_map(|a| match a.kind {
AttestationKind::Review { verdict, .. } => {
Some((verdict == ReviewVerdict::Approve, a.timestamp))
}
_ => None,
})
.max_by_key(|(approved, ts)| (*ts, !*approved));
Ok(latest)
}
pub fn proposal_status(store: &Store, proposal_id: &ProposalId) -> Result<ProposalStatus, StoreError> {
Ok(match latest_review(store, proposal_id)? {
None => ProposalStatus::Pending,
Some((true, _)) => ProposalStatus::Approved,
Some((false, _)) => ProposalStatus::Rejected,
})
}
pub fn effective_acceptance(
store: &Store,
issue: &Issue,
) -> Result<(Acceptance, Option<ProposalId>), StoreError> {
let log = IssueLog::open(store.root())?;
let mut best: Option<(u64, AcceptanceProposal)> = None;
for p in log.proposals_for(&issue.issue_id)? {
if let Some((true, ts)) = latest_review(store, &p.proposal_id)? {
if best
.as_ref()
.is_none_or(|(b, bp)| (ts, &p.proposal_id) > (*b, &bp.proposal_id))
{
best = Some((ts, p));
}
}
}
Ok(match best {
Some((_, p)) => (p.acceptance, Some(p.proposal_id)),
None => (issue.acceptance.clone(), None),
})
}
pub fn with_effective_acceptance(store: &Store, issue: &Issue) -> Result<Issue, StoreError> {
let (acceptance, _) = effective_acceptance(store, issue)?;
Ok(Issue { acceptance, ..issue.clone() })
}
fn issue_gate_producer() -> ProducerDescriptor {
ProducerDescriptor {
tool: "lex-store::issue-gate".into(),
version: env!("CARGO_PKG_VERSION").into(),
model: None,
}
}
fn surface(stages: &[lex_ast::Stage]) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for st in stages {
match st {
lex_ast::Stage::FnDecl(fd) => {
out.insert(fd.name.clone(), render_signature(fd));
}
lex_ast::Stage::TypeDecl(td) => {
out.insert(td.name.clone(), render_type_signature(td));
}
lex_ast::Stage::Import(_) => {}
}
}
out
}
fn tail_after_name(rendered: &str, name: &str) -> Option<String> {
let rest = rendered
.strip_prefix("fn ")
.or_else(|| rendered.strip_prefix("type "))?;
let tail = rest.strip_prefix(name)?;
Some(tail.trim().to_string())
}
fn squash(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace()).collect()
}
pub(crate) fn normalize_signature(sig: &str) -> String {
let chars: Vec<char> = squash(sig).chars().collect();
let (items, _) = parse_items(&chars, 0, None);
items.join(",")
}
fn parse_items(cs: &[char], mut i: usize, close: Option<char>) -> (Vec<String>, usize) {
let mut items = Vec::new();
let mut cur = String::new();
while i < cs.len() {
let c = cs[i];
match c {
'{' | '(' | '[' => {
let closer = match c {
'{' => '}',
'(' => ')',
_ => ']',
};
let (mut inner, next) = parse_items(cs, i + 1, Some(closer));
if c == '{' && !inner.is_empty() && inner.iter().all(|it| it.contains("::")) {
inner.sort_by(|a, b| field_name(a).cmp(field_name(b)));
}
cur.push(c);
cur.push_str(&inner.join(","));
cur.push(closer);
i = next;
}
')' | ']' | '}' if Some(c) == close => {
items.push(cur);
return (items, i + 1);
}
',' => {
items.push(std::mem::take(&mut cur));
i += 1;
}
_ => {
cur.push(c);
i += 1;
}
}
}
items.push(cur);
(items, i)
}
fn field_name(item: &str) -> &str {
item.split("::").next().unwrap_or(item)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tail_strips_keyword_and_name() {
assert_eq!(
tail_after_name("fn gcd(a :: Int, b :: Int) -> Int", "gcd").as_deref(),
Some("(a :: Int, b :: Int) -> Int")
);
assert_eq!(tail_after_name("type Shape = A | B", "Shape").as_deref(), Some("= A | B"));
assert_eq!(tail_after_name("fn gcd(a :: Int) -> Int", "lcm"), None);
}
#[test]
fn record_field_order_is_not_semantic() {
assert_eq!(
normalize_signature("() -> { zed :: Int, abc :: Int }"),
normalize_signature("() -> { abc :: Int, zed :: Int }")
);
assert_eq!(
normalize_signature("(x :: List[{ b :: { z :: Int, a :: Int }, a :: Str }]) -> Int"),
"(x::List[{a::Str,b::{a::Int,z::Int}}])->Int"
);
assert_eq!(normalize_signature("(a :: Map[Str, Int], b :: Int) -> [net, io] Int"), "(a::Map[Str,Int],b::Int)->[net,io]Int");
assert_ne!(
normalize_signature("() -> { a :: Int, b :: Int }"),
normalize_signature("() -> { a :: Int, b :: Str }")
);
assert_ne!(normalize_signature("() -> { a :: Int }"), normalize_signature("() -> { b :: Int }"));
}
#[test]
fn squash_ignores_whitespace_only() {
assert_eq!(squash("(a :: Int, b :: Int) -> Int"), squash("(a::Int,b::Int)->Int"));
assert_ne!(squash("(a :: Int) -> Int"), squash("(a :: Str) -> Int"));
}
}