use crate::events::EventKind;
use crate::gate::{GateKind, GateReport, GateSurface};
use std::path::{Component, Path, PathBuf};
pub const FILE_REF_SCHEME: &str = "file:";
pub fn file_artefact_ref(mission_relative: &str) -> String {
format!("{FILE_REF_SCHEME}{mission_relative}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArtefactResolution {
Resolved { path: PathBuf },
Unresolved { path: PathBuf },
Inline,
}
pub fn resolve_artefact(mission_dir: &Path, reference: &str) -> ArtefactResolution {
let Some(relative) = reference.strip_prefix(FILE_REF_SCHEME) else {
return ArtefactResolution::Inline;
};
let rel_path = Path::new(relative);
let honest = !relative.is_empty()
&& rel_path
.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir));
let path = mission_dir.join(rel_path);
let resolved = honest
&& std::fs::symlink_metadata(&path)
.map(|m| m.file_type().is_file())
.unwrap_or(false);
if resolved {
ArtefactResolution::Resolved { path }
} else {
ArtefactResolution::Unresolved { path }
}
}
pub fn gate_result_events(surface: GateSurface, reports: &[GateReport]) -> Vec<EventKind> {
let mut deterministic_index = 0u32;
let mut model_judged_index = 0u32;
reports
.iter()
.map(|report| {
let index = match report.kind {
GateKind::Deterministic => {
let index = deterministic_index;
deterministic_index += 1;
index
}
GateKind::ModelJudged => {
let index = model_judged_index;
model_judged_index += 1;
index
}
};
EventKind::GateResult {
gate: report.name.clone(),
surface,
kind: report.kind,
index,
verdict: report.outcome.verdict,
artefact_ref: report.outcome.artefact.reference.clone(),
artefact_detail: report.outcome.artefact.detail.clone(),
score: report.outcome.score.map(|score| score.score),
threshold: report.outcome.score.map(|score| score.threshold),
rule_ids: report.outcome.rule_ids.clone(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gate::{ArtefactRef, GateOutcome, GateVerdict};
fn report(name: &str, kind: GateKind, verdict: GateVerdict) -> GateReport {
let outcome = match verdict {
GateVerdict::Pass => GateOutcome::pass(ArtefactRef::new(format!("ref {name}"))),
GateVerdict::Fail => {
GateOutcome::fail(ArtefactRef::new(format!("ref {name}")).with_detail("boom"))
}
};
GateReport {
name: name.to_string(),
kind,
outcome,
}
}
#[test]
fn gate_result_events_assign_per_section_indices_in_pipeline_order() {
let mut det_pass = report("det-a", GateKind::Deterministic, GateVerdict::Pass);
det_pass.outcome.score = Some(crate::gate::GateScore {
score: 0.9,
threshold: 0.5,
});
let reports = vec![
det_pass,
report("det-b", GateKind::Deterministic, GateVerdict::Fail),
report("model-a", GateKind::ModelJudged, GateVerdict::Pass),
];
let events = gate_result_events(GateSurface::FinalGate, &reports);
assert_eq!(events.len(), 3);
let shape: Vec<(String, GateKind, u32, GateVerdict)> = events
.iter()
.map(|event| match event {
EventKind::GateResult {
gate,
kind,
index,
verdict,
..
} => (gate.clone(), *kind, *index, *verdict),
_ => panic!("wrong variant"),
})
.collect();
assert_eq!(
shape,
vec![
(
"det-a".to_string(),
GateKind::Deterministic,
0,
GateVerdict::Pass
),
(
"det-b".to_string(),
GateKind::Deterministic,
1,
GateVerdict::Fail
),
(
"model-a".to_string(),
GateKind::ModelJudged,
0,
GateVerdict::Pass
),
]
);
match &events[0] {
EventKind::GateResult {
artefact_ref,
artefact_detail,
score,
threshold,
..
} => {
assert_eq!(artefact_ref, "ref det-a");
assert_eq!(*artefact_detail, None);
assert_eq!(*score, Some(0.9));
assert_eq!(*threshold, Some(0.5));
}
_ => panic!("wrong variant"),
}
match &events[1] {
EventKind::GateResult {
artefact_detail, ..
} => assert_eq!(artefact_detail.as_deref(), Some("boom")),
_ => panic!("wrong variant"),
}
}
#[test]
fn gate_result_events_empty_pipeline_emits_nothing() {
assert!(gate_result_events(GateSurface::Approval, &[]).is_empty());
}
#[test]
fn gate_result_event_file_ref_resolves_when_bytes_exist() {
let tmp = tempfile::TempDir::new().unwrap();
let mission_dir = tmp.path();
std::fs::create_dir_all(mission_dir.join("runs")).unwrap();
std::fs::write(mission_dir.join("runs").join("r-1.jsonl"), b"{}").unwrap();
let resolved = resolve_artefact(mission_dir, &file_artefact_ref("runs/r-1.jsonl"));
match resolved {
ArtefactResolution::Resolved { path } => {
assert_eq!(path, mission_dir.join("runs/r-1.jsonl"))
}
other => panic!("expected resolved, got {other:?}"),
}
}
#[test]
fn gate_result_event_file_ref_is_unresolved_when_bytes_are_gone() {
let tmp = tempfile::TempDir::new().unwrap();
let mission_dir = tmp.path();
assert_eq!(
resolve_artefact(mission_dir, &file_artefact_ref("runs/r-1.jsonl")),
ArtefactResolution::Unresolved {
path: mission_dir.join("runs/r-1.jsonl")
}
);
std::fs::create_dir_all(mission_dir.join("runs")).unwrap();
assert!(matches!(
resolve_artefact(mission_dir, &file_artefact_ref("runs")),
ArtefactResolution::Unresolved { .. }
));
}
#[test]
fn gate_result_event_file_ref_escape_shapes_are_unresolved() {
let tmp = tempfile::TempDir::new().unwrap();
let mission_dir = tmp.path();
for reference in [
file_artefact_ref("../outside.jsonl"),
file_artefact_ref("runs/../../escape"),
file_artefact_ref("/etc/passwd"),
file_artefact_ref(""),
] {
assert!(
matches!(
resolve_artefact(mission_dir, &reference),
ArtefactResolution::Unresolved { .. }
),
"{reference} must classify unresolved"
);
}
}
#[test]
fn gate_result_event_textual_ref_is_inline() {
let tmp = tempfile::TempDir::new().unwrap();
for reference in [
"contract gate vacuous-filter",
"cargo test --workspace",
".kranz/merge-gates.json",
] {
assert_eq!(
resolve_artefact(tmp.path(), reference),
ArtefactResolution::Inline,
"{reference} must classify inline"
);
}
}
#[cfg(unix)]
#[test]
fn gate_result_event_symlinked_artefact_is_unresolved() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let mission_dir = tmp.path().join("mission");
std::fs::create_dir_all(mission_dir.join("runs")).unwrap();
let elsewhere = tmp.path().join("elsewhere.jsonl");
std::fs::write(&elsewhere, b"{}").unwrap();
symlink(&elsewhere, mission_dir.join("runs").join("r-1.jsonl")).unwrap();
assert!(matches!(
resolve_artefact(&mission_dir, &file_artefact_ref("runs/r-1.jsonl")),
ArtefactResolution::Unresolved { .. }
));
}
}